From 248418f8cd41c55ed26f627d35f158150232663e Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 07:37:12 -0500 Subject: [PATCH 001/143] feat(tbtc): add covenant signer job substrate --- cmd/flags.go | 12 + cmd/flags_test.go | 7 + cmd/start.go | 10 + config/category.go | 3 + config/config.go | 16 +- config/config_test.go | 4 + pkg/covenantsigner/config.go | 7 + pkg/covenantsigner/covenantsigner_test.go | 358 ++++++++++++++++++++++ pkg/covenantsigner/doc.go | 4 + pkg/covenantsigner/engine.go | 39 +++ pkg/covenantsigner/server.go | 179 +++++++++++ pkg/covenantsigner/service.go | 219 +++++++++++++ pkg/covenantsigner/store.go | 173 +++++++++++ pkg/covenantsigner/types.go | 151 +++++++++ pkg/covenantsigner/validation.go | 151 +++++++++ test/config.json | 3 + test/config.toml | 3 + test/config.yaml | 2 + 18 files changed, 1334 insertions(+), 7 deletions(-) create mode 100644 pkg/covenantsigner/config.go create mode 100644 pkg/covenantsigner/covenantsigner_test.go create mode 100644 pkg/covenantsigner/doc.go create mode 100644 pkg/covenantsigner/engine.go create mode 100644 pkg/covenantsigner/server.go create mode 100644 pkg/covenantsigner/service.go create mode 100644 pkg/covenantsigner/store.go create mode 100644 pkg/covenantsigner/types.go create mode 100644 pkg/covenantsigner/validation.go diff --git a/cmd/flags.go b/cmd/flags.go index 6ce094c2e6..fc581e7bab 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -15,6 +15,7 @@ import ( "github.com/keep-network/keep-core/pkg/bitcoin/electrum" chainEthereum "github.com/keep-network/keep-core/pkg/chain/ethereum" "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/covenantsigner" "github.com/keep-network/keep-core/pkg/maintainer/spv" "github.com/keep-network/keep-core/pkg/net/libp2p" "github.com/keep-network/keep-core/pkg/tbtc" @@ -46,6 +47,8 @@ func initFlags( initStorageFlags(cmd, cfg) case config.ClientInfo: initClientInfoFlags(cmd, cfg) + case config.CovenantSigner: + initCovenantSignerFlags(cmd, cfg) case config.Tbtc: initTbtcFlags(cmd, cfg) case config.Maintainer: @@ -310,6 +313,15 @@ func initTbtcFlags(cmd *cobra.Command, cfg *config.Config) { ) } +func initCovenantSignerFlags(cmd *cobra.Command, cfg *config.Config) { + cmd.Flags().IntVar( + &cfg.CovenantSigner.Port, + "covenantSigner.port", + covenantsigner.Config{}.Port, + "Covenant signer provider HTTP server listening port. Zero disables the service.", + ) +} + // Initialize flags for Maintainer configuration. func initMaintainerFlags(command *cobra.Command, cfg *config.Config) { command.Flags().BoolVar( diff --git a/cmd/flags_test.go b/cmd/flags_test.go index 58ee1249ae..4bc23e68a5 100644 --- a/cmd/flags_test.go +++ b/cmd/flags_test.go @@ -190,6 +190,13 @@ var cmdFlagsTests = map[string]struct { expectedValueFromFlag: 76 * time.Second, defaultValue: 10 * time.Minute, }, + "covenantSigner.port": { + readValueFunc: func(c *config.Config) interface{} { return c.CovenantSigner.Port }, + flagName: "--covenantSigner.port", + flagValue: "9711", + expectedValueFromFlag: 9711, + defaultValue: 0, + }, "tbtc.preParamsPoolSize": { readValueFunc: func(c *config.Config) interface{} { return c.Tbtc.PreParamsPoolSize }, flagName: "--tbtc.preParamsPoolSize", diff --git a/cmd/start.go b/cmd/start.go index cfaece274c..66b79d76fa 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -20,6 +20,7 @@ import ( "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/ethereum" "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/covenantsigner" "github.com/keep-network/keep-core/pkg/firewall" "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/net" @@ -174,6 +175,15 @@ func start(cmd *cobra.Command) error { if err != nil { return fmt.Errorf("error initializing TBTC: [%v]", err) } + + _, _, err = covenantsigner.Initialize( + ctx, + clientConfig.CovenantSigner, + tbtcDataPersistence, + ) + if err != nil { + return fmt.Errorf("error initializing covenant signer: [%v]", err) + } } nodeHeader( diff --git a/config/category.go b/config/category.go index f6b3f2ab0c..4896afd854 100644 --- a/config/category.go +++ b/config/category.go @@ -9,6 +9,7 @@ const ( Network Storage ClientInfo + CovenantSigner Tbtc Maintainer Developer @@ -22,6 +23,7 @@ var StartCmdCategories = []Category{ Network, Storage, ClientInfo, + CovenantSigner, Tbtc, Developer, } @@ -41,6 +43,7 @@ var AllCategories = []Category{ Network, Storage, ClientInfo, + CovenantSigner, Tbtc, Maintainer, Developer, diff --git a/config/config.go b/config/config.go index 92081b2f10..b7183e143a 100644 --- a/config/config.go +++ b/config/config.go @@ -21,6 +21,7 @@ import ( commonEthereum "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-core/pkg/bitcoin/electrum" "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/covenantsigner" "github.com/keep-network/keep-core/pkg/maintainer" "github.com/keep-network/keep-core/pkg/net/libp2p" "github.com/keep-network/keep-core/pkg/storage" @@ -45,13 +46,14 @@ const ( // Config is the top level config structure. type Config struct { - Ethereum commonEthereum.Config - Bitcoin BitcoinConfig - LibP2P libp2p.Config `mapstructure:"network"` - Storage storage.Config - ClientInfo clientinfo.Config - Maintainer maintainer.Config - Tbtc tbtc.Config + Ethereum commonEthereum.Config + Bitcoin BitcoinConfig + LibP2P libp2p.Config `mapstructure:"network"` + Storage storage.Config + ClientInfo clientinfo.Config + CovenantSigner covenantsigner.Config + Maintainer maintainer.Config + Tbtc tbtc.Config } // BitcoinConfig defines the configuration for Bitcoin. diff --git a/config/config_test.go b/config/config_test.go index 26d8a74fea..8f63b7ea99 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -199,6 +199,10 @@ func TestReadConfigFromFile(t *testing.T) { readValueFunc: func(c *Config) interface{} { return c.ClientInfo.EthereumMetricsTick }, expectedValue: 87 * time.Second, }, + "CovenantSigner.Port": { + readValueFunc: func(c *Config) interface{} { return c.CovenantSigner.Port }, + expectedValue: 9702, + }, "Maintainer.BitcoinDifficulty.Enabled": { readValueFunc: func(c *Config) interface{} { return c.Maintainer.BitcoinDifficulty.Enabled }, expectedValue: true, diff --git a/pkg/covenantsigner/config.go b/pkg/covenantsigner/config.go new file mode 100644 index 0000000000..75fb5c118e --- /dev/null +++ b/pkg/covenantsigner/config.go @@ -0,0 +1,7 @@ +package covenantsigner + +// Config configures the covenant signer HTTP service. +type Config struct { + // Port enables the covenant signer provider HTTP surface when non-zero. + Port int +} diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go new file mode 100644 index 0000000000..1742c97add --- /dev/null +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -0,0 +1,358 @@ +package covenantsigner + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/keep-network/keep-common/pkg/persistence" +) + +type memoryDescriptor struct { + name string + directory string + content []byte +} + +func (md *memoryDescriptor) Name() string { return md.name } +func (md *memoryDescriptor) Directory() string { return md.directory } +func (md *memoryDescriptor) Content() ([]byte, error) { + return md.content, nil +} + +type memoryHandle struct { + items map[string]*memoryDescriptor +} + +func newMemoryHandle() *memoryHandle { + return &memoryHandle{items: make(map[string]*memoryDescriptor)} +} + +func (mh *memoryHandle) key(directory, name string) string { + return directory + "/" + name +} + +func (mh *memoryHandle) Save(data []byte, directory string, name string) error { + mh.items[mh.key(directory, name)] = &memoryDescriptor{ + name: name, + directory: directory, + content: append([]byte{}, data...), + } + return nil +} + +func (mh *memoryHandle) Delete(directory string, name string) error { + delete(mh.items, mh.key(directory, name)) + return nil +} + +func (mh *memoryHandle) ReadAll() (<-chan persistence.DataDescriptor, <-chan error) { + dataChan := make(chan persistence.DataDescriptor, len(mh.items)) + errorChan := make(chan error) + for _, item := range mh.items { + dataChan <- item + } + close(dataChan) + close(errorChan) + return dataChan, errorChan +} + +type scriptedEngine struct { + submit func(*Job) (*Transition, error) + poll func(*Job) (*Transition, error) +} + +func (se *scriptedEngine) OnSubmit(_ context.Context, job *Job) (*Transition, error) { + if se.submit == nil { + return nil, nil + } + return se.submit(job) +} + +func (se *scriptedEngine) OnPoll(_ context.Context, job *Job) (*Transition, error) { + if se.poll == nil { + return nil, nil + } + return se.poll(job) +} + +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} + +func validSelfTemplate() json.RawMessage { + return mustTemplate(SelfV1Template{ + Template: TemplateSelfV1, + DepositorPublicKey: "0x021111", + SignerPublicKey: "0x022222", + Delta2: 4320, + }) +} + +func validQcTemplate() json.RawMessage { + return mustTemplate(QcV1Template{ + Template: TemplateQcV1, + DepositorPublicKey: "0x021111", + CustodianPublicKey: "0x023333", + SignerPublicKey: "0x022222", + Beta: 144, + Delta2: 4320, + }) +} + +func mustTemplate(value any) json.RawMessage { + data, _ := json.Marshal(value) + return data +} + +func baseRequest(route TemplateID) RouteSubmitRequest { + request := RouteSubmitRequest{ + FacadeRequestID: "rf_123", + IdempotencyKey: "idem_123", + Route: route, + Strategy: "0x1234", + Reserve: "0xabcd", + Epoch: 12, + MaturityHeight: 912345, + ActiveOutpoint: CovenantOutpoint{TxID: "0x0102", Vout: 1, ScriptHash: "0x0304"}, + DestinationCommitmentHash: "0x0506", + ArtifactSignatures: []string{"0x0708"}, + Artifacts: map[RecoveryPathID]ArtifactRecord{}, + } + + switch route { + case TemplateSelfV1: + request.ScriptTemplate = validSelfTemplate() + request.Signing = SigningRequirements{SignerRequired: true, CustodianRequired: false} + case TemplateQcV1: + request.ScriptTemplate = validQcTemplate() + request.Signing = SigningRequirements{SignerRequired: true, CustodianRequired: true} + } + + return request +} + +func TestServiceSubmitDeduplicatesByRouteRequestID(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + input := SignerSubmitInput{ + RouteRequestID: "ors_123", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + } + + first, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + t.Fatal(err) + } + + second, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + t.Fatal(err) + } + + if first.RequestID == "" { + t.Fatal("expected durable request id") + } + if first.RequestID != second.RequestID { + t.Fatalf("expected dedupe on routeRequestId, got %s vs %s", first.RequestID, second.RequestID) + } +} + +func TestServicePollCanTransitionToReady(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return &Transition{ + State: JobStateArtifactReady, + Detail: "artifact ready", + PSBTHash: "0x090a", + TransactionHex: "0x0b0c", + }, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_ready", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err != nil { + t.Fatal(err) + } + + pollResult, err := service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "ors_ready", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err != nil { + t.Fatal(err) + } + + if pollResult.Status != StepStatusReady { + t.Fatalf("expected READY, got %s", pollResult.Status) + } + if pollResult.PSBTHash != "0x090a" || pollResult.TransactionHex != "0x0b0c" { + t.Fatalf("unexpected ready payload: %#v", pollResult) + } +} + +func TestServicePollMapsJobNotFoundToFailed(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return nil, errJobNotFound + }, + }) + if err != nil { + t.Fatal(err) + } + + submitResult, err := service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_missing", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + }) + if err != nil { + t.Fatal(err) + } + + pollResult, err := service.Poll(context.Background(), TemplateQcV1, SignerPollInput{ + RouteRequestID: "orq_missing", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + }) + if err != nil { + t.Fatal(err) + } + + if pollResult.Status != StepStatusFailed || pollResult.Reason != ReasonJobNotFound { + t.Fatalf("unexpected failed result: %#v", pollResult) + } +} + +func TestStoreReloadPreservesJobs(t *testing.T) { + handle := newMemoryHandle() + store, err := NewStore(handle) + if err != nil { + t.Fatal(err) + } + + job := &Job{ + RequestID: "kcs_self_1234", + RouteRequestID: "ors_reload", + Route: TemplateSelfV1, + IdempotencyKey: "idem_reload", + FacadeRequestID: "rf_reload", + RequestDigest: "0xdeadbeef", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + if err := store.Put(job); err != nil { + t.Fatal(err) + } + + reloaded, err := NewStore(handle) + if err != nil { + t.Fatal(err) + } + + loadedJob, ok, err := reloaded.GetByRouteRequest(TemplateSelfV1, "ors_reload") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected persisted job") + } + if !reflect.DeepEqual(job.Request, loadedJob.Request) { + t.Fatalf("unexpected reloaded request: %#v", loadedJob.Request) + } +} + +func TestServerHandlesSubmitAndPathPoll(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service)) + defer server.Close() + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + response, err := http.Post(server.URL+"/v1/self_v1/signer/requests", "application/json", bytes.NewReader(submitPayload)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected submit status: %d %s", response.StatusCode, string(body)) + } + + submitResult := StepResult{} + if err := json.NewDecoder(response.Body).Decode(&submitResult); err != nil { + t.Fatal(err) + } + + pollPayload := mustJSON(t, SignerPollInput{ + RouteRequestID: "ors_http", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + pollResponse, err := http.Post(server.URL+"/v1/self_v1/signer/requests/"+submitResult.RequestID+":poll", "application/json", bytes.NewReader(pollPayload)) + if err != nil { + t.Fatal(err) + } + defer pollResponse.Body.Close() + + if pollResponse.StatusCode != http.StatusOK { + body, _ := io.ReadAll(pollResponse.Body) + t.Fatalf("unexpected poll status: %d %s", pollResponse.StatusCode, string(body)) + } +} diff --git a/pkg/covenantsigner/doc.go b/pkg/covenantsigner/doc.go new file mode 100644 index 0000000000..1883107b83 --- /dev/null +++ b/pkg/covenantsigner/doc.go @@ -0,0 +1,4 @@ +// Package covenantsigner implements the first keep-core covenant signer +// extension slice: durable submit/poll semantics, request validation, and a +// compatible HTTP surface for covenant recovery/presign signer jobs. +package covenantsigner diff --git a/pkg/covenantsigner/engine.go b/pkg/covenantsigner/engine.go new file mode 100644 index 0000000000..c1eab76cf0 --- /dev/null +++ b/pkg/covenantsigner/engine.go @@ -0,0 +1,39 @@ +package covenantsigner + +import ( + "context" + "errors" +) + +var errJobNotFound = errors.New("covenant signer job not found") + +type Transition struct { + State JobState + Detail string + Reason FailureReason + PSBTHash string + TransactionHex string + Handoff map[string]any +} + +type Engine interface { + OnSubmit(ctx context.Context, job *Job) (*Transition, error) + OnPoll(ctx context.Context, job *Job) (*Transition, error) +} + +type passiveEngine struct{} + +func NewPassiveEngine() Engine { + return &passiveEngine{} +} + +func (pe *passiveEngine) OnSubmit(context.Context, *Job) (*Transition, error) { + return &Transition{ + State: JobStatePending, + Detail: "accepted for covenant signing", + }, nil +} + +func (pe *passiveEngine) OnPoll(context.Context, *Job) (*Transition, error) { + return nil, nil +} diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go new file mode 100644 index 0000000000..1be0ea30a4 --- /dev/null +++ b/pkg/covenantsigner/server.go @@ -0,0 +1,179 @@ +package covenantsigner + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-common/pkg/persistence" +) + +var logger = log.Logger("keep-covenant-signer") + +type Server struct { + service *Service + httpServer *http.Server +} + +func Initialize(ctx context.Context, config Config, handle persistence.BasicHandle) (*Server, bool, error) { + if config.Port == 0 { + return nil, false, nil + } + + service, err := NewService(handle, NewPassiveEngine()) + if err != nil { + return nil, false, err + } + + server := &Server{ + service: service, + httpServer: &http.Server{ + Addr: fmt.Sprintf(":%d", config.Port), + Handler: newHandler(service), + }, + } + + go func() { + <-ctx.Done() + _ = server.httpServer.Shutdown(context.Background()) + }() + + go func() { + if err := server.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Errorf("covenant signer server failed: [%v]", err) + } + }() + + logger.Infof("enabled covenant signer provider endpoint on port [%v]", config.Port) + + return server, true, nil +} + +func newHandler(service *Service) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) + + mux.HandleFunc("POST /v1/self_v1/signer/requests", submitHandler(service, TemplateSelfV1)) + mux.HandleFunc("POST /v1/qc_v1/signer/requests", submitHandler(service, TemplateQcV1)) + mux.HandleFunc("POST /v1/self_v1/signer/requests:poll", pollBodyHandler(service, TemplateSelfV1)) + mux.HandleFunc("POST /v1/qc_v1/signer/requests:poll", pollBodyHandler(service, TemplateQcV1)) + mux.HandleFunc("/v1/self_v1/signer/requests/", pollPathHandler(service, TemplateSelfV1)) + mux.HandleFunc("/v1/qc_v1/signer/requests/", pollPathHandler(service, TemplateQcV1)) + + return mux +} + +func decodeJSON[T any](w http.ResponseWriter, r *http.Request, target *T) bool { + defer r.Body.Close() + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return false + } + + return true +} + +func writeJSON(w http.ResponseWriter, statusCode int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(payload) +} + +func handleError(w http.ResponseWriter, err error) { + var inputErr *inputError + if errors.As(err, &inputErr) { + http.Error(w, inputErr.Error(), http.StatusBadRequest) + return + } + if errors.Is(err, errJobNotFound) { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) +} + +func submitHandler(service *Service, route TemplateID) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + input := SignerSubmitInput{} + if !decodeJSON(w, r, &input) { + return + } + + result, err := service.Submit(r.Context(), route, input) + if err != nil { + handleError(w, err) + return + } + + writeJSON(w, http.StatusOK, result) + } +} + +func pollBodyHandler(service *Service, route TemplateID) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + input := SignerPollInput{} + if !decodeJSON(w, r, &input) { + return + } + + result, err := service.Poll(r.Context(), route, input) + if err != nil { + handleError(w, err) + return + } + + writeJSON(w, http.StatusOK, result) + } +} + +func pollPathHandler(service *Service, route TemplateID) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + prefix := "/v1/" + string(route) + "/signer/requests/" + if !strings.HasPrefix(r.URL.Path, prefix) || !strings.HasSuffix(r.URL.Path, ":poll") { + http.NotFound(w, r) + return + } + + pathRequestID := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, prefix), ":poll") + if pathRequestID == "" || strings.Contains(pathRequestID, "/") { + http.NotFound(w, r) + return + } + + input := SignerPollInput{} + if !decodeJSON(w, r, &input) { + return + } + if input.RequestID != "" && input.RequestID != pathRequestID { + http.Error(w, "requestId in body does not match path", http.StatusBadRequest) + return + } + input.RequestID = pathRequestID + + result, err := service.Poll(r.Context(), route, input) + if err != nil { + handleError(w, err) + return + } + + writeJSON(w, http.StatusOK, result) + } +} diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go new file mode 100644 index 0000000000..21ac271f1d --- /dev/null +++ b/pkg/covenantsigner/service.go @@ -0,0 +1,219 @@ +package covenantsigner + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "github.com/keep-network/keep-common/pkg/persistence" +) + +type Service struct { + store *Store + engine Engine + now func() time.Time +} + +func NewService(handle persistence.BasicHandle, engine Engine) (*Service, error) { + if engine == nil { + engine = NewPassiveEngine() + } + + store, err := NewStore(handle) + if err != nil { + return nil, err + } + + return &Service{ + store: store, + engine: engine, + now: time.Now().UTC, + }, nil +} + +func newRequestID(prefix string) (string, error) { + randomBytes := make([]byte, 8) + if _, err := rand.Read(randomBytes); err != nil { + return "", err + } + + return fmt.Sprintf("%s_%s", prefix, hex.EncodeToString(randomBytes)), nil +} + +func applyTransition(job *Job, transition *Transition, now time.Time) { + if transition == nil { + return + } + + job.State = transition.State + job.Detail = transition.Detail + job.Reason = transition.Reason + job.PSBTHash = transition.PSBTHash + job.TransactionHex = transition.TransactionHex + job.Handoff = transition.Handoff + job.UpdatedAt = now.Format(time.RFC3339Nano) + + switch transition.State { + case JobStateArtifactReady, JobStateHandoffReady: + job.CompletedAt = job.UpdatedAt + job.FailedAt = "" + case JobStateFailed: + job.FailedAt = job.UpdatedAt + } +} + +func mapJobResult(job *Job) StepResult { + switch job.State { + case JobStateArtifactReady: + return StepResult{ + Status: StepStatusReady, + RequestID: job.RequestID, + Detail: job.Detail, + PSBTHash: job.PSBTHash, + TransactionHex: job.TransactionHex, + } + case JobStateHandoffReady: + return StepResult{ + Status: StepStatusReady, + RequestID: job.RequestID, + Detail: job.Detail, + Handoff: job.Handoff, + } + case JobStateFailed: + return StepResult{ + Status: StepStatusFailed, + RequestID: job.RequestID, + Detail: job.Detail, + Reason: job.Reason, + } + default: + return StepResult{ + Status: StepStatusPending, + RequestID: job.RequestID, + Detail: job.Detail, + } + } +} + +func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubmitInput) (StepResult, error) { + if err := validateSubmitInput(route, input); err != nil { + return StepResult{}, err + } + + if existing, ok, err := s.store.GetByRouteRequest(route, input.RouteRequestID); err != nil { + return StepResult{}, err + } else if ok { + return mapJobResult(existing), nil + } + + requestIDPrefix := "kcs" + if route == TemplateQcV1 { + requestIDPrefix = "kcs_qc" + } else if route == TemplateSelfV1 { + requestIDPrefix = "kcs_self" + } + + requestID, err := newRequestID(requestIDPrefix) + if err != nil { + return StepResult{}, err + } + + now := s.now() + requestDigest, err := requestDigest(input.Request) + if err != nil { + return StepResult{}, err + } + + job := &Job{ + RequestID: requestID, + RouteRequestID: input.RouteRequestID, + Route: route, + IdempotencyKey: input.Request.IdempotencyKey, + FacadeRequestID: input.Request.FacadeRequestID, + RequestDigest: requestDigest, + State: JobStateSubmitted, + Detail: "accepted for covenant signing", + CreatedAt: now.Format(time.RFC3339Nano), + UpdatedAt: now.Format(time.RFC3339Nano), + Request: input.Request, + } + + if err := s.store.Put(job); err != nil { + return StepResult{}, err + } + + transition, err := s.engine.OnSubmit(ctx, job) + if err != nil { + return StepResult{}, err + } + + if transition == nil { + transition = &Transition{ + State: JobStatePending, + Detail: "accepted for covenant signing", + } + } + + applyTransition(job, transition, s.now()) + if err := s.store.Put(job); err != nil { + return StepResult{}, err + } + + return mapJobResult(job), nil +} + +func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollInput) (StepResult, error) { + if err := validatePollInput(route, input); err != nil { + return StepResult{}, err + } + + job, ok, err := s.store.GetByRequestID(input.RequestID) + if err != nil { + return StepResult{}, err + } + if !ok || job.Route != route { + return StepResult{}, errJobNotFound + } + if job.RouteRequestID != input.RouteRequestID { + return StepResult{}, &inputError{"routeRequestId does not match stored job"} + } + + digest, err := requestDigest(input.Request) + if err != nil { + return StepResult{}, err + } + if digest != job.RequestDigest { + return StepResult{}, &inputError{"request does not match stored job payload"} + } + + if job.State == JobStateArtifactReady || job.State == JobStateHandoffReady || job.State == JobStateFailed { + return mapJobResult(job), nil + } + + transition, err := s.engine.OnPoll(ctx, job) + if err != nil { + if err == errJobNotFound { + applyTransition(job, &Transition{ + State: JobStateFailed, + Reason: ReasonJobNotFound, + Detail: "signer job no longer exists", + }, s.now()) + if storeErr := s.store.Put(job); storeErr != nil { + return StepResult{}, storeErr + } + return mapJobResult(job), nil + } + return StepResult{}, err + } + + if transition != nil { + applyTransition(job, transition, s.now()) + if err := s.store.Put(job); err != nil { + return StepResult{}, err + } + } + + return mapJobResult(job), nil +} diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go new file mode 100644 index 0000000000..263bd5b07c --- /dev/null +++ b/pkg/covenantsigner/store.go @@ -0,0 +1,173 @@ +package covenantsigner + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/keep-network/keep-common/pkg/persistence" +) + +const jobsDirectory = "covenant-signer/jobs" + +type Store struct { + handle persistence.BasicHandle + mutex sync.Mutex + byRequestID map[string]*Job + byRouteKey map[string]string +} + +func NewStore(handle persistence.BasicHandle) (*Store, error) { + store := &Store{ + handle: handle, + byRequestID: make(map[string]*Job), + byRouteKey: make(map[string]string), + } + + if err := store.load(); err != nil { + return nil, err + } + + return store, nil +} + +func routeKey(route TemplateID, routeRequestID string) string { + return fmt.Sprintf("%s:%s", route, routeRequestID) +} + +func cloneJob(job *Job) (*Job, error) { + payload, err := json.Marshal(job) + if err != nil { + return nil, err + } + + cloned := &Job{} + if err := json.Unmarshal(payload, cloned); err != nil { + return nil, err + } + + return cloned, nil +} + +func (s *Store) load() error { + s.mutex.Lock() + defer s.mutex.Unlock() + + dataChan, errorChan := s.handle.ReadAll() + + for dataChan != nil || errorChan != nil { + select { + case descriptor, ok := <-dataChan: + if !ok { + dataChan = nil + continue + } + + if descriptor.Directory() != jobsDirectory { + continue + } + + content, err := descriptor.Content() + if err != nil { + return err + } + + job := &Job{} + if err := json.Unmarshal(content, job); err != nil { + return err + } + + existingID, ok := s.byRouteKey[routeKey(job.Route, job.RouteRequestID)] + if ok { + existing := s.byRequestID[existingID] + if existing != nil && existing.UpdatedAt >= job.UpdatedAt { + continue + } + } + + s.byRequestID[job.RequestID] = job + s.byRouteKey[routeKey(job.Route, job.RouteRequestID)] = job.RequestID + case err, ok := <-errorChan: + if !ok { + errorChan = nil + continue + } + if err != nil { + return err + } + } + } + + return nil +} + +func (s *Store) GetByRequestID(requestID string) (*Job, bool, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + job, ok := s.byRequestID[requestID] + if !ok { + return nil, false, nil + } + + cloned, err := cloneJob(job) + if err != nil { + return nil, false, err + } + + return cloned, true, nil +} + +func (s *Store) GetByRouteRequest(route TemplateID, routeRequestID string) (*Job, bool, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + requestID, ok := s.byRouteKey[routeKey(route, routeRequestID)] + if !ok { + return nil, false, nil + } + + job := s.byRequestID[requestID] + if job == nil { + return nil, false, nil + } + + cloned, err := cloneJob(job) + if err != nil { + return nil, false, err + } + + return cloned, true, nil +} + +func (s *Store) Put(job *Job) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + payload, err := json.Marshal(job) + if err != nil { + return err + } + + key := routeKey(job.Route, job.RouteRequestID) + if existingRequestID, ok := s.byRouteKey[key]; ok && existingRequestID != job.RequestID { + if err := s.handle.Delete(jobsDirectory, existingRequestID+".json"); err != nil { + return err + } + delete(s.byRequestID, existingRequestID) + } + + if err := s.handle.Save(payload, jobsDirectory, job.RequestID+".json"); err != nil { + return err + } + + cloned, err := cloneJob(job) + if err != nil { + return err + } + + s.byRequestID[job.RequestID] = cloned + s.byRouteKey[key] = job.RequestID + + return nil +} diff --git a/pkg/covenantsigner/types.go b/pkg/covenantsigner/types.go new file mode 100644 index 0000000000..845baffb10 --- /dev/null +++ b/pkg/covenantsigner/types.go @@ -0,0 +1,151 @@ +package covenantsigner + +import "encoding/json" + +type TemplateID string + +const ( + TemplateQcV1 TemplateID = "qc_v1" + TemplateSelfV1 TemplateID = "self_v1" +) + +type RecoveryPathID string + +const ( + PathCooperative RecoveryPathID = "COOPERATIVE" + PathMigration RecoveryPathID = "MIGRATION" + PathEarlyExit RecoveryPathID = "EARLY_EXIT" + PathLastResort RecoveryPathID = "LAST_RESORT" +) + +type RecoveryStage string + +const ( + StageSignerCoordination RecoveryStage = "SIGNER_COORDINATION" +) + +type FailureReason string + +const ( + ReasonAuthFailed FailureReason = "AUTH_FAILED" + ReasonPolicyRejected FailureReason = "POLICY_REJECTED" + ReasonInvalidInput FailureReason = "INVALID_INPUT" + ReasonProviderUnavailable FailureReason = "PROVIDER_UNAVAILABLE" + ReasonJobNotFound FailureReason = "JOB_NOT_FOUND" + ReasonJobPending FailureReason = "JOB_PENDING" + ReasonProviderFailed FailureReason = "PROVIDER_FAILED" + ReasonMalformedArtifact FailureReason = "MALFORMED_ARTIFACT" +) + +type StepStatus string + +const ( + StepStatusPending StepStatus = "PENDING" + StepStatusReady StepStatus = "READY" + StepStatusFailed StepStatus = "FAILED" +) + +type JobState string + +const ( + JobStateSubmitted JobState = "SUBMITTED" + JobStateValidating JobState = "VALIDATING" + JobStateSigning JobState = "SIGNING" + JobStatePending JobState = "PENDING" + JobStateArtifactReady JobState = "ARTIFACT_READY" + JobStateHandoffReady JobState = "HANDOFF_READY" + JobStateFailed JobState = "FAILED" +) + +type CovenantOutpoint struct { + TxID string `json:"txid"` + Vout uint32 `json:"vout"` + ScriptHash string `json:"scriptHash,omitempty"` +} + +type ArtifactRecord struct { + PSBTHash string `json:"psbtHash"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + TransactionHex string `json:"transactionHex,omitempty"` + TransactionID string `json:"transactionId,omitempty"` +} + +type SigningRequirements struct { + SignerRequired bool `json:"signerRequired"` + CustodianRequired bool `json:"custodianRequired"` +} + +type RouteSubmitRequest struct { + FacadeRequestID string `json:"facadeRequestId"` + IdempotencyKey string `json:"idempotencyKey"` + Route TemplateID `json:"route"` + Strategy string `json:"strategy"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + MaturityHeight uint64 `json:"maturityHeight"` + ActiveOutpoint CovenantOutpoint `json:"activeOutpoint"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + ArtifactSignatures []string `json:"artifactSignatures"` + Artifacts map[RecoveryPathID]ArtifactRecord `json:"artifacts"` + ScriptTemplate json.RawMessage `json:"scriptTemplate"` + Signing SigningRequirements `json:"signing"` +} + +type SignerSubmitInput struct { + RouteRequestID string `json:"routeRequestId"` + Request RouteSubmitRequest `json:"request"` + Stage RecoveryStage `json:"stage"` +} + +type SignerPollInput struct { + RouteRequestID string `json:"routeRequestId"` + RequestID string `json:"requestId"` + Request RouteSubmitRequest `json:"request"` + Stage RecoveryStage `json:"stage"` +} + +type StepResult struct { + Status StepStatus `json:"status"` + RequestID string `json:"requestId,omitempty"` + Detail string `json:"detail,omitempty"` + Reason FailureReason `json:"reason,omitempty"` + PSBTHash string `json:"psbtHash,omitempty"` + TransactionHex string `json:"transactionHex,omitempty"` + Handoff map[string]any `json:"handoff,omitempty"` +} + +type Job struct { + RequestID string `json:"requestId"` + RouteRequestID string `json:"routeRequestId"` + Route TemplateID `json:"route"` + IdempotencyKey string `json:"idempotencyKey"` + FacadeRequestID string `json:"facadeRequestId"` + RequestDigest string `json:"requestDigest"` + State JobState `json:"state"` + Detail string `json:"detail,omitempty"` + Reason FailureReason `json:"reason,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + CompletedAt string `json:"completedAt,omitempty"` + FailedAt string `json:"failedAt,omitempty"` + Request RouteSubmitRequest `json:"request"` + PSBTHash string `json:"psbtHash,omitempty"` + TransactionHex string `json:"transactionHex,omitempty"` + Handoff map[string]any `json:"handoff,omitempty"` +} + +type SelfV1Template struct { + Template TemplateID `json:"template"` + DepositorPublicKey string `json:"depositorPublicKey"` + SignerPublicKey string `json:"signerPublicKey"` + Delta2 uint64 `json:"delta2"` +} + +type QcV1Template struct { + Template TemplateID `json:"template"` + DepositorPublicKey string `json:"depositorPublicKey"` + CustodianPublicKey string `json:"custodianPublicKey"` + SignerPublicKey string `json:"signerPublicKey"` + Beta uint64 `json:"beta"` + Delta2 uint64 `json:"delta2"` +} diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go new file mode 100644 index 0000000000..f7e6ca5332 --- /dev/null +++ b/pkg/covenantsigner/validation.go @@ -0,0 +1,151 @@ +package covenantsigner + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" +) + +type inputError struct { + message string +} + +func (ie *inputError) Error() string { + return ie.message +} + +func strictUnmarshal(data []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} + +func requestDigest(request RouteSubmitRequest) (string, error) { + payload, err := json.Marshal(request) + if err != nil { + return "", err + } + + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]), nil +} + +func validateHexString(name string, value string) error { + if !strings.HasPrefix(value, "0x") || len(value) <= 2 || len(value)%2 != 0 { + return &inputError{fmt.Sprintf("%s must be a 0x-prefixed even-length hex string", name)} + } + + if _, err := hex.DecodeString(strings.TrimPrefix(value, "0x")); err != nil { + return &inputError{fmt.Sprintf("%s must be valid hex", name)} + } + + return nil +} + +func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { + if request.FacadeRequestID == "" { + return &inputError{"request.facadeRequestId is required"} + } + if request.IdempotencyKey == "" { + return &inputError{"request.idempotencyKey is required"} + } + if request.Route != route { + return &inputError{"request.route does not match endpoint route"} + } + if err := validateHexString("request.strategy", request.Strategy); err != nil { + return err + } + if err := validateHexString("request.reserve", request.Reserve); err != nil { + return err + } + if err := validateHexString("request.activeOutpoint.txid", request.ActiveOutpoint.TxID); err != nil { + return err + } + if request.ActiveOutpoint.ScriptHash != "" { + if err := validateHexString("request.activeOutpoint.scriptHash", request.ActiveOutpoint.ScriptHash); err != nil { + return err + } + } + if err := validateHexString("request.destinationCommitmentHash", request.DestinationCommitmentHash); err != nil { + return err + } + if len(request.ArtifactSignatures) == 0 { + return &inputError{"request.artifactSignatures must not be empty"} + } + for i, signature := range request.ArtifactSignatures { + if err := validateHexString(fmt.Sprintf("request.artifactSignatures[%d]", i), signature); err != nil { + return err + } + } + + switch route { + case TemplateSelfV1: + if !request.Signing.SignerRequired || request.Signing.CustodianRequired { + return &inputError{"request.signing must set signerRequired=true and custodianRequired=false for self_v1"} + } + template := &SelfV1Template{} + if err := strictUnmarshal(request.ScriptTemplate, template); err != nil { + return &inputError{fmt.Sprintf("request.scriptTemplate is invalid for self_v1: %v", err)} + } + if template.Template != TemplateSelfV1 { + return &inputError{"request.scriptTemplate.template must be self_v1"} + } + if err := validateHexString("request.scriptTemplate.depositorPublicKey", template.DepositorPublicKey); err != nil { + return err + } + if err := validateHexString("request.scriptTemplate.signerPublicKey", template.SignerPublicKey); err != nil { + return err + } + case TemplateQcV1: + if !request.Signing.SignerRequired || !request.Signing.CustodianRequired { + return &inputError{"request.signing must set signerRequired=true and custodianRequired=true for qc_v1"} + } + template := &QcV1Template{} + if err := strictUnmarshal(request.ScriptTemplate, template); err != nil { + return &inputError{fmt.Sprintf("request.scriptTemplate is invalid for qc_v1: %v", err)} + } + if template.Template != TemplateQcV1 { + return &inputError{"request.scriptTemplate.template must be qc_v1"} + } + if err := validateHexString("request.scriptTemplate.depositorPublicKey", template.DepositorPublicKey); err != nil { + return err + } + if err := validateHexString("request.scriptTemplate.custodianPublicKey", template.CustodianPublicKey); err != nil { + return err + } + if err := validateHexString("request.scriptTemplate.signerPublicKey", template.SignerPublicKey); err != nil { + return err + } + default: + return &inputError{"unsupported request.route"} + } + + return nil +} + +func validateSubmitInput(route TemplateID, input SignerSubmitInput) error { + if input.RouteRequestID == "" { + return &inputError{"routeRequestId is required"} + } + if input.Stage != StageSignerCoordination { + return &inputError{"stage must be SIGNER_COORDINATION"} + } + return validateCommonRequest(route, input.Request) +} + +func validatePollInput(route TemplateID, input SignerPollInput) error { + if input.RequestID == "" { + return &inputError{"requestId is required"} + } + if err := validateSubmitInput(route, SignerSubmitInput{ + RouteRequestID: input.RouteRequestID, + Request: input.Request, + Stage: input.Stage, + }); err != nil { + return err + } + return nil +} diff --git a/test/config.json b/test/config.json index 9b10178109..96b5771908 100644 --- a/test/config.json +++ b/test/config.json @@ -39,6 +39,9 @@ "NetworkMetricsTick": "43s", "EthereumMetricsTick": "1m27s" }, + "CovenantSigner": { + "Port": 9702 + }, "Maintainer": { "BitcoinDifficulty": { "Enabled": true, diff --git a/test/config.toml b/test/config.toml index 9801f00f0a..220c2dd6fa 100644 --- a/test/config.toml +++ b/test/config.toml @@ -35,6 +35,9 @@ Port = 3498 NetworkMetricsTick = "43s" EthereumMetricsTick = "1m27s" +[covenantsigner] +Port = 9702 + [maintainer.BitcoinDifficulty] Enabled = true DisableProxy = true diff --git a/test/config.yaml b/test/config.yaml index abddcb3fde..29b78b814d 100644 --- a/test/config.yaml +++ b/test/config.yaml @@ -30,6 +30,8 @@ ClientInfo: Port: 3498 NetworkMetricsTick: "43s" EthereumMetricsTick: "1m27s" +CovenantSigner: + Port: 9702 Maintainer: BitcoinDifficulty: Enabled: true From 1679fb04c7eb3a55777def0a62872112c180dc41 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 07:52:47 -0500 Subject: [PATCH 002/143] fix(tbtc): harden covenant signer substrate --- pkg/covenantsigner/covenantsigner_test.go | 135 ++++++++++++++++++++++ pkg/covenantsigner/server.go | 15 ++- pkg/covenantsigner/service.go | 7 +- 3 files changed, 153 insertions(+), 4 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 1742c97add..2709a6558c 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -5,10 +5,12 @@ import ( "context" "encoding/json" "io" + "net" "net/http" "net/http/httptest" "reflect" "testing" + "time" "github.com/keep-network/keep-common/pkg/persistence" ) @@ -223,6 +225,70 @@ func TestServicePollCanTransitionToReady(t *testing.T) { } } +func TestServiceTimestampsAdvanceAcrossTransitions(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return &Transition{ + State: JobStateArtifactReady, + Detail: "artifact ready", + PSBTHash: "0x090a", + TransactionHex: "0x0b0c", + }, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_timestamps", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err != nil { + t.Fatal(err) + } + + submittedJob, ok, err := service.store.GetByRequestID(submitResult.RequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected submitted job") + } + + time.Sleep(5 * time.Millisecond) + + _, err = service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "ors_timestamps", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err != nil { + t.Fatal(err) + } + + polledJob, ok, err := service.store.GetByRequestID(submitResult.RequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected polled job") + } + + if submittedJob.CreatedAt == polledJob.UpdatedAt { + t.Fatalf("expected updated timestamp to advance, got created=%s updated=%s", submittedJob.CreatedAt, polledJob.UpdatedAt) + } + if polledJob.CompletedAt == "" { + t.Fatal("expected completed timestamp to be populated") + } +} + func TestServicePollMapsJobNotFoundToFailed(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ @@ -356,3 +422,72 @@ func TestServerHandlesSubmitAndPathPoll(t *testing.T) { t.Fatalf("unexpected poll status: %d %s", pollResponse.StatusCode, string(body)) } } + +func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service)) + defer server.Close() + + payload := bytes.NewBufferString(`{ + "routeRequestId":"ors_http_unknown", + "stage":"SIGNER_COORDINATION", + "request":{ + "facadeRequestId":"rf_123", + "idempotencyKey":"idem_123", + "route":"self_v1", + "strategy":"0x1234", + "reserve":"0xabcd", + "epoch":12, + "maturityHeight":912345, + "activeOutpoint":{"txid":"0x0102","vout":1,"scriptHash":"0x0304"}, + "destinationCommitmentHash":"0x0506", + "artifactSignatures":["0x0708"], + "artifacts":{}, + "scriptTemplate":{"template":"self_v1","depositorPublicKey":"0x021111","signerPublicKey":"0x022222","delta2":4320}, + "signing":{"signerRequired":true,"custodianRequired":false}, + "futureField":"ignored" + }, + "futureTopLevel":"ignored" + }`) + + response, err := http.Post(server.URL+"/v1/self_v1/signer/requests", "application/json", payload) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected submit status: %d %s", response.StatusCode, string(body)) + } +} + +func TestInitializeRejectsInvalidOrUnavailablePort(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if _, enabled, err := Initialize(ctx, Config{Port: -1}, handle); err == nil || enabled { + t.Fatalf("expected invalid negative port to fail, got enabled=%v err=%v", enabled, err) + } + + listener, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + if _, enabled, err := Initialize(ctx, Config{Port: port}, handle); err == nil || enabled { + t.Fatalf("expected occupied port to fail, got enabled=%v err=%v", enabled, err) + } +} diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 1be0ea30a4..78971c12fc 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "strings" @@ -23,6 +24,9 @@ func Initialize(ctx context.Context, config Config, handle persistence.BasicHand if config.Port == 0 { return nil, false, nil } + if config.Port < 0 || config.Port > 65535 { + return nil, false, fmt.Errorf("invalid covenant signer port [%d]", config.Port) + } service, err := NewService(handle, NewPassiveEngine()) if err != nil { @@ -37,13 +41,18 @@ func Initialize(ctx context.Context, config Config, handle persistence.BasicHand }, } + listener, err := net.Listen("tcp", server.httpServer.Addr) + if err != nil { + return nil, false, fmt.Errorf("failed to bind covenant signer port [%d]: %w", config.Port, err) + } + go func() { <-ctx.Done() _ = server.httpServer.Shutdown(context.Background()) }() go func() { - if err := server.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + if err := server.httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Errorf("covenant signer server failed: [%v]", err) } }() @@ -76,7 +85,6 @@ func decodeJSON[T any](w http.ResponseWriter, r *http.Request, target *T) bool { defer r.Body.Close() decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return false @@ -102,7 +110,8 @@ func handleError(w http.ResponseWriter, err error) { return } - http.Error(w, err.Error(), http.StatusInternalServerError) + logger.Errorf("covenant signer request failed: [%v]", err) + http.Error(w, "internal server error", http.StatusInternalServerError) } func submitHandler(service *Service, route TemplateID) http.HandlerFunc { diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 21ac271f1d..22b0dbe24e 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "sync" "time" "github.com/keep-network/keep-common/pkg/persistence" @@ -14,6 +15,7 @@ type Service struct { store *Store engine Engine now func() time.Time + mutex sync.Mutex } func NewService(handle persistence.BasicHandle, engine Engine) (*Service, error) { @@ -29,7 +31,7 @@ func NewService(handle persistence.BasicHandle, engine Engine) (*Service, error) return &Service{ store: store, engine: engine, - now: time.Now().UTC, + now: func() time.Time { return time.Now().UTC() }, }, nil } @@ -98,6 +100,9 @@ func mapJobResult(job *Job) StepResult { } func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubmitInput) (StepResult, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + if err := validateSubmitInput(route, input); err != nil { return StepResult{}, err } From 4d7b3338940c0138bf23e195fa17541df4017678 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 09:23:02 -0500 Subject: [PATCH 003/143] feat(tbtc): validate migration destination reservation artifacts --- pkg/covenantsigner/covenantsigner_test.go | 79 ++++++++++++- pkg/covenantsigner/doc.go | 8 +- pkg/covenantsigner/types.go | 32 +++++ pkg/covenantsigner/validation.go | 137 ++++++++++++++++++++++ 4 files changed, 249 insertions(+), 7 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 2709a6558c..9d09e84b5e 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "reflect" + "strings" "testing" "time" @@ -118,16 +119,18 @@ func mustTemplate(value any) json.RawMessage { } func baseRequest(route TemplateID) RouteSubmitRequest { + migrationDestination := validMigrationDestination() request := RouteSubmitRequest{ FacadeRequestID: "rf_123", IdempotencyKey: "idem_123", Route: route, Strategy: "0x1234", - Reserve: "0xabcd", + Reserve: migrationDestination.Reserve, Epoch: 12, MaturityHeight: 912345, ActiveOutpoint: CovenantOutpoint{TxID: "0x0102", Vout: 1, ScriptHash: "0x0304"}, - DestinationCommitmentHash: "0x0506", + DestinationCommitmentHash: migrationDestination.DestinationCommitmentHash, + MigrationDestination: migrationDestination, ArtifactSignatures: []string{"0x0708"}, Artifacts: map[RecoveryPathID]ArtifactRecord{}, } @@ -144,6 +147,26 @@ func baseRequest(route TemplateID) RouteSubmitRequest { return request } +func validMigrationDestination() *MigrationDestinationReservation { + reservation := &MigrationDestinationReservation{ + ReservationID: "cmdr_12345678", + Reserve: "0x1111111111111111111111111111111111111111", + Epoch: 12, + Route: ReservationRouteMigration, + Revealer: "0x2222222222222222222222222222222222222222", + Vault: "0x3333333333333333333333333333333333333333", + Network: "regtest", + Status: ReservationStatusReserved, + DepositScript: "0x0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } + + reservation.DepositScriptHash, _ = computeDepositScriptHash(reservation.DepositScript) + reservation.MigrationExtraData = computeMigrationExtraData(reservation.Revealer) + reservation.DestinationCommitmentHash, _ = computeDestinationCommitmentHash(reservation) + + return reservation +} + func TestServiceSubmitDeduplicatesByRouteRequestID(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ @@ -327,6 +350,40 @@ func TestServicePollMapsJobNotFoundToFailed(t *testing.T) { } } +func TestMigrationDestinationMatchesKnownVector(t *testing.T) { + reservation := validMigrationDestination() + + if reservation.DepositScriptHash != "0x8532ec6785e391b2af968b5728d574e271c7f46658f5ed10845d9ad5b23ac6d3" { + t.Fatalf("unexpected depositScriptHash: %s", reservation.DepositScriptHash) + } + if reservation.MigrationExtraData != "0x41435f4d49475241544556312222222222222222222222222222222222222222" { + t.Fatalf("unexpected migrationExtraData: %s", reservation.MigrationExtraData) + } + if reservation.DestinationCommitmentHash != "0x3efc50372759413e0f1900a2340fbb947648c524e5ec3cb4cf8887ea2d7df474" { + t.Fatalf("unexpected destinationCommitmentHash: %s", reservation.DestinationCommitmentHash) + } +} + +func TestServiceRejectsMismatchedMigrationDestinationArtifact(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateSelfV1) + request.MigrationDestination.DepositScriptHash = "0xdeadbeef" + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_bad_reservation", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains(err.Error(), "depositScriptHash does not match depositScript") { + t.Fatalf("expected depositScriptHash mismatch, got %v", err) + } +} + func TestStoreReloadPreservesJobs(t *testing.T) { handle := newMemoryHandle() store, err := NewStore(handle) @@ -445,11 +502,25 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { "idempotencyKey":"idem_123", "route":"self_v1", "strategy":"0x1234", - "reserve":"0xabcd", + "reserve":"0x1111111111111111111111111111111111111111", "epoch":12, "maturityHeight":912345, "activeOutpoint":{"txid":"0x0102","vout":1,"scriptHash":"0x0304"}, - "destinationCommitmentHash":"0x0506", + "destinationCommitmentHash":"0x3efc50372759413e0f1900a2340fbb947648c524e5ec3cb4cf8887ea2d7df474", + "migrationDestination":{ + "reservationId":"cmdr_12345678", + "reserve":"0x1111111111111111111111111111111111111111", + "epoch":12, + "route":"MIGRATION", + "revealer":"0x2222222222222222222222222222222222222222", + "vault":"0x3333333333333333333333333333333333333333", + "network":"regtest", + "status":"RESERVED", + "depositScript":"0x0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "depositScriptHash":"0x8532ec6785e391b2af968b5728d574e271c7f46658f5ed10845d9ad5b23ac6d3", + "migrationExtraData":"0x41435f4d49475241544556312222222222222222222222222222222222222222", + "destinationCommitmentHash":"0x3efc50372759413e0f1900a2340fbb947648c524e5ec3cb4cf8887ea2d7df474" + }, "artifactSignatures":["0x0708"], "artifacts":{}, "scriptTemplate":{"template":"self_v1","depositorPublicKey":"0x021111","signerPublicKey":"0x022222","delta2":4320}, diff --git a/pkg/covenantsigner/doc.go b/pkg/covenantsigner/doc.go index 1883107b83..f4f8d5482f 100644 --- a/pkg/covenantsigner/doc.go +++ b/pkg/covenantsigner/doc.go @@ -1,4 +1,6 @@ -// Package covenantsigner implements the first keep-core covenant signer -// extension slice: durable submit/poll semantics, request validation, and a -// compatible HTTP surface for covenant recovery/presign signer jobs. +// Package covenantsigner implements keep-core covenant signer substrate slices: +// durable submit/poll semantics, strict request validation, and a compatible +// HTTP surface for covenant recovery/presign signer jobs. The current branch +// also validates the concrete migration destination reservation artifact that +// later real signing flows will consume. package covenantsigner diff --git a/pkg/covenantsigner/types.go b/pkg/covenantsigner/types.go index 845baffb10..c92fd1a464 100644 --- a/pkg/covenantsigner/types.go +++ b/pkg/covenantsigner/types.go @@ -37,6 +37,22 @@ const ( ReasonMalformedArtifact FailureReason = "MALFORMED_ARTIFACT" ) +type ReservationRoute string + +const ( + ReservationRouteMigration ReservationRoute = "MIGRATION" +) + +type ReservationStatus string + +const ( + ReservationStatusReserved ReservationStatus = "RESERVED" + ReservationStatusCommittedToEpoch ReservationStatus = "COMMITTED_TO_EPOCH" + ReservationStatusRevealed ReservationStatus = "REVEALED" + ReservationStatusRetired ReservationStatus = "RETIRED" + ReservationStatusExpired ReservationStatus = "EXPIRED" +) + type StepStatus string const ( @@ -70,6 +86,21 @@ type ArtifactRecord struct { TransactionID string `json:"transactionId,omitempty"` } +type MigrationDestinationReservation struct { + ReservationID string `json:"reservationId,omitempty"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route ReservationRoute `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + Status ReservationStatus `json:"status"` + DepositScript string `json:"depositScript"` + DepositScriptHash string `json:"depositScriptHash"` + MigrationExtraData string `json:"migrationExtraData"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` +} + type SigningRequirements struct { SignerRequired bool `json:"signerRequired"` CustodianRequired bool `json:"custodianRequired"` @@ -85,6 +116,7 @@ type RouteSubmitRequest struct { MaturityHeight uint64 `json:"maturityHeight"` ActiveOutpoint CovenantOutpoint `json:"activeOutpoint"` DestinationCommitmentHash string `json:"destinationCommitmentHash"` + MigrationDestination *MigrationDestinationReservation `json:"migrationDestination,omitempty"` ArtifactSignatures []string `json:"artifactSignatures"` Artifacts map[RecoveryPathID]ArtifactRecord `json:"artifacts"` ScriptTemplate json.RawMessage `json:"scriptTemplate"` diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index f7e6ca5332..b6d86fb58e 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -45,6 +45,140 @@ func validateHexString(name string, value string) error { return nil } +func validateAddressString(name string, value string) error { + if err := validateHexString(name, value); err != nil { + return err + } + + if len(value) != 42 { + return &inputError{fmt.Sprintf("%s must be a 20-byte 0x-prefixed hex address", name)} + } + + return nil +} + +func normalizeLowerHex(value string) string { + return strings.ToLower(value) +} + +func computeMigrationExtraData(revealer string) string { + return "0x" + hex.EncodeToString([]byte("AC_MIGRATEV1")) + strings.TrimPrefix(normalizeLowerHex(revealer), "0x") +} + +func computeDepositScriptHash(depositScript string) (string, error) { + rawScript, err := hex.DecodeString(strings.TrimPrefix(depositScript, "0x")) + if err != nil { + return "", err + } + + sum := sha256.Sum256(rawScript) + return "0x" + hex.EncodeToString(sum[:]), nil +} + +type destinationCommitmentPayload struct { + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route string `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + DepositScriptHash string `json:"depositScriptHash"` + MigrationExtraData string `json:"migrationExtraData"` +} + +func computeDestinationCommitmentHash( + reservation *MigrationDestinationReservation, +) (string, error) { + payload, err := json.Marshal(destinationCommitmentPayload{ + Reserve: normalizeLowerHex(reservation.Reserve), + Epoch: reservation.Epoch, + Route: string(reservation.Route), + Revealer: normalizeLowerHex(reservation.Revealer), + Vault: normalizeLowerHex(reservation.Vault), + Network: strings.TrimSpace(reservation.Network), + DepositScriptHash: normalizeLowerHex(reservation.DepositScriptHash), + MigrationExtraData: normalizeLowerHex(reservation.MigrationExtraData), + }) + if err != nil { + return "", err + } + + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]), nil +} + +func validateMigrationDestination( + request RouteSubmitRequest, + reservation *MigrationDestinationReservation, +) error { + if reservation == nil { + return &inputError{"request.migrationDestination is required"} + } + if reservation.Route != ReservationRouteMigration { + return &inputError{"request.migrationDestination.route must be MIGRATION"} + } + if reservation.Status != ReservationStatusReserved && + reservation.Status != ReservationStatusCommittedToEpoch { + return &inputError{"request.migrationDestination.status must be RESERVED or COMMITTED_TO_EPOCH"} + } + if err := validateAddressString("request.migrationDestination.reserve", reservation.Reserve); err != nil { + return err + } + if err := validateAddressString("request.migrationDestination.revealer", reservation.Revealer); err != nil { + return err + } + if err := validateAddressString("request.migrationDestination.vault", reservation.Vault); err != nil { + return err + } + if strings.TrimSpace(reservation.Network) == "" { + return &inputError{"request.migrationDestination.network is required"} + } + if err := validateHexString("request.migrationDestination.depositScript", reservation.DepositScript); err != nil { + return err + } + if err := validateHexString("request.migrationDestination.depositScriptHash", reservation.DepositScriptHash); err != nil { + return err + } + if err := validateHexString("request.migrationDestination.migrationExtraData", reservation.MigrationExtraData); err != nil { + return err + } + if err := validateHexString("request.migrationDestination.destinationCommitmentHash", reservation.DestinationCommitmentHash); err != nil { + return err + } + if request.Epoch != reservation.Epoch { + return &inputError{"request.migrationDestination.epoch does not match request.epoch"} + } + if normalizeLowerHex(request.Reserve) != normalizeLowerHex(reservation.Reserve) { + return &inputError{"request.migrationDestination.reserve does not match request.reserve"} + } + if normalizeLowerHex(request.DestinationCommitmentHash) != normalizeLowerHex(reservation.DestinationCommitmentHash) { + return &inputError{"request.migrationDestination.destinationCommitmentHash does not match request.destinationCommitmentHash"} + } + + expectedExtraData := computeMigrationExtraData(reservation.Revealer) + if normalizeLowerHex(reservation.MigrationExtraData) != expectedExtraData { + return &inputError{"request.migrationDestination.migrationExtraData does not match migration revealer encoding"} + } + + depositScriptHash, err := computeDepositScriptHash(reservation.DepositScript) + if err != nil { + return &inputError{"request.migrationDestination.depositScript is not valid hex"} + } + if normalizeLowerHex(reservation.DepositScriptHash) != depositScriptHash { + return &inputError{"request.migrationDestination.depositScriptHash does not match depositScript"} + } + + commitmentHash, err := computeDestinationCommitmentHash(reservation) + if err != nil { + return err + } + if normalizeLowerHex(reservation.DestinationCommitmentHash) != commitmentHash { + return &inputError{"request.migrationDestination.destinationCommitmentHash does not match canonical reservation artifact"} + } + + return nil +} + func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { if request.FacadeRequestID == "" { return &inputError{"request.facadeRequestId is required"} @@ -72,6 +206,9 @@ func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { if err := validateHexString("request.destinationCommitmentHash", request.DestinationCommitmentHash); err != nil { return err } + if err := validateMigrationDestination(request, request.MigrationDestination); err != nil { + return err + } if len(request.ArtifactSignatures) == 0 { return &inputError{"request.artifactSignatures must not be empty"} } From cce16ca7cc42c334ac1e54223e1423588d227125 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 09:33:07 -0500 Subject: [PATCH 004/143] test(tbtc): expand reservation artifact validation coverage --- pkg/covenantsigner/covenantsigner_test.go | 88 +++++++++++++++++++++++ pkg/covenantsigner/validation.go | 5 ++ 2 files changed, 93 insertions(+) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 9d09e84b5e..e50a8830f8 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -384,6 +384,94 @@ func TestServiceRejectsMismatchedMigrationDestinationArtifact(t *testing.T) { } } +func TestServiceRejectsInvalidMigrationDestinationVariants(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + testCases := []struct { + name string + mutate func(request *RouteSubmitRequest) + expectErr string + }{ + { + name: "missing reservation artifact", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination = nil + }, + expectErr: "request.migrationDestination is required", + }, + { + name: "wrong reservation route", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination.Route = "COOPERATIVE" + }, + expectErr: "request.migrationDestination.route must be MIGRATION", + }, + { + name: "retired reservation status", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination.Status = ReservationStatusRetired + }, + expectErr: "request.migrationDestination.status must be RESERVED or COMMITTED_TO_EPOCH", + }, + { + name: "epoch mismatch", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination.Epoch = 13 + }, + expectErr: "request.migrationDestination.epoch does not match request.epoch", + }, + { + name: "reserve mismatch", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination.Reserve = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + expectErr: "request.migrationDestination.reserve does not match request.reserve", + }, + { + name: "request commitment mismatch", + mutate: func(request *RouteSubmitRequest) { + request.DestinationCommitmentHash = "0xdeadbeef" + }, + expectErr: "request.migrationDestination.destinationCommitmentHash does not match request.destinationCommitmentHash", + }, + { + name: "migration extraData mismatch", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination.MigrationExtraData = "0xdeadbeef" + }, + expectErr: "request.migrationDestination.migrationExtraData does not match migration revealer encoding", + }, + { + name: "canonical commitment mismatch", + mutate: func(request *RouteSubmitRequest) { + request.MigrationDestination.DestinationCommitmentHash = "0xdeadbeef" + request.DestinationCommitmentHash = "0xdeadbeef" + }, + expectErr: "request.migrationDestination.destinationCommitmentHash does not match canonical reservation artifact", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + request := baseRequest(TemplateSelfV1) + testCase.mutate(&request) + + _, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_invalid_variant_" + strings.ReplaceAll(testCase.name, " ", "_"), + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains(err.Error(), testCase.expectErr) { + t.Fatalf("expected %q, got %v", testCase.expectErr, err) + } + }) + } +} + func TestStoreReloadPreservesJobs(t *testing.T) { handle := newMemoryHandle() store, err := NewStore(handle) diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index b6d86fb58e..4252e11d98 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -76,6 +76,8 @@ func computeDepositScriptHash(depositScript string) (string, error) { } type destinationCommitmentPayload struct { + // Field order is hash-significant and must stay aligned with the TypeScript + // reservation-service object literal used to compute the same commitment. Reserve string `json:"reserve"` Epoch uint64 `json:"epoch"` Route string `json:"route"` @@ -206,6 +208,9 @@ func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { if err := validateHexString("request.destinationCommitmentHash", request.DestinationCommitmentHash); err != nil { return err } + // This intentionally creates a deployment ordering constraint: the + // orchestrator must supply the concrete migration destination artifact + // before this signer version can accept requests. if err := validateMigrationDestination(request, request.MigrationDestination); err != nil { return err } From d30ecd1c50bea469a9215a2a7f7a35ad053fde16 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 09:39:08 -0500 Subject: [PATCH 005/143] feat(tbtc): validate covenant migration transaction plan --- pkg/covenantsigner/covenantsigner_test.go | 114 +++++++++++++++++++++- pkg/covenantsigner/doc.go | 3 +- pkg/covenantsigner/types.go | 10 ++ pkg/covenantsigner/validation.go | 45 +++++++++ 4 files changed, 169 insertions(+), 3 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index e50a8830f8..13606cebfa 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -131,8 +131,16 @@ func baseRequest(route TemplateID) RouteSubmitRequest { ActiveOutpoint: CovenantOutpoint{TxID: "0x0102", Vout: 1, ScriptHash: "0x0304"}, DestinationCommitmentHash: migrationDestination.DestinationCommitmentHash, MigrationDestination: migrationDestination, - ArtifactSignatures: []string{"0x0708"}, - Artifacts: map[RecoveryPathID]ArtifactRecord{}, + MigrationTransactionPlan: &MigrationTransactionPlan{ + InputValueSats: 1000000, + DestinationValueSats: 998000, + AnchorValueSats: canonicalAnchorValueSats, + FeeSats: 1670, + InputSequence: canonicalCovenantInputSequence, + LockTime: 912345, + }, + ArtifactSignatures: []string{"0x0708"}, + Artifacts: map[RecoveryPathID]ArtifactRecord{}, } switch route { @@ -472,6 +480,100 @@ func TestServiceRejectsInvalidMigrationDestinationVariants(t *testing.T) { } } +func TestServiceRejectsInvalidMigrationTransactionPlanVariants(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + testCases := []struct { + name string + mutate func(request *RouteSubmitRequest) + expectErr string + }{ + { + name: "missing transaction plan", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan = nil + }, + expectErr: "request.migrationTransactionPlan is required", + }, + { + name: "zero input value", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.InputValueSats = 0 + }, + expectErr: "request.migrationTransactionPlan.inputValueSats must be greater than zero", + }, + { + name: "zero destination value", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.DestinationValueSats = 0 + }, + expectErr: "request.migrationTransactionPlan.destinationValueSats must be greater than zero", + }, + { + name: "wrong anchor value", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.AnchorValueSats = 331 + }, + expectErr: "request.migrationTransactionPlan.anchorValueSats must equal the canonical 330 sat anchor", + }, + { + name: "wrong input sequence", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.InputSequence = 0xFFFFFFFF + }, + expectErr: "request.migrationTransactionPlan.inputSequence must equal 0xFFFFFFFD", + }, + { + name: "locktime mismatch", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.LockTime = request.MaturityHeight + 1 + }, + expectErr: "request.migrationTransactionPlan.lockTime must match request.maturityHeight", + }, + { + name: "insufficient input for destination", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.InputValueSats = request.MigrationTransactionPlan.DestinationValueSats - 1 + }, + expectErr: "request.migrationTransactionPlan.inputValueSats must cover destinationValueSats", + }, + { + name: "insufficient input for anchor", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.InputValueSats = request.MigrationTransactionPlan.DestinationValueSats + canonicalAnchorValueSats - 1 + }, + expectErr: "request.migrationTransactionPlan.inputValueSats must cover anchorValueSats", + }, + { + name: "accounting mismatch", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.FeeSats++ + }, + expectErr: "request.migrationTransactionPlan values must satisfy inputValueSats = destinationValueSats + anchorValueSats + feeSats", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + request := baseRequest(TemplateSelfV1) + testCase.mutate(&request) + + _, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_invalid_plan_" + strings.ReplaceAll(testCase.name, " ", "_"), + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains(err.Error(), testCase.expectErr) { + t.Fatalf("expected %q, got %v", testCase.expectErr, err) + } + }) + } +} + func TestStoreReloadPreservesJobs(t *testing.T) { handle := newMemoryHandle() store, err := NewStore(handle) @@ -609,6 +711,14 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { "migrationExtraData":"0x41435f4d49475241544556312222222222222222222222222222222222222222", "destinationCommitmentHash":"0x3efc50372759413e0f1900a2340fbb947648c524e5ec3cb4cf8887ea2d7df474" }, + "migrationTransactionPlan":{ + "inputValueSats":1000000, + "destinationValueSats":998000, + "anchorValueSats":330, + "feeSats":1670, + "inputSequence":4294967293, + "lockTime":912345 + }, "artifactSignatures":["0x0708"], "artifacts":{}, "scriptTemplate":{"template":"self_v1","depositorPublicKey":"0x021111","signerPublicKey":"0x022222","delta2":4320}, diff --git a/pkg/covenantsigner/doc.go b/pkg/covenantsigner/doc.go index f4f8d5482f..dce57778c4 100644 --- a/pkg/covenantsigner/doc.go +++ b/pkg/covenantsigner/doc.go @@ -2,5 +2,6 @@ // durable submit/poll semantics, strict request validation, and a compatible // HTTP surface for covenant recovery/presign signer jobs. The current branch // also validates the concrete migration destination reservation artifact that -// later real signing flows will consume. +// later real signing flows will consume, together with the canonical +// pre-signed migration transaction plan fields needed before tx construction. package covenantsigner diff --git a/pkg/covenantsigner/types.go b/pkg/covenantsigner/types.go index c92fd1a464..b8b5ae35a2 100644 --- a/pkg/covenantsigner/types.go +++ b/pkg/covenantsigner/types.go @@ -101,6 +101,15 @@ type MigrationDestinationReservation struct { DestinationCommitmentHash string `json:"destinationCommitmentHash"` } +type MigrationTransactionPlan struct { + InputValueSats uint64 `json:"inputValueSats"` + DestinationValueSats uint64 `json:"destinationValueSats"` + AnchorValueSats uint64 `json:"anchorValueSats"` + FeeSats uint64 `json:"feeSats"` + InputSequence uint32 `json:"inputSequence"` + LockTime uint64 `json:"lockTime"` +} + type SigningRequirements struct { SignerRequired bool `json:"signerRequired"` CustodianRequired bool `json:"custodianRequired"` @@ -117,6 +126,7 @@ type RouteSubmitRequest struct { ActiveOutpoint CovenantOutpoint `json:"activeOutpoint"` DestinationCommitmentHash string `json:"destinationCommitmentHash"` MigrationDestination *MigrationDestinationReservation `json:"migrationDestination,omitempty"` + MigrationTransactionPlan *MigrationTransactionPlan `json:"migrationTransactionPlan,omitempty"` ArtifactSignatures []string `json:"artifactSignatures"` Artifacts map[RecoveryPathID]ArtifactRecord `json:"artifacts"` ScriptTemplate json.RawMessage `json:"scriptTemplate"` diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 4252e11d98..1706bc3b10 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -9,6 +9,11 @@ import ( "strings" ) +const ( + canonicalCovenantInputSequence uint32 = 0xFFFFFFFD + canonicalAnchorValueSats uint64 = 330 +) + type inputError struct { message string } @@ -181,6 +186,43 @@ func validateMigrationDestination( return nil } +func validateMigrationTransactionPlan( + request RouteSubmitRequest, + plan *MigrationTransactionPlan, +) error { + if plan == nil { + return &inputError{"request.migrationTransactionPlan is required"} + } + if plan.InputValueSats == 0 { + return &inputError{"request.migrationTransactionPlan.inputValueSats must be greater than zero"} + } + if plan.DestinationValueSats == 0 { + return &inputError{"request.migrationTransactionPlan.destinationValueSats must be greater than zero"} + } + if plan.AnchorValueSats != canonicalAnchorValueSats { + return &inputError{"request.migrationTransactionPlan.anchorValueSats must equal the canonical 330 sat anchor"} + } + if plan.InputSequence != canonicalCovenantInputSequence { + return &inputError{"request.migrationTransactionPlan.inputSequence must equal 0xFFFFFFFD"} + } + if plan.LockTime != request.MaturityHeight { + return &inputError{"request.migrationTransactionPlan.lockTime must match request.maturityHeight"} + } + if plan.InputValueSats < plan.DestinationValueSats { + return &inputError{"request.migrationTransactionPlan.inputValueSats must cover destinationValueSats"} + } + remainingAfterDestination := plan.InputValueSats - plan.DestinationValueSats + if remainingAfterDestination < plan.AnchorValueSats { + return &inputError{"request.migrationTransactionPlan.inputValueSats must cover anchorValueSats"} + } + remainingAfterAnchor := remainingAfterDestination - plan.AnchorValueSats + if remainingAfterAnchor != plan.FeeSats { + return &inputError{"request.migrationTransactionPlan values must satisfy inputValueSats = destinationValueSats + anchorValueSats + feeSats"} + } + + return nil +} + func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { if request.FacadeRequestID == "" { return &inputError{"request.facadeRequestId is required"} @@ -214,6 +256,9 @@ func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { if err := validateMigrationDestination(request, request.MigrationDestination); err != nil { return err } + if err := validateMigrationTransactionPlan(request, request.MigrationTransactionPlan); err != nil { + return err + } if len(request.ArtifactSignatures) == 0 { return &inputError{"request.artifactSignatures must not be empty"} } From a7ddf50ee78e0b74b893dea5d678270b2163e055 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 09:47:13 -0500 Subject: [PATCH 006/143] docs(tbtc): note transaction-plan rollout dependency --- pkg/covenantsigner/validation.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 1706bc3b10..74ac25b4fb 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -256,6 +256,9 @@ func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { if err := validateMigrationDestination(request, request.MigrationDestination); err != nil { return err } + // This intentionally creates the next deployment ordering constraint: the + // orchestrator must supply the canonical migration transaction plan before + // this signer version can accept requests. if err := validateMigrationTransactionPlan(request, request.MigrationTransactionPlan); err != nil { return err } From fefb76539c42669776047ce7f4dcb4082f5c54b5 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 10:39:31 -0500 Subject: [PATCH 007/143] feat(tbtc): implement self_v1 signer completion --- cmd/start.go | 3 +- pkg/bitcoin/transaction_builder.go | 35 ++ pkg/bitcoin/transaction_builder_test.go | 43 +++ pkg/covenantsigner/covenantsigner_test.go | 4 +- pkg/covenantsigner/server.go | 9 +- pkg/tbtc/covenant_signer.go | 397 ++++++++++++++++++++ pkg/tbtc/covenant_signer_test.go | 435 ++++++++++++++++++++++ pkg/tbtc/tbtc.go | 14 +- 8 files changed, 929 insertions(+), 11 deletions(-) create mode 100644 pkg/tbtc/covenant_signer.go create mode 100644 pkg/tbtc/covenant_signer_test.go diff --git a/cmd/start.go b/cmd/start.go index 66b79d76fa..5120e2b7c0 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -159,7 +159,7 @@ func start(cmd *cobra.Command) error { btcChain, ) - err = tbtc.Initialize( + covenantSignerEngine, err := tbtc.Initialize( ctx, tbtcChain, btcChain, @@ -180,6 +180,7 @@ func start(cmd *cobra.Command) error { ctx, clientConfig.CovenantSigner, tbtcDataPersistence, + covenantSignerEngine, ) if err != nil { return fmt.Errorf("error initializing covenant signer: [%v]", err) diff --git a/pkg/bitcoin/transaction_builder.go b/pkg/bitcoin/transaction_builder.go index e446f07517..4fd688461f 100644 --- a/pkg/bitcoin/transaction_builder.go +++ b/pkg/bitcoin/transaction_builder.go @@ -156,6 +156,41 @@ func (tb *TransactionBuilder) AddOutput(output *TransactionOutput) { tb.internal.AddTxOut(wire.NewTxOut(output.Value, output.PublicKeyScript)) } +// SetInputSequence overrides the sequence number for the input at the given +// index. +func (tb *TransactionBuilder) SetInputSequence(index int, sequence uint32) error { + if index < 0 || index >= len(tb.internal.TxIn) { + return fmt.Errorf("wrong input index") + } + + tb.internal.TxIn[index].Sequence = sequence + + return nil +} + +// SetInputWitness overrides the witness stack for the input at the given +// index. +func (tb *TransactionBuilder) SetInputWitness(index int, witness [][]byte) error { + if index < 0 || index >= len(tb.internal.TxIn) { + return fmt.Errorf("wrong input index") + } + + tb.internal.TxIn[index].Witness = witness + tb.internal.TxIn[index].SignatureScript = nil + + return nil +} + +// SetLocktime overrides the transaction locktime. +func (tb *TransactionBuilder) SetLocktime(locktime uint32) { + tb.internal.LockTime = locktime +} + +// Build returns the transaction in its current state. +func (tb *TransactionBuilder) Build() *Transaction { + return tb.internal.toTransaction() +} + // ComputeSignatureHashes computes the signature hashes for all transaction // inputs and stores them into the builder's state. Elements of the returned // slice are ordered in the same way as the transaction inputs they correspond diff --git a/pkg/bitcoin/transaction_builder_test.go b/pkg/bitcoin/transaction_builder_test.go index 246e70cd51..b35b1245ed 100644 --- a/pkg/bitcoin/transaction_builder_test.go +++ b/pkg/bitcoin/transaction_builder_test.go @@ -215,6 +215,49 @@ func TestTransactionBuilder_AddOutput(t *testing.T) { assertInternalOutput(t, builder, 0, output) } +func TestTransactionBuilder_SetInputSequenceWitnessAndLocktime(t *testing.T) { + localChain := newLocalChain() + builder := NewTransactionBuilder(localChain) + + inputTransaction := transactionFrom(t, "01000000000101a0367a0790e3dfc199df34ca9ce5c35591510b6525d2d5869166728a5ed554be0100000000ffffffff02e02e00000000000022002086a303cdd2e2eab1d1679f1a813835dc5a1b65321077cdccaf08f98cbf04ca962cff100000000000160014e257eccafbc07c381642ce6e7e55120fb077fbed02473044022050759dde2c84bccf3c1502b0e33a6acb570117fd27a982c0c2991c9f9737508e02201fcba5d6f6c0ab780042138a9110418b3f589d8d09a900f20ee28cfcdb14d2970121039d61d62dcd048d3f8550d22eb90b4af908db60231d117aeede04e7bc11907bfa00000000") + if err := localChain.addTransaction(inputTransaction); err != nil { + t.Fatal(err) + } + + utxo := &UnspentTransactionOutput{ + Outpoint: &TransactionOutpoint{ + TransactionHash: inputTransaction.Hash(), + OutputIndex: 0, + }, + Value: 12000, + } + redeemScript := hexToSlice(t, "14934b98637ca318a4d6e7ca6ffd1690b8e77df6377508f9f0c90d000395237576a9148db50eb52063ea9d98b3eac91489a90f738986f68763ac6776a914e257eccafbc07c381642ce6e7e55120fb077fbed8804e0250162b175ac68") + + if err := builder.AddScriptHashInput(utxo, redeemScript); err != nil { + t.Fatal(err) + } + if err := builder.SetInputSequence(0, 0xfffffffd); err != nil { + t.Fatal(err) + } + if err := builder.SetInputWitness(0, [][]byte{{0x01}, {0x02}, redeemScript}); err != nil { + t.Fatal(err) + } + builder.SetLocktime(12345) + + assertInternalInput(t, builder, 0, &TransactionInput{ + Outpoint: utxo.Outpoint, + SignatureScript: nil, + Witness: [][]byte{{0x01}, {0x02}, redeemScript}, + Sequence: 0xfffffffd, + }) + + transaction := builder.Build() + testutils.AssertIntsEqual(t, "locktime", 12345, int(transaction.Locktime)) + if !reflect.DeepEqual(transaction.Inputs[0].Witness, [][]byte{{0x01}, {0x02}, redeemScript}) { + t.Fatal("unexpected built transaction witness") + } +} + // The goal of this test is making sure that the TransactionBuilder can // produce proper signature hashes and apply signatures for all input types, // i.e. P2PKH, P2WPKH, P2SH, and P2WSH. This test uses transactions that diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 13606cebfa..756faaeb0c 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -745,7 +745,7 @@ func TestInitializeRejectsInvalidOrUnavailablePort(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - if _, enabled, err := Initialize(ctx, Config{Port: -1}, handle); err == nil || enabled { + if _, enabled, err := Initialize(ctx, Config{Port: -1}, handle, nil); err == nil || enabled { t.Fatalf("expected invalid negative port to fail, got enabled=%v err=%v", enabled, err) } @@ -756,7 +756,7 @@ func TestInitializeRejectsInvalidOrUnavailablePort(t *testing.T) { defer listener.Close() port := listener.Addr().(*net.TCPAddr).Port - if _, enabled, err := Initialize(ctx, Config{Port: port}, handle); err == nil || enabled { + if _, enabled, err := Initialize(ctx, Config{Port: port}, handle, nil); err == nil || enabled { t.Fatalf("expected occupied port to fail, got enabled=%v err=%v", enabled, err) } } diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 78971c12fc..9a15baf7b6 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -20,7 +20,12 @@ type Server struct { httpServer *http.Server } -func Initialize(ctx context.Context, config Config, handle persistence.BasicHandle) (*Server, bool, error) { +func Initialize( + ctx context.Context, + config Config, + handle persistence.BasicHandle, + engine Engine, +) (*Server, bool, error) { if config.Port == 0 { return nil, false, nil } @@ -28,7 +33,7 @@ func Initialize(ctx context.Context, config Config, handle persistence.BasicHand return nil, false, fmt.Errorf("invalid covenant signer port [%d]", config.Port) } - service, err := NewService(handle, NewPassiveEngine()) + service, err := NewService(handle, engine) if err != nil { return nil, false, err } diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go new file mode 100644 index 0000000000..451b164684 --- /dev/null +++ b/pkg/tbtc/covenant_signer.go @@ -0,0 +1,397 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "strings" + + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/txscript" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/covenantsigner" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +type covenantSignerEngine struct { + node *node +} + +func newCovenantSignerEngine(node *node) covenantsigner.Engine { + return &covenantSignerEngine{node: node} +} + +func (cse *covenantSignerEngine) OnSubmit( + ctx context.Context, + job *covenantsigner.Job, +) (*covenantsigner.Transition, error) { + switch job.Route { + case covenantsigner.TemplateSelfV1: + return cse.submitSelfV1(ctx, job), nil + case covenantsigner.TemplateQcV1: + return &covenantsigner.Transition{ + State: covenantsigner.JobStatePending, + Detail: "accepted for qc_v1 signer coordination", + }, nil + default: + return &covenantsigner.Transition{ + State: covenantsigner.JobStateFailed, + Reason: covenantsigner.ReasonInvalidInput, + Detail: "unsupported covenant route", + }, nil + } +} + +func (cse *covenantSignerEngine) OnPoll( + context.Context, + *covenantsigner.Job, +) (*covenantsigner.Transition, error) { + return nil, nil +} + +func (cse *covenantSignerEngine) submitSelfV1( + ctx context.Context, + job *covenantsigner.Job, +) *covenantsigner.Transition { + template, err := decodeSelfV1Template(job.Request.ScriptTemplate) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + + walletPublicKey, err := parseCompressedPublicKey(template.SignerPublicKey) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, "invalid self_v1 signer public key") + } + + signingExecutor, ok, err := cse.node.getSigningExecutor(walletPublicKey) + if err != nil { + return failedTransition(covenantsigner.ReasonProviderFailed, fmt.Sprintf("cannot resolve signing executor: %v", err)) + } + if !ok { + return failedTransition(covenantsigner.ReasonPolicyRejected, "wallet is not controlled by this node") + } + + witnessScript, err := buildSelfV1WitnessScript(template, job.Request.MaturityHeight) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + + activeUtxo, err := cse.resolveSelfV1ActiveUtxo(job.Request, witnessScript) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + + transaction, err := cse.buildAndSignSelfV1Transaction( + ctx, + signingExecutor, + job.Request, + activeUtxo, + witnessScript, + ) + if err != nil { + return failedTransition(covenantsigner.ReasonProviderFailed, err.Error()) + } + + transactionHex := "0x" + hex.EncodeToString(transaction.Serialize(bitcoin.Witness)) + + // Until the wider stack standardizes a PSBT-native artifact hash, + // return a deterministic 32-byte artifact identifier derived from the + // final witness transaction serialization. + psbtHash := "0x" + transaction.WitnessHash().Hex(bitcoin.InternalByteOrder) + + return &covenantsigner.Transition{ + State: covenantsigner.JobStateArtifactReady, + Detail: "self_v1 artifact ready", + PSBTHash: psbtHash, + TransactionHex: transactionHex, + } +} + +func decodeSelfV1Template(raw json.RawMessage) (*covenantsigner.SelfV1Template, error) { + template := &covenantsigner.SelfV1Template{} + if err := json.Unmarshal(raw, template); err != nil { + return nil, fmt.Errorf("cannot decode self_v1 template: %v", err) + } + if template.Template != covenantsigner.TemplateSelfV1 { + return nil, fmt.Errorf("request template must be self_v1") + } + return template, nil +} + +func parseCompressedPublicKey(encoded string) (*ecdsa.PublicKey, error) { + bytes, err := canonicalCompressedPublicKeyBytes(encoded) + if err != nil { + return nil, err + } + + parsed, err := btcec.ParsePubKey(bytes, btcec.S256()) + if err != nil { + return nil, err + } + + return &ecdsa.PublicKey{ + Curve: tecdsa.Curve, + X: parsed.X, + Y: parsed.Y, + }, nil +} + +func buildSelfV1WitnessScript( + template *covenantsigner.SelfV1Template, + maturityHeight uint64, +) (bitcoin.Script, error) { + if maturityHeight > math.MaxUint32 { + return nil, fmt.Errorf("maturity height exceeds bitcoin locktime range") + } + if template.Delta2 > math.MaxUint32 || maturityHeight > math.MaxUint32-template.Delta2 { + return nil, fmt.Errorf("self_v1 delta2 overflows bitcoin locktime range") + } + + depositorPublicKey, err := canonicalCompressedPublicKeyBytes(template.DepositorPublicKey) + if err != nil { + return nil, fmt.Errorf("invalid self_v1 depositor public key") + } + signerPublicKey, err := canonicalCompressedPublicKeyBytes(template.SignerPublicKey) + if err != nil { + return nil, fmt.Errorf("invalid self_v1 signer public key") + } + + maturityScriptNumber, err := encodeScriptNumber(uint32(maturityHeight)) + if err != nil { + return nil, err + } + lastResortScriptNumber, err := encodeScriptNumber(uint32(maturityHeight + template.Delta2)) + if err != nil { + return nil, err + } + + return txscript.NewScriptBuilder(). + AddOp(txscript.OP_IF). + AddOp(txscript.OP_2). + AddData(depositorPublicKey). + AddData(signerPublicKey). + AddOp(txscript.OP_2). + AddOp(txscript.OP_CHECKMULTISIG). + AddOp(txscript.OP_ELSE). + AddOp(txscript.OP_IF). + AddData(maturityScriptNumber). + AddOp(txscript.OP_CHECKLOCKTIMEVERIFY). + AddOp(txscript.OP_DROP). + AddData(signerPublicKey). + AddOp(txscript.OP_CHECKSIG). + AddOp(txscript.OP_ELSE). + AddData(lastResortScriptNumber). + AddOp(txscript.OP_CHECKLOCKTIMEVERIFY). + AddOp(txscript.OP_DROP). + AddData(depositorPublicKey). + AddOp(txscript.OP_CHECKSIG). + AddOp(txscript.OP_ENDIF). + AddOp(txscript.OP_ENDIF). + Script() +} + +func (cse *covenantSignerEngine) resolveSelfV1ActiveUtxo( + request covenantsigner.RouteSubmitRequest, + witnessScript bitcoin.Script, +) (*bitcoin.UnspentTransactionOutput, error) { + activeTxHash, err := bitcoin.NewHashFromString( + strings.TrimPrefix(request.ActiveOutpoint.TxID, "0x"), + bitcoin.ReversedByteOrder, + ) + if err != nil { + return nil, fmt.Errorf("active outpoint txid is invalid") + } + + transaction, err := cse.node.btcChain.GetTransaction(activeTxHash) + if err != nil { + return nil, fmt.Errorf("active outpoint transaction not found") + } + if int(request.ActiveOutpoint.Vout) >= len(transaction.Outputs) { + return nil, fmt.Errorf("active outpoint output index is out of range") + } + + expectedWitnessScriptHash := bitcoin.WitnessScriptHash(witnessScript) + expectedScriptPubKey, err := bitcoin.PayToWitnessScriptHash(expectedWitnessScriptHash) + if err != nil { + return nil, fmt.Errorf("cannot build expected self_v1 locking script: %v", err) + } + + actualOutput := transaction.Outputs[request.ActiveOutpoint.Vout] + if !bytes.Equal(actualOutput.PublicKeyScript, expectedScriptPubKey) { + return nil, fmt.Errorf("active outpoint script does not match self_v1 template") + } + if actualOutput.Value <= 0 { + return nil, fmt.Errorf("active outpoint value must be greater than zero") + } + if uint64(actualOutput.Value) != request.MigrationTransactionPlan.InputValueSats { + return nil, fmt.Errorf("active outpoint value does not match migration transaction plan") + } + + if request.ActiveOutpoint.ScriptHash != "" { + scriptHash := sha256.Sum256(expectedScriptPubKey) + expectedScriptHash := "0x" + hex.EncodeToString(scriptHash[:]) + if strings.ToLower(request.ActiveOutpoint.ScriptHash) != expectedScriptHash { + return nil, fmt.Errorf("active outpoint script hash does not match self_v1 template") + } + } + + return &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: activeTxHash, + OutputIndex: request.ActiveOutpoint.Vout, + }, + Value: actualOutput.Value, + }, nil +} + +func (cse *covenantSignerEngine) buildAndSignSelfV1Transaction( + ctx context.Context, + signingExecutor *signingExecutor, + request covenantsigner.RouteSubmitRequest, + activeUtxo *bitcoin.UnspentTransactionOutput, + witnessScript bitcoin.Script, +) (*bitcoin.Transaction, error) { + destinationScript, err := decodePrefixedHex(request.MigrationDestination.DepositScript) + if err != nil { + return nil, fmt.Errorf("migration destination deposit script is invalid") + } + + builder := bitcoin.NewTransactionBuilder(cse.node.btcChain) + if err := builder.AddScriptHashInput(activeUtxo, witnessScript); err != nil { + return nil, fmt.Errorf("cannot add covenant input: %v", err) + } + if err := builder.SetInputSequence(0, request.MigrationTransactionPlan.InputSequence); err != nil { + return nil, fmt.Errorf("cannot set covenant input sequence: %v", err) + } + builder.SetLocktime(uint32(request.MigrationTransactionPlan.LockTime)) + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: int64(request.MigrationTransactionPlan.DestinationValueSats), + PublicKeyScript: destinationScript, + }) + + anchorScript, err := canonicalAnchorScriptPubKey() + if err != nil { + return nil, err + } + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: int64(request.MigrationTransactionPlan.AnchorValueSats), + PublicKeyScript: anchorScript, + }) + + sigHashes, err := builder.ComputeSignatureHashes() + if err != nil { + return nil, fmt.Errorf("cannot compute covenant sighash: %v", err) + } + if len(sigHashes) != 1 { + return nil, fmt.Errorf("unexpected covenant sighash count") + } + + startBlock, err := signingExecutor.getCurrentBlockFn() + if err != nil { + return nil, fmt.Errorf("cannot determine signing start block: %v", err) + } + + signatures, err := signingExecutor.signBatch(ctx, sigHashes, startBlock) + if err != nil { + return nil, fmt.Errorf("cannot sign covenant transaction: %v", err) + } + if len(signatures) != 1 { + return nil, fmt.Errorf("unexpected covenant signature count") + } + + witness, err := buildSelfV1MigrationWitness(signatures[0], witnessScript) + if err != nil { + return nil, err + } + if err := builder.SetInputWitness(0, witness); err != nil { + return nil, fmt.Errorf("cannot set covenant witness: %v", err) + } + + transaction := builder.Build() + if len(transaction.Inputs) != 1 { + return nil, fmt.Errorf("unexpected covenant input count") + } + if !bytes.Equal(transaction.Inputs[0].Witness[len(transaction.Inputs[0].Witness)-1], witnessScript) { + // This can never happen with the current builder path, but keeping the + // explicit comparison helps catch future witness-shape regressions. + return nil, fmt.Errorf("unexpected covenant witness stack") + } + + return transaction, nil +} + +func buildSelfV1MigrationWitness( + signature *tecdsa.Signature, + witnessScript bitcoin.Script, +) ([][]byte, error) { + if signature == nil || signature.R == nil || signature.S == nil { + return nil, fmt.Errorf("missing covenant signature") + } + + signatureBytes := append( + (&btcec.Signature{R: signature.R, S: signature.S}).Serialize(), + byte(txscript.SigHashAll), + ) + + return [][]byte{ + signatureBytes, + {0x01}, + {}, + witnessScript, + }, nil +} + +func canonicalAnchorScriptPubKey() (bitcoin.Script, error) { + witnessScriptHash := bitcoin.WitnessScriptHash(bitcoin.Script{txscript.OP_TRUE}) + return bitcoin.PayToWitnessScriptHash(witnessScriptHash) +} + +func decodePrefixedHex(value string) ([]byte, error) { + return hex.DecodeString(strings.TrimPrefix(value, "0x")) +} + +func canonicalCompressedPublicKeyBytes(encoded string) ([]byte, error) { + bytes, err := decodePrefixedHex(encoded) + if err != nil { + return nil, err + } + + parsed, err := btcec.ParsePubKey(bytes, btcec.S256()) + if err != nil { + return nil, err + } + + return parsed.SerializeCompressed(), nil +} + +func encodeScriptNumber(value uint32) ([]byte, error) { + if value == 0 { + return []byte{}, nil + } + + result := make([]byte, 0, 5) + absolute := value + for absolute > 0 { + result = append(result, byte(absolute&0xff)) + absolute >>= 8 + } + + if result[len(result)-1]&0x80 != 0 { + result = append(result, 0x00) + } + + return result, nil +} + +func failedTransition(reason covenantsigner.FailureReason, detail string) *covenantsigner.Transition { + return &covenantsigner.Transition{ + State: covenantsigner.JobStateFailed, + Reason: reason, + Detail: detail, + } +} diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go new file mode 100644 index 0000000000..54c3e98fbe --- /dev/null +++ b/pkg/tbtc/covenant_signer_test.go @@ -0,0 +1,435 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "testing" + + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/covenantsigner" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/generator" + "github.com/keep-network/keep-core/pkg/internal/tecdsatest" + "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +type covenantSignerMemoryDescriptor struct { + name string + directory string + content []byte +} + +func (md *covenantSignerMemoryDescriptor) Name() string { return md.name } +func (md *covenantSignerMemoryDescriptor) Directory() string { return md.directory } +func (md *covenantSignerMemoryDescriptor) Content() ([]byte, error) { + return md.content, nil +} + +type covenantSignerMemoryHandle struct { + items map[string]*covenantSignerMemoryDescriptor +} + +func newCovenantSignerMemoryHandle() *covenantSignerMemoryHandle { + return &covenantSignerMemoryHandle{items: make(map[string]*covenantSignerMemoryDescriptor)} +} + +func (h *covenantSignerMemoryHandle) key(directory, name string) string { + return directory + "/" + name +} + +func (h *covenantSignerMemoryHandle) Save(data []byte, directory, name string) error { + h.items[h.key(directory, name)] = &covenantSignerMemoryDescriptor{ + name: name, + directory: directory, + content: append([]byte{}, data...), + } + return nil +} + +func (h *covenantSignerMemoryHandle) Delete(directory, name string) error { + delete(h.items, h.key(directory, name)) + return nil +} + +func (h *covenantSignerMemoryHandle) ReadAll() (<-chan persistence.DataDescriptor, <-chan error) { + dataChan := make(chan persistence.DataDescriptor, len(h.items)) + errChan := make(chan error) + for _, item := range h.items { + dataChan <- item + } + close(dataChan) + close(errChan) + return dataChan, errChan +} + +func TestCovenantSignerEngine_SubmitSelfV1Ready(t *testing.T) { + node, bitcoinChain, walletPublicKey := setupCovenantSignerTestNode(t) + + service, err := covenantsigner.NewService( + newCovenantSignerMemoryHandle(), + newCovenantSignerEngine(node), + ) + if err != nil { + t.Fatal(err) + } + + depositorPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x42}, 32)) + depositorPublicKey := depositorPrivateKey.PubKey().SerializeCompressed() + signerPublicKey := (*btcec.PublicKey)(walletPublicKey).SerializeCompressed() + + template := &covenantsigner.SelfV1Template{ + Template: covenantsigner.TemplateSelfV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPublicKey), + SignerPublicKey: "0x" + hex.EncodeToString(signerPublicKey), + Delta2: 4320, + } + templateJSON, err := json.Marshal(template) + if err != nil { + t.Fatal(err) + } + + maturityHeight := uint64(912345) + witnessScript, err := buildSelfV1WitnessScript(template, maturityHeight) + if err != nil { + t.Fatal(err) + } + witnessScriptHash := bitcoin.WitnessScriptHash(witnessScript) + activeScriptPubKey, err := bitcoin.PayToWitnessScriptHash(witnessScriptHash) + if err != nil { + t.Fatal(err) + } + + destinationScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{ + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + }) + if err != nil { + t.Fatal(err) + } + + const ( + inputValueSats = uint64(1_000_000) + destinationValueSats = uint64(998_000) + anchorValueSats = uint64(330) + feeSats = uint64(1_670) + ) + + prevTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: int64(inputValueSats), + PublicKeyScript: activeScriptPubKey, + }, + }, + Locktime: 0, + } + bitcoinChain.transactions = append(bitcoinChain.transactions, prevTransaction) + + activeScriptHash := sha256.Sum256(activeScriptPubKey) + revealer := "0x2222222222222222222222222222222222222222" + reserve := "0x1111111111111111111111111111111111111111" + vault := "0x3333333333333333333333333333333333333333" + + migrationDestination := &covenantsigner.MigrationDestinationReservation{ + ReservationID: "cmdr_self_1", + Reserve: reserve, + Epoch: 12, + Route: covenantsigner.ReservationRouteMigration, + Revealer: revealer, + Vault: vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusReserved, + DepositScript: "0x" + hex.EncodeToString(destinationScript), + } + migrationDestination.DepositScriptHash = testDepositScriptHash(t, destinationScript) + migrationDestination.MigrationExtraData = testMigrationExtraData(revealer) + migrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, migrationDestination) + + request := covenantsigner.RouteSubmitRequest{ + FacadeRequestID: "rf_self_1", + IdempotencyKey: "idem_self_1", + Route: covenantsigner.TemplateSelfV1, + Strategy: "0x1234", + Reserve: reserve, + Epoch: 12, + MaturityHeight: maturityHeight, + ActiveOutpoint: covenantsigner.CovenantOutpoint{TxID: "0x" + prevTransaction.Hash().Hex(bitcoin.ReversedByteOrder), Vout: 0, ScriptHash: "0x" + hex.EncodeToString(activeScriptHash[:])}, + DestinationCommitmentHash: migrationDestination.DestinationCommitmentHash, + MigrationDestination: migrationDestination, + MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ + InputValueSats: inputValueSats, + DestinationValueSats: destinationValueSats, + AnchorValueSats: anchorValueSats, + FeeSats: feeSats, + InputSequence: 0xfffffffd, + LockTime: maturityHeight, + }, + ArtifactSignatures: []string{"0x0708"}, + Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, + ScriptTemplate: templateJSON, + Signing: covenantsigner.SigningRequirements{ + SignerRequired: true, + CustodianRequired: false, + }, + } + + result, err := service.Submit(context.Background(), covenantsigner.TemplateSelfV1, covenantsigner.SignerSubmitInput{ + RouteRequestID: "ors_self_ready", + Stage: covenantsigner.StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + if result.Status != covenantsigner.StepStatusReady { + t.Fatalf("expected READY, got %s", result.Status) + } + if result.PSBTHash == "" || result.TransactionHex == "" { + t.Fatalf("expected final artifact payload, got %#v", result) + } + + transactionBytes, err := hex.DecodeString(strings.TrimPrefix(result.TransactionHex, "0x")) + if err != nil { + t.Fatal(err) + } + + transaction := &bitcoin.Transaction{} + if err := transaction.Deserialize(transactionBytes); err != nil { + t.Fatal(err) + } + + if transaction.Locktime != uint32(maturityHeight) { + t.Fatalf("unexpected locktime: %d", transaction.Locktime) + } + if len(transaction.Inputs) != 1 { + t.Fatalf("unexpected input count: %d", len(transaction.Inputs)) + } + if transaction.Inputs[0].Sequence != 0xfffffffd { + t.Fatalf("unexpected input sequence: %x", transaction.Inputs[0].Sequence) + } + if len(transaction.Outputs) != 2 { + t.Fatalf("unexpected output count: %d", len(transaction.Outputs)) + } + if transaction.Outputs[0].Value != int64(destinationValueSats) { + t.Fatalf("unexpected destination value: %d", transaction.Outputs[0].Value) + } + if !bytes.Equal(transaction.Outputs[0].PublicKeyScript, destinationScript) { + t.Fatal("unexpected destination output script") + } + + expectedAnchorScript, err := canonicalAnchorScriptPubKey() + if err != nil { + t.Fatal(err) + } + if transaction.Outputs[1].Value != int64(anchorValueSats) { + t.Fatalf("unexpected anchor value: %d", transaction.Outputs[1].Value) + } + if !bytes.Equal(transaction.Outputs[1].PublicKeyScript, expectedAnchorScript) { + t.Fatal("unexpected anchor output script") + } + + if len(transaction.Inputs[0].Witness) != 4 { + t.Fatalf("unexpected witness item count: %d", len(transaction.Inputs[0].Witness)) + } + if !bytes.Equal(transaction.Inputs[0].Witness[1], []byte{0x01}) { + t.Fatal("missing migration selector witness item") + } + if len(transaction.Inputs[0].Witness[2]) != 0 { + t.Fatal("expected empty second selector witness item") + } + if !bytes.Equal(transaction.Inputs[0].Witness[3], witnessScript) { + t.Fatal("unexpected witness script") + } + + if result.PSBTHash != "0x"+transaction.WitnessHash().Hex(bitcoin.InternalByteOrder) { + t.Fatalf("unexpected psbtHash: %s", result.PSBTHash) + } + + signatureWithHashType := transaction.Inputs[0].Witness[0] + if len(signatureWithHashType) == 0 || signatureWithHashType[len(signatureWithHashType)-1] != byte(txscript.SigHashAll) { + t.Fatal("unexpected sighash type in witness signature") + } + + wireTransaction := wire.NewMsgTx(wire.TxVersion) + if err := wireTransaction.Deserialize(bytes.NewReader(transaction.Serialize(bitcoin.Witness))); err != nil { + t.Fatal(err) + } + + sighashBytes, err := txscript.CalcWitnessSigHash( + witnessScript, + txscript.NewTxSigHashes(wireTransaction), + txscript.SigHashAll, + wireTransaction, + 0, + int64(inputValueSats), + ) + if err != nil { + t.Fatal(err) + } + + parsedSignature, err := btcec.ParseDERSignature(signatureWithHashType[:len(signatureWithHashType)-1], btcec.S256()) + if err != nil { + t.Fatal(err) + } + if !ecdsa.Verify(walletPublicKey, sighashBytes, parsedSignature.R, parsedSignature.S) { + t.Fatal("invalid covenant signature") + } +} + +func setupCovenantSignerTestNode( + t *testing.T, +) (*node, *localBitcoinChain, *ecdsa.PublicKey) { + t.Helper() + + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + operatorPrivateKey, operatorPublicKey, err := operator.GenerateKeyPair(local_v1.DefaultCurve) + if err != nil { + t.Fatal(err) + } + + localChain := ConnectWithKey(operatorPrivateKey) + localProvider := local.ConnectWithKey(operatorPublicKey) + bitcoinChain := newLocalBitcoinChain() + + operatorAddress, err := localChain.Signing().PublicKeyToAddress(operatorPublicKey) + if err != nil { + t.Fatal(err) + } + + var operators []chain.Address + for i := 0; i < groupParameters.GroupSize; i++ { + operators = append(operators, operatorAddress) + } + + testData, err := tecdsatest.LoadPrivateKeyShareTestFixtures(groupParameters.GroupSize) + if err != nil { + t.Fatalf("failed to load test data: [%v]", err) + } + + signers := make([]*signer, len(testData)) + for i := range testData { + privateKeyShare := tecdsa.NewPrivateKeyShare(testData[i]) + signers[i] = &signer{ + wallet: wallet{ + publicKey: privateKeyShare.PublicKey(), + signingGroupOperators: operators, + }, + signingGroupMemberIndex: group.MemberIndex(i + 1), + privateKeyShare: privateKeyShare, + } + } + + walletPublicKeyHash := bitcoin.PublicKeyHash(signers[0].wallet.publicKey) + walletID, err := localChain.CalculateWalletID(signers[0].wallet.publicKey) + if err != nil { + t.Fatal(err) + } + + localChain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: walletID, + State: StateLive, + }, + ) + + node, err := newNode( + groupParameters, + localChain, + bitcoinChain, + localProvider, + createMockKeyStorePersistence(t, signers...), + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{}, + ) + if err != nil { + t.Fatal(err) + } + + executor, ok, err := node.getSigningExecutor(signers[0].wallet.publicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + executor.signingAttemptsLimit *= 8 + + return node, bitcoinChain, signers[0].wallet.publicKey +} + +func testMigrationExtraData(revealer string) string { + return "0x" + hex.EncodeToString([]byte("AC_MIGRATEV1")) + strings.TrimPrefix(strings.ToLower(revealer), "0x") +} + +func testDepositScriptHash(t *testing.T, depositScript bitcoin.Script) string { + t.Helper() + + sum := sha256.Sum256(depositScript) + return "0x" + hex.EncodeToString(sum[:]) +} + +type testDestinationCommitmentPayload struct { + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route string `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + DepositScriptHash string `json:"depositScriptHash"` + MigrationExtraData string `json:"migrationExtraData"` +} + +func testDestinationCommitmentHash( + t *testing.T, + reservation *covenantsigner.MigrationDestinationReservation, +) string { + t.Helper() + + payload, err := json.Marshal(testDestinationCommitmentPayload{ + Reserve: strings.ToLower(reservation.Reserve), + Epoch: reservation.Epoch, + Route: string(reservation.Route), + Revealer: strings.ToLower(reservation.Revealer), + Vault: strings.ToLower(reservation.Vault), + Network: strings.TrimSpace(reservation.Network), + DepositScriptHash: strings.ToLower(reservation.DepositScriptHash), + MigrationExtraData: strings.ToLower(reservation.MigrationExtraData), + }) + if err != nil { + t.Fatal(err) + } + + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]) +} diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 62b226aed6..65c93f841f 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -7,6 +7,7 @@ import ( "time" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/covenantsigner" "github.com/ipfs/go-log" @@ -69,7 +70,8 @@ type Config struct { // Initialize kicks off the TBTC by initializing internal state, ensuring // preconditions like staking are met, and then kicking off the internal TBTC -// implementation. Returns an error if this failed. +// implementation. Returns the covenant signer engine bound to the initialized +// node together with an error if initialization failed. func Initialize( ctx context.Context, chain Chain, @@ -82,7 +84,7 @@ func Initialize( config Config, clientInfo *clientinfo.Registry, perfMetrics *clientinfo.PerformanceMetrics, -) error { +) (covenantsigner.Engine, error) { groupParameters := &GroupParameters{ GroupSize: 100, GroupQuorum: 90, @@ -101,12 +103,12 @@ func Initialize( config, ) if err != nil { - return fmt.Errorf("cannot set up TBTC node: [%v]", err) + return nil, fmt.Errorf("cannot set up TBTC node: [%v]", err) } err = node.runCoordinationLayer(ctx) if err != nil { - return fmt.Errorf("cannot run coordination layer: [%w]", err) + return nil, fmt.Errorf("cannot run coordination layer: [%w]", err) } deduplicator := newDeduplicator() @@ -161,7 +163,7 @@ func Initialize( ), ) if err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "could not set up sortition pool monitoring: [%v]", err, ) @@ -323,7 +325,7 @@ func Initialize( }() }) - return nil + return newCovenantSignerEngine(node), nil } // enoughPreParamsInPoolPolicy is a policy that enforces the sufficient size From 2c9c05f2533926a1a6a11df5c4931cc4c0138cbc Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 10:52:45 -0500 Subject: [PATCH 008/143] fix(tbtc): harden self_v1 signer validation --- pkg/tbtc/covenant_signer.go | 50 ++++++++++++- pkg/tbtc/covenant_signer_test.go | 118 +++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 2 deletions(-) diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index 451b164684..df095b3367 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -85,6 +85,9 @@ func (cse *covenantSignerEngine) submitSelfV1( if err != nil { return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) } + if err := validateSelfV1OutputValues(job.Request); err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } transaction, err := cse.buildAndSignSelfV1Transaction( ctx, @@ -145,6 +148,9 @@ func buildSelfV1WitnessScript( template *covenantsigner.SelfV1Template, maturityHeight uint64, ) (bitcoin.Script, error) { + if maturityHeight == 0 { + return nil, fmt.Errorf("maturity height must be greater than zero") + } if maturityHeight > math.MaxUint32 { return nil, fmt.Errorf("maturity height exceeds bitcoin locktime range") } @@ -233,6 +239,8 @@ func (cse *covenantSignerEngine) resolveSelfV1ActiveUtxo( } if request.ActiveOutpoint.ScriptHash != "" { + // The optional scriptHash convention follows the tBTC-side request + // contract: sha256(scriptPubKey) for the active covenant output. scriptHash := sha256.Sum256(expectedScriptPubKey) expectedScriptHash := "0x" + hex.EncodeToString(scriptHash[:]) if strings.ToLower(request.ActiveOutpoint.ScriptHash) != expectedScriptHash { @@ -249,6 +257,22 @@ func (cse *covenantSignerEngine) resolveSelfV1ActiveUtxo( }, nil } +func validateSelfV1OutputValues(request covenantsigner.RouteSubmitRequest) error { + _, err := toBitcoinOutputValue( + request.MigrationTransactionPlan.DestinationValueSats, + "migration destination value", + ) + if err != nil { + return err + } + + _, err = toBitcoinOutputValue( + request.MigrationTransactionPlan.AnchorValueSats, + "migration anchor value", + ) + return err +} + func (cse *covenantSignerEngine) buildAndSignSelfV1Transaction( ctx context.Context, signingExecutor *signingExecutor, @@ -260,6 +284,20 @@ func (cse *covenantSignerEngine) buildAndSignSelfV1Transaction( if err != nil { return nil, fmt.Errorf("migration destination deposit script is invalid") } + destinationValue, err := toBitcoinOutputValue( + request.MigrationTransactionPlan.DestinationValueSats, + "migration destination value", + ) + if err != nil { + return nil, err + } + anchorValue, err := toBitcoinOutputValue( + request.MigrationTransactionPlan.AnchorValueSats, + "migration anchor value", + ) + if err != nil { + return nil, err + } builder := bitcoin.NewTransactionBuilder(cse.node.btcChain) if err := builder.AddScriptHashInput(activeUtxo, witnessScript); err != nil { @@ -270,7 +308,7 @@ func (cse *covenantSignerEngine) buildAndSignSelfV1Transaction( } builder.SetLocktime(uint32(request.MigrationTransactionPlan.LockTime)) builder.AddOutput(&bitcoin.TransactionOutput{ - Value: int64(request.MigrationTransactionPlan.DestinationValueSats), + Value: destinationValue, PublicKeyScript: destinationScript, }) @@ -279,7 +317,7 @@ func (cse *covenantSignerEngine) buildAndSignSelfV1Transaction( return nil, err } builder.AddOutput(&bitcoin.TransactionOutput{ - Value: int64(request.MigrationTransactionPlan.AnchorValueSats), + Value: anchorValue, PublicKeyScript: anchorScript, }) @@ -369,6 +407,14 @@ func canonicalCompressedPublicKeyBytes(encoded string) ([]byte, error) { return parsed.SerializeCompressed(), nil } +func toBitcoinOutputValue(value uint64, field string) (int64, error) { + if value > math.MaxInt64 { + return 0, fmt.Errorf("%s exceeds bitcoin output value range", field) + } + + return int64(value), nil +} + func encodeScriptNumber(value uint32) ([]byte, error) { if value == 0 { return []byte{}, nil diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index 54c3e98fbe..4244a8f901 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "math" "strings" "testing" @@ -299,6 +300,123 @@ func TestCovenantSignerEngine_SubmitSelfV1Ready(t *testing.T) { } } +func TestCovenantSignerEngine_SubmitSelfV1RejectsZeroMaturityHeight(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + + service, err := covenantsigner.NewService( + newCovenantSignerMemoryHandle(), + newCovenantSignerEngine(node), + ) + if err != nil { + t.Fatal(err) + } + + depositorPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x42}, 32)) + depositorPublicKey := depositorPrivateKey.PubKey().SerializeCompressed() + signerPublicKey := (*btcec.PublicKey)(walletPublicKey).SerializeCompressed() + + template := &covenantsigner.SelfV1Template{ + Template: covenantsigner.TemplateSelfV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPublicKey), + SignerPublicKey: "0x" + hex.EncodeToString(signerPublicKey), + Delta2: 4320, + } + templateJSON, err := json.Marshal(template) + if err != nil { + t.Fatal(err) + } + + destinationScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{ + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + }) + if err != nil { + t.Fatal(err) + } + + revealer := "0x2222222222222222222222222222222222222222" + reserve := "0x1111111111111111111111111111111111111111" + vault := "0x3333333333333333333333333333333333333333" + request := covenantsigner.RouteSubmitRequest{ + FacadeRequestID: "rf_self_zero", + IdempotencyKey: "idem_self_zero", + Route: covenantsigner.TemplateSelfV1, + Strategy: "0x1234", + Reserve: reserve, + Epoch: 12, + MaturityHeight: 0, + ActiveOutpoint: covenantsigner.CovenantOutpoint{ + TxID: "0x" + strings.Repeat("11", 32), + }, + MigrationDestination: &covenantsigner.MigrationDestinationReservation{ + ReservationID: "cmdr_self_zero", + Reserve: reserve, + Epoch: 12, + Route: covenantsigner.ReservationRouteMigration, + Revealer: revealer, + Vault: vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusReserved, + DepositScript: "0x" + hex.EncodeToString(destinationScript), + }, + MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ + InputValueSats: 1_000_000, + DestinationValueSats: 998_000, + AnchorValueSats: 330, + FeeSats: 1_670, + InputSequence: 0xfffffffd, + LockTime: 0, + }, + ArtifactSignatures: []string{"0x0708"}, + Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, + ScriptTemplate: templateJSON, + Signing: covenantsigner.SigningRequirements{ + SignerRequired: true, + CustodianRequired: false, + }, + } + request.MigrationDestination.DepositScriptHash = testDepositScriptHash(t, destinationScript) + request.MigrationDestination.MigrationExtraData = testMigrationExtraData(revealer) + request.MigrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, request.MigrationDestination) + request.DestinationCommitmentHash = request.MigrationDestination.DestinationCommitmentHash + + result, err := service.Submit(context.Background(), covenantsigner.TemplateSelfV1, covenantsigner.SignerSubmitInput{ + RouteRequestID: "ors_self_zero", + Stage: covenantsigner.StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + if result.Status != covenantsigner.StepStatusFailed { + t.Fatalf("expected FAILED, got %s", result.Status) + } + if result.Reason != covenantsigner.ReasonInvalidInput { + t.Fatalf("unexpected failure reason: %s", result.Reason) + } + if !strings.Contains(result.Detail, "maturity height must be greater than zero") { + t.Fatalf("unexpected failure detail: %s", result.Detail) + } +} + +func TestValidateSelfV1OutputValues_RejectsValuesExceedingInt64(t *testing.T) { + err := validateSelfV1OutputValues(covenantsigner.RouteSubmitRequest{ + MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ + DestinationValueSats: uint64(math.MaxInt64) + 1, + AnchorValueSats: 330, + }, + }) + if err == nil { + t.Fatal("expected output value validation error") + } + if !strings.Contains(err.Error(), "migration destination value exceeds bitcoin output value range") { + t.Fatalf("unexpected error: %v", err) + } +} + func setupCovenantSignerTestNode( t *testing.T, ) (*node, *localBitcoinChain, *ecdsa.PublicKey) { From a65370f978f564ac912242e75672f41a2c102706 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 11:10:22 -0500 Subject: [PATCH 009/143] fix(tbtc): repair covenant signer project branch CI --- pkg/covenantsigner/server.go | 6 ++++-- pkg/tbtc/covenant_signer_test.go | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 9a15baf7b6..c9f707f71c 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "strings" + "time" "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-common/pkg/persistence" @@ -41,8 +42,9 @@ func Initialize( server := &Server{ service: service, httpServer: &http.Server{ - Addr: fmt.Sprintf(":%d", config.Port), - Handler: newHandler(service), + Addr: fmt.Sprintf(":%d", config.Port), + Handler: newHandler(service), + ReadHeaderTimeout: 5 * time.Second, }, } diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index 4244a8f901..a7d4d72173 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -16,9 +16,9 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/keep-network/keep-common/pkg/persistence" "github.com/keep-network/keep-core/pkg/bitcoin" - "github.com/keep-network/keep-core/pkg/covenantsigner" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/covenantsigner" "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/internal/tecdsatest" "github.com/keep-network/keep-core/pkg/net/local" @@ -351,15 +351,15 @@ func TestCovenantSignerEngine_SubmitSelfV1RejectsZeroMaturityHeight(t *testing.T TxID: "0x" + strings.Repeat("11", 32), }, MigrationDestination: &covenantsigner.MigrationDestinationReservation{ - ReservationID: "cmdr_self_zero", - Reserve: reserve, - Epoch: 12, - Route: covenantsigner.ReservationRouteMigration, - Revealer: revealer, - Vault: vault, - Network: "regtest", - Status: covenantsigner.ReservationStatusReserved, - DepositScript: "0x" + hex.EncodeToString(destinationScript), + ReservationID: "cmdr_self_zero", + Reserve: reserve, + Epoch: 12, + Route: covenantsigner.ReservationRouteMigration, + Revealer: revealer, + Vault: vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusReserved, + DepositScript: "0x" + hex.EncodeToString(destinationScript), }, MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ InputValueSats: 1_000_000, From d0344e7a9e0d086fa26b0f7d8071cf0ddf103312 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 11:07:32 -0500 Subject: [PATCH 010/143] feat(tbtc): add qc_v1 signer handoff --- pkg/tbtc/covenant_signer.go | 384 +++++++++++++++++++++++++++++-- pkg/tbtc/covenant_signer_test.go | 280 +++++++++++++++++++++- 2 files changed, 649 insertions(+), 15 deletions(-) diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index df095b3367..d6109894a5 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -22,6 +22,22 @@ type covenantSignerEngine struct { node *node } +const qcV1SignerHandoffKind = "qc_v1_signer_handoff_v1" + +type qcV1SignerHandoff struct { + Kind string + SignerRequestID string + BundleID string + DestinationCommitmentHash string + PayloadHash string + UnsignedTransactionHex string + WitnessScript string + SignerSignature string + SelectorWitnessItems []string + RequiresDummy bool + SighashType uint32 +} + func newCovenantSignerEngine(node *node) covenantsigner.Engine { return &covenantSignerEngine{node: node} } @@ -34,10 +50,7 @@ func (cse *covenantSignerEngine) OnSubmit( case covenantsigner.TemplateSelfV1: return cse.submitSelfV1(ctx, job), nil case covenantsigner.TemplateQcV1: - return &covenantsigner.Transition{ - State: covenantsigner.JobStatePending, - Detail: "accepted for qc_v1 signer coordination", - }, nil + return cse.submitQcV1(ctx, job), nil default: return &covenantsigner.Transition{ State: covenantsigner.JobStateFailed, @@ -85,7 +98,7 @@ func (cse *covenantSignerEngine) submitSelfV1( if err != nil { return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) } - if err := validateSelfV1OutputValues(job.Request); err != nil { + if err := validateMigrationOutputValues(job.Request); err != nil { return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) } @@ -115,6 +128,60 @@ func (cse *covenantSignerEngine) submitSelfV1( } } +func (cse *covenantSignerEngine) submitQcV1( + ctx context.Context, + job *covenantsigner.Job, +) *covenantsigner.Transition { + template, err := decodeQcV1Template(job.Request.ScriptTemplate) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + + walletPublicKey, err := parseCompressedPublicKey(template.SignerPublicKey) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, "invalid qc_v1 signer public key") + } + + signingExecutor, ok, err := cse.node.getSigningExecutor(walletPublicKey) + if err != nil { + return failedTransition(covenantsigner.ReasonProviderFailed, fmt.Sprintf("cannot resolve signing executor: %v", err)) + } + if !ok { + return failedTransition(covenantsigner.ReasonPolicyRejected, "wallet is not controlled by this node") + } + + witnessScript, err := buildQcV1WitnessScript(template, job.Request.MaturityHeight) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + + activeUtxo, err := cse.resolveQcV1ActiveUtxo(job.Request, witnessScript) + if err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + if err := validateMigrationOutputValues(job.Request); err != nil { + return failedTransition(covenantsigner.ReasonInvalidInput, err.Error()) + } + + handoff, err := cse.buildQcV1SignerHandoff( + ctx, + job.RequestID, + signingExecutor, + job.Request, + activeUtxo, + witnessScript, + ) + if err != nil { + return failedTransition(covenantsigner.ReasonProviderFailed, err.Error()) + } + + return &covenantsigner.Transition{ + State: covenantsigner.JobStateHandoffReady, + Detail: "qc_v1 signer handoff ready for custodian coordination", + Handoff: handoff.toMap(), + } +} + func decodeSelfV1Template(raw json.RawMessage) (*covenantsigner.SelfV1Template, error) { template := &covenantsigner.SelfV1Template{} if err := json.Unmarshal(raw, template); err != nil { @@ -126,6 +193,17 @@ func decodeSelfV1Template(raw json.RawMessage) (*covenantsigner.SelfV1Template, return template, nil } +func decodeQcV1Template(raw json.RawMessage) (*covenantsigner.QcV1Template, error) { + template := &covenantsigner.QcV1Template{} + if err := json.Unmarshal(raw, template); err != nil { + return nil, fmt.Errorf("cannot decode qc_v1 template: %v", err) + } + if template.Template != covenantsigner.TemplateQcV1 { + return nil, fmt.Errorf("request template must be qc_v1") + } + return template, nil +} + func parseCompressedPublicKey(encoded string) (*ecdsa.PublicKey, error) { bytes, err := canonicalCompressedPublicKeyBytes(encoded) if err != nil { @@ -201,6 +279,89 @@ func buildSelfV1WitnessScript( Script() } +func buildQcV1WitnessScript( + template *covenantsigner.QcV1Template, + maturityHeight uint64, +) (bitcoin.Script, error) { + if maturityHeight == 0 { + return nil, fmt.Errorf("maturity height must be greater than zero") + } + if maturityHeight > math.MaxUint32 { + return nil, fmt.Errorf("maturity height exceeds bitcoin locktime range") + } + if template.Beta > math.MaxUint32 || template.Beta >= maturityHeight { + return nil, fmt.Errorf("qc_v1 beta must be below maturity height") + } + if template.Delta2 > math.MaxUint32 || maturityHeight > math.MaxUint32-template.Delta2 { + return nil, fmt.Errorf("qc_v1 delta2 overflows bitcoin locktime range") + } + + depositorPublicKey, err := canonicalCompressedPublicKeyBytes(template.DepositorPublicKey) + if err != nil { + return nil, fmt.Errorf("invalid qc_v1 depositor public key") + } + custodianPublicKey, err := canonicalCompressedPublicKeyBytes(template.CustodianPublicKey) + if err != nil { + return nil, fmt.Errorf("invalid qc_v1 custodian public key") + } + signerPublicKey, err := canonicalCompressedPublicKeyBytes(template.SignerPublicKey) + if err != nil { + return nil, fmt.Errorf("invalid qc_v1 signer public key") + } + + maturityScriptNumber, err := encodeScriptNumber(uint32(maturityHeight)) + if err != nil { + return nil, err + } + earlyExitScriptNumber, err := encodeScriptNumber(uint32(maturityHeight - template.Beta)) + if err != nil { + return nil, err + } + lastResortScriptNumber, err := encodeScriptNumber(uint32(maturityHeight + template.Delta2)) + if err != nil { + return nil, err + } + + return txscript.NewScriptBuilder(). + AddOp(txscript.OP_IF). + AddOp(txscript.OP_3). + AddData(depositorPublicKey). + AddData(custodianPublicKey). + AddData(signerPublicKey). + AddOp(txscript.OP_3). + AddOp(txscript.OP_CHECKMULTISIG). + AddOp(txscript.OP_ELSE). + AddOp(txscript.OP_IF). + AddData(maturityScriptNumber). + AddOp(txscript.OP_CHECKLOCKTIMEVERIFY). + AddOp(txscript.OP_DROP). + AddOp(txscript.OP_2). + AddData(signerPublicKey). + AddData(custodianPublicKey). + AddOp(txscript.OP_2). + AddOp(txscript.OP_CHECKMULTISIG). + AddOp(txscript.OP_ELSE). + AddOp(txscript.OP_IF). + AddData(earlyExitScriptNumber). + AddOp(txscript.OP_CHECKLOCKTIMEVERIFY). + AddOp(txscript.OP_DROP). + AddOp(txscript.OP_2). + AddData(depositorPublicKey). + AddData(custodianPublicKey). + AddOp(txscript.OP_2). + AddOp(txscript.OP_CHECKMULTISIG). + AddOp(txscript.OP_ELSE). + AddData(lastResortScriptNumber). + AddOp(txscript.OP_CHECKLOCKTIMEVERIFY). + AddOp(txscript.OP_DROP). + AddData(depositorPublicKey). + AddOp(txscript.OP_CHECKSIG). + AddOp(txscript.OP_ENDIF). + AddOp(txscript.OP_ENDIF). + AddOp(txscript.OP_ENDIF). + Script() +} + func (cse *covenantSignerEngine) resolveSelfV1ActiveUtxo( request covenantsigner.RouteSubmitRequest, witnessScript bitcoin.Script, @@ -257,7 +418,61 @@ func (cse *covenantSignerEngine) resolveSelfV1ActiveUtxo( }, nil } -func validateSelfV1OutputValues(request covenantsigner.RouteSubmitRequest) error { +func (cse *covenantSignerEngine) resolveQcV1ActiveUtxo( + request covenantsigner.RouteSubmitRequest, + witnessScript bitcoin.Script, +) (*bitcoin.UnspentTransactionOutput, error) { + activeTxHash, err := bitcoin.NewHashFromString( + strings.TrimPrefix(request.ActiveOutpoint.TxID, "0x"), + bitcoin.ReversedByteOrder, + ) + if err != nil { + return nil, fmt.Errorf("active outpoint txid is invalid") + } + + transaction, err := cse.node.btcChain.GetTransaction(activeTxHash) + if err != nil { + return nil, fmt.Errorf("active outpoint transaction not found") + } + if int(request.ActiveOutpoint.Vout) >= len(transaction.Outputs) { + return nil, fmt.Errorf("active outpoint output index is out of range") + } + + expectedWitnessScriptHash := bitcoin.WitnessScriptHash(witnessScript) + expectedScriptPubKey, err := bitcoin.PayToWitnessScriptHash(expectedWitnessScriptHash) + if err != nil { + return nil, fmt.Errorf("cannot build expected qc_v1 locking script: %v", err) + } + + actualOutput := transaction.Outputs[request.ActiveOutpoint.Vout] + if !bytes.Equal(actualOutput.PublicKeyScript, expectedScriptPubKey) { + return nil, fmt.Errorf("active outpoint script does not match qc_v1 template") + } + if actualOutput.Value <= 0 { + return nil, fmt.Errorf("active outpoint value must be greater than zero") + } + if uint64(actualOutput.Value) != request.MigrationTransactionPlan.InputValueSats { + return nil, fmt.Errorf("active outpoint value does not match migration transaction plan") + } + + if request.ActiveOutpoint.ScriptHash != "" { + scriptHash := sha256.Sum256(expectedScriptPubKey) + expectedScriptHash := "0x" + hex.EncodeToString(scriptHash[:]) + if strings.ToLower(request.ActiveOutpoint.ScriptHash) != expectedScriptHash { + return nil, fmt.Errorf("active outpoint script hash does not match qc_v1 template") + } + } + + return &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: activeTxHash, + OutputIndex: request.ActiveOutpoint.Vout, + }, + Value: actualOutput.Value, + }, nil +} + +func validateMigrationOutputValues(request covenantsigner.RouteSubmitRequest) error { _, err := toBitcoinOutputValue( request.MigrationTransactionPlan.DestinationValueSats, "migration destination value", @@ -363,19 +578,125 @@ func (cse *covenantSignerEngine) buildAndSignSelfV1Transaction( return transaction, nil } +func (cse *covenantSignerEngine) buildQcV1SignerHandoff( + ctx context.Context, + requestID string, + signingExecutor *signingExecutor, + request covenantsigner.RouteSubmitRequest, + activeUtxo *bitcoin.UnspentTransactionOutput, + witnessScript bitcoin.Script, +) (*qcV1SignerHandoff, error) { + destinationScript, err := decodePrefixedHex(request.MigrationDestination.DepositScript) + if err != nil { + return nil, fmt.Errorf("migration destination deposit script is invalid") + } + destinationValue, err := toBitcoinOutputValue( + request.MigrationTransactionPlan.DestinationValueSats, + "migration destination value", + ) + if err != nil { + return nil, err + } + anchorValue, err := toBitcoinOutputValue( + request.MigrationTransactionPlan.AnchorValueSats, + "migration anchor value", + ) + if err != nil { + return nil, err + } + + builder := bitcoin.NewTransactionBuilder(cse.node.btcChain) + if err := builder.AddScriptHashInput(activeUtxo, witnessScript); err != nil { + return nil, fmt.Errorf("cannot add covenant input: %v", err) + } + if err := builder.SetInputSequence(0, request.MigrationTransactionPlan.InputSequence); err != nil { + return nil, fmt.Errorf("cannot set covenant input sequence: %v", err) + } + builder.SetLocktime(uint32(request.MigrationTransactionPlan.LockTime)) + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: destinationValue, + PublicKeyScript: destinationScript, + }) + + anchorScript, err := canonicalAnchorScriptPubKey() + if err != nil { + return nil, err + } + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: anchorValue, + PublicKeyScript: anchorScript, + }) + + sigHashes, err := builder.ComputeSignatureHashes() + if err != nil { + return nil, fmt.Errorf("cannot compute covenant sighash: %v", err) + } + if len(sigHashes) != 1 { + return nil, fmt.Errorf("unexpected covenant sighash count") + } + + startBlock, err := signingExecutor.getCurrentBlockFn() + if err != nil { + return nil, fmt.Errorf("cannot determine signing start block: %v", err) + } + + signatures, err := signingExecutor.signBatch(ctx, sigHashes, startBlock) + if err != nil { + return nil, fmt.Errorf("cannot sign covenant transaction: %v", err) + } + if len(signatures) != 1 { + return nil, fmt.Errorf("unexpected covenant signature count") + } + + signatureBytes, err := buildWitnessSignatureBytes(signatures[0]) + if err != nil { + return nil, err + } + + unsignedTransaction := builder.Build() + unsignedTransactionHex := "0x" + hex.EncodeToString(unsignedTransaction.Serialize(bitcoin.Standard)) + witnessScriptHex := "0x" + hex.EncodeToString(witnessScript) + signatureHex := "0x" + hex.EncodeToString(signatureBytes) + selectorWitnessItems := []string{"0x01", "0x"} + + payloadHash, err := computeQcV1SignerHandoffPayloadHash(map[string]any{ + "kind": qcV1SignerHandoffKind, + "unsignedTransactionHex": unsignedTransactionHex, + "witnessScript": witnessScriptHex, + "signerSignature": signatureHex, + "selectorWitnessItems": selectorWitnessItems, + "requiresDummy": true, + "sighashType": uint32(txscript.SigHashAll), + "destinationCommitmentHash": request.DestinationCommitmentHash, + }) + if err != nil { + return nil, err + } + + return &qcV1SignerHandoff{ + Kind: qcV1SignerHandoffKind, + SignerRequestID: requestID, + BundleID: payloadHash, + DestinationCommitmentHash: request.DestinationCommitmentHash, + PayloadHash: payloadHash, + UnsignedTransactionHex: unsignedTransactionHex, + WitnessScript: witnessScriptHex, + SignerSignature: signatureHex, + SelectorWitnessItems: selectorWitnessItems, + RequiresDummy: true, + SighashType: uint32(txscript.SigHashAll), + }, nil +} + func buildSelfV1MigrationWitness( signature *tecdsa.Signature, witnessScript bitcoin.Script, ) ([][]byte, error) { - if signature == nil || signature.R == nil || signature.S == nil { - return nil, fmt.Errorf("missing covenant signature") + signatureBytes, err := buildWitnessSignatureBytes(signature) + if err != nil { + return nil, err } - signatureBytes := append( - (&btcec.Signature{R: signature.R, S: signature.S}).Serialize(), - byte(txscript.SigHashAll), - ) - return [][]byte{ signatureBytes, {0x01}, @@ -384,6 +705,43 @@ func buildSelfV1MigrationWitness( }, nil } +func buildWitnessSignatureBytes(signature *tecdsa.Signature) ([]byte, error) { + if signature == nil || signature.R == nil || signature.S == nil { + return nil, fmt.Errorf("missing covenant signature") + } + + return append( + (&btcec.Signature{R: signature.R, S: signature.S}).Serialize(), + byte(txscript.SigHashAll), + ), nil +} + +func computeQcV1SignerHandoffPayloadHash(payload map[string]any) (string, error) { + rawPayload, err := json.Marshal(payload) + if err != nil { + return "", err + } + + sum := sha256.Sum256(rawPayload) + return "0x" + hex.EncodeToString(sum[:]), nil +} + +func (handoff *qcV1SignerHandoff) toMap() map[string]any { + return map[string]any{ + "kind": handoff.Kind, + "signerRequestId": handoff.SignerRequestID, + "bundleId": handoff.BundleID, + "destinationCommitmentHash": handoff.DestinationCommitmentHash, + "payloadHash": handoff.PayloadHash, + "unsignedTransactionHex": handoff.UnsignedTransactionHex, + "witnessScript": handoff.WitnessScript, + "signerSignature": handoff.SignerSignature, + "selectorWitnessItems": handoff.SelectorWitnessItems, + "requiresDummy": handoff.RequiresDummy, + "sighashType": handoff.SighashType, + } +} + func canonicalAnchorScriptPubKey() (bitcoin.Script, error) { witnessScriptHash := bitcoin.WitnessScriptHash(bitcoin.Script{txscript.OP_TRUE}) return bitcoin.PayToWitnessScriptHash(witnessScriptHash) diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index a7d4d72173..1b8446a51d 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -300,6 +300,282 @@ func TestCovenantSignerEngine_SubmitSelfV1Ready(t *testing.T) { } } +func TestCovenantSignerEngine_SubmitQcV1HandoffReady(t *testing.T) { + node, bitcoinChain, walletPublicKey := setupCovenantSignerTestNode(t) + + service, err := covenantsigner.NewService( + newCovenantSignerMemoryHandle(), + newCovenantSignerEngine(node), + ) + if err != nil { + t.Fatal(err) + } + + depositorPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x42}, 32)) + depositorPublicKey := depositorPrivateKey.PubKey().SerializeCompressed() + custodianPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x24}, 32)) + custodianPublicKey := custodianPrivateKey.PubKey().SerializeCompressed() + signerPublicKey := (*btcec.PublicKey)(walletPublicKey).SerializeCompressed() + + template := &covenantsigner.QcV1Template{ + Template: covenantsigner.TemplateQcV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPublicKey), + CustodianPublicKey: "0x" + hex.EncodeToString(custodianPublicKey), + SignerPublicKey: "0x" + hex.EncodeToString(signerPublicKey), + Beta: 144, + Delta2: 4320, + } + templateJSON, err := json.Marshal(template) + if err != nil { + t.Fatal(err) + } + + maturityHeight := uint64(912345) + witnessScript, err := buildQcV1WitnessScript(template, maturityHeight) + if err != nil { + t.Fatal(err) + } + witnessScriptHash := bitcoin.WitnessScriptHash(witnessScript) + activeScriptPubKey, err := bitcoin.PayToWitnessScriptHash(witnessScriptHash) + if err != nil { + t.Fatal(err) + } + + destinationScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{ + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + }) + if err != nil { + t.Fatal(err) + } + + const ( + inputValueSats = uint64(2_000_000) + destinationValueSats = uint64(1_997_500) + anchorValueSats = uint64(330) + feeSats = uint64(2_170) + ) + + prevTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: int64(inputValueSats), + PublicKeyScript: activeScriptPubKey, + }, + }, + Locktime: 0, + } + bitcoinChain.transactions = append(bitcoinChain.transactions, prevTransaction) + + activeScriptHash := sha256.Sum256(activeScriptPubKey) + revealer := "0x4444444444444444444444444444444444444444" + reserve := "0x1111111111111111111111111111111111111111" + vault := "0x3333333333333333333333333333333333333333" + + migrationDestination := &covenantsigner.MigrationDestinationReservation{ + ReservationID: "cmdr_qc_1", + Reserve: reserve, + Epoch: 21, + Route: covenantsigner.ReservationRouteMigration, + Revealer: revealer, + Vault: vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusCommittedToEpoch, + DepositScript: "0x" + hex.EncodeToString(destinationScript), + } + migrationDestination.DepositScriptHash = testDepositScriptHash(t, destinationScript) + migrationDestination.MigrationExtraData = testMigrationExtraData(revealer) + migrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, migrationDestination) + + request := covenantsigner.RouteSubmitRequest{ + FacadeRequestID: "rf_qc_1", + IdempotencyKey: "idem_qc_1", + Route: covenantsigner.TemplateQcV1, + Strategy: "0x1234", + Reserve: reserve, + Epoch: 21, + MaturityHeight: maturityHeight, + ActiveOutpoint: covenantsigner.CovenantOutpoint{TxID: "0x" + prevTransaction.Hash().Hex(bitcoin.ReversedByteOrder), Vout: 0, ScriptHash: "0x" + hex.EncodeToString(activeScriptHash[:])}, + DestinationCommitmentHash: migrationDestination.DestinationCommitmentHash, + MigrationDestination: migrationDestination, + MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ + InputValueSats: inputValueSats, + DestinationValueSats: destinationValueSats, + AnchorValueSats: anchorValueSats, + FeeSats: feeSats, + InputSequence: 0xfffffffd, + LockTime: maturityHeight, + }, + ArtifactSignatures: []string{"0x090a"}, + Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, + ScriptTemplate: templateJSON, + Signing: covenantsigner.SigningRequirements{ + SignerRequired: true, + CustodianRequired: true, + }, + } + + result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ + RouteRequestID: "ors_qc_ready", + Stage: covenantsigner.StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + if result.Status != covenantsigner.StepStatusReady { + t.Fatalf("expected READY, got %s", result.Status) + } + if result.Handoff == nil { + t.Fatal("expected handoff payload") + } + if result.TransactionHex != "" || result.PSBTHash != "" { + t.Fatalf("expected handoff-only result, got %#v", result) + } + + handoffKind, ok := result.Handoff["kind"].(string) + if !ok || handoffKind != qcV1SignerHandoffKind { + t.Fatalf("unexpected handoff kind: %#v", result.Handoff["kind"]) + } + if signerRequestID, ok := result.Handoff["signerRequestId"].(string); !ok || signerRequestID != result.RequestID { + t.Fatalf("unexpected signerRequestId: %#v", result.Handoff["signerRequestId"]) + } + if requiresDummy, ok := result.Handoff["requiresDummy"].(bool); !ok || !requiresDummy { + t.Fatalf("unexpected requiresDummy: %#v", result.Handoff["requiresDummy"]) + } + if sighashType, ok := result.Handoff["sighashType"].(uint32); !ok || sighashType != uint32(txscript.SigHashAll) { + t.Fatalf("unexpected sighashType: %#v", result.Handoff["sighashType"]) + } + + selectorWitnessItems, ok := result.Handoff["selectorWitnessItems"].([]string) + if !ok { + t.Fatalf("unexpected selector witness items type: %#v", result.Handoff["selectorWitnessItems"]) + } + if len(selectorWitnessItems) != 2 || selectorWitnessItems[0] != "0x01" || selectorWitnessItems[1] != "0x" { + t.Fatalf("unexpected selector witness items: %#v", selectorWitnessItems) + } + + unsignedTransactionHex, ok := result.Handoff["unsignedTransactionHex"].(string) + if !ok || unsignedTransactionHex == "" { + t.Fatalf("unexpected unsignedTransactionHex: %#v", result.Handoff["unsignedTransactionHex"]) + } + unsignedTransactionBytes, err := hex.DecodeString(strings.TrimPrefix(unsignedTransactionHex, "0x")) + if err != nil { + t.Fatal(err) + } + unsignedTransaction := &bitcoin.Transaction{} + if err := unsignedTransaction.Deserialize(unsignedTransactionBytes); err != nil { + t.Fatal(err) + } + + if unsignedTransaction.Locktime != uint32(maturityHeight) { + t.Fatalf("unexpected locktime: %d", unsignedTransaction.Locktime) + } + if len(unsignedTransaction.Inputs) != 1 { + t.Fatalf("unexpected input count: %d", len(unsignedTransaction.Inputs)) + } + if unsignedTransaction.Inputs[0].Sequence != 0xfffffffd { + t.Fatalf("unexpected input sequence: %x", unsignedTransaction.Inputs[0].Sequence) + } + if len(unsignedTransaction.Outputs) != 2 { + t.Fatalf("unexpected output count: %d", len(unsignedTransaction.Outputs)) + } + if unsignedTransaction.Outputs[0].Value != int64(destinationValueSats) { + t.Fatalf("unexpected destination value: %d", unsignedTransaction.Outputs[0].Value) + } + if !bytes.Equal(unsignedTransaction.Outputs[0].PublicKeyScript, destinationScript) { + t.Fatal("unexpected destination output script") + } + + expectedAnchorScript, err := canonicalAnchorScriptPubKey() + if err != nil { + t.Fatal(err) + } + if unsignedTransaction.Outputs[1].Value != int64(anchorValueSats) { + t.Fatalf("unexpected anchor value: %d", unsignedTransaction.Outputs[1].Value) + } + if !bytes.Equal(unsignedTransaction.Outputs[1].PublicKeyScript, expectedAnchorScript) { + t.Fatal("unexpected anchor output script") + } + + witnessScriptHex, ok := result.Handoff["witnessScript"].(string) + if !ok || witnessScriptHex == "" { + t.Fatalf("unexpected witnessScript: %#v", result.Handoff["witnessScript"]) + } + if witnessScriptHex != "0x"+hex.EncodeToString(witnessScript) { + t.Fatalf("unexpected witness script hex: %s", witnessScriptHex) + } + + signatureHex, ok := result.Handoff["signerSignature"].(string) + if !ok || signatureHex == "" { + t.Fatalf("unexpected signerSignature: %#v", result.Handoff["signerSignature"]) + } + signatureBytes, err := hex.DecodeString(strings.TrimPrefix(signatureHex, "0x")) + if err != nil { + t.Fatal(err) + } + if len(signatureBytes) == 0 || signatureBytes[len(signatureBytes)-1] != byte(txscript.SigHashAll) { + t.Fatal("unexpected sighash type in handoff signature") + } + + wireTransaction := wire.NewMsgTx(wire.TxVersion) + if err := wireTransaction.Deserialize(bytes.NewReader(unsignedTransaction.Serialize(bitcoin.Standard))); err != nil { + t.Fatal(err) + } + sighashBytes, err := txscript.CalcWitnessSigHash( + witnessScript, + txscript.NewTxSigHashes(wireTransaction), + txscript.SigHashAll, + wireTransaction, + 0, + int64(inputValueSats), + ) + if err != nil { + t.Fatal(err) + } + parsedSignature, err := btcec.ParseDERSignature(signatureBytes[:len(signatureBytes)-1], btcec.S256()) + if err != nil { + t.Fatal(err) + } + if !ecdsa.Verify(walletPublicKey, sighashBytes, parsedSignature.R, parsedSignature.S) { + t.Fatal("invalid qc_v1 signer handoff signature") + } + + expectedPayloadHash, err := computeQcV1SignerHandoffPayloadHash(map[string]any{ + "kind": qcV1SignerHandoffKind, + "unsignedTransactionHex": unsignedTransactionHex, + "witnessScript": witnessScriptHex, + "signerSignature": signatureHex, + "selectorWitnessItems": selectorWitnessItems, + "requiresDummy": true, + "sighashType": uint32(txscript.SigHashAll), + "destinationCommitmentHash": request.DestinationCommitmentHash, + }) + if err != nil { + t.Fatal(err) + } + + if payloadHash, ok := result.Handoff["payloadHash"].(string); !ok || payloadHash != expectedPayloadHash { + t.Fatalf("unexpected payloadHash: %#v", result.Handoff["payloadHash"]) + } + if bundleID, ok := result.Handoff["bundleId"].(string); !ok || bundleID != expectedPayloadHash { + t.Fatalf("unexpected bundleId: %#v", result.Handoff["bundleId"]) + } + if destinationCommitmentHash, ok := result.Handoff["destinationCommitmentHash"].(string); !ok || destinationCommitmentHash != request.DestinationCommitmentHash { + t.Fatalf("unexpected destinationCommitmentHash: %#v", result.Handoff["destinationCommitmentHash"]) + } +} + func TestCovenantSignerEngine_SubmitSelfV1RejectsZeroMaturityHeight(t *testing.T) { node, _, walletPublicKey := setupCovenantSignerTestNode(t) @@ -402,8 +678,8 @@ func TestCovenantSignerEngine_SubmitSelfV1RejectsZeroMaturityHeight(t *testing.T } } -func TestValidateSelfV1OutputValues_RejectsValuesExceedingInt64(t *testing.T) { - err := validateSelfV1OutputValues(covenantsigner.RouteSubmitRequest{ +func TestValidateMigrationOutputValues_RejectsValuesExceedingInt64(t *testing.T) { + err := validateMigrationOutputValues(covenantsigner.RouteSubmitRequest{ MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ DestinationValueSats: uint64(math.MaxInt64) + 1, AnchorValueSats: 330, From 0a8ab4da1f415626e04dd03bfb1328b7b64531f9 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 11:20:48 -0500 Subject: [PATCH 011/143] test(tbtc): tighten qc_v1 handoff coverage --- pkg/tbtc/covenant_signer.go | 5 + pkg/tbtc/covenant_signer_test.go | 241 +++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index d6109894a5..6d6d33f386 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -456,6 +456,8 @@ func (cse *covenantSignerEngine) resolveQcV1ActiveUtxo( } if request.ActiveOutpoint.ScriptHash != "" { + // The optional scriptHash convention follows the tBTC-side request + // contract: sha256(scriptPubKey) for the active covenant output. scriptHash := sha256.Sum256(expectedScriptPubKey) expectedScriptHash := "0x" + hex.EncodeToString(scriptHash[:]) if strings.ToLower(request.ActiveOutpoint.ScriptHash) != expectedScriptHash { @@ -717,6 +719,9 @@ func buildWitnessSignatureBytes(signature *tecdsa.Signature) ([]byte, error) { } func computeQcV1SignerHandoffPayloadHash(payload map[string]any) (string, error) { + // The handoff bundle ID is content-addressed using Go's stable JSON map-key + // ordering. Future non-Go custodian consumers that want to recompute this + // hash must preserve the same canonical field set and serialization rules. rawPayload, err := json.Marshal(payload) if err != nil { return "", err diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index 1b8446a51d..cb077d4ff4 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -576,6 +576,247 @@ func TestCovenantSignerEngine_SubmitQcV1HandoffReady(t *testing.T) { } } +func TestCovenantSignerEngine_SubmitQcV1RejectsInvalidBeta(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + + service, err := covenantsigner.NewService( + newCovenantSignerMemoryHandle(), + newCovenantSignerEngine(node), + ) + if err != nil { + t.Fatal(err) + } + + depositorPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x42}, 32)) + depositorPublicKey := depositorPrivateKey.PubKey().SerializeCompressed() + custodianPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x24}, 32)) + custodianPublicKey := custodianPrivateKey.PubKey().SerializeCompressed() + signerPublicKey := (*btcec.PublicKey)(walletPublicKey).SerializeCompressed() + + template := &covenantsigner.QcV1Template{ + Template: covenantsigner.TemplateQcV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPublicKey), + CustodianPublicKey: "0x" + hex.EncodeToString(custodianPublicKey), + SignerPublicKey: "0x" + hex.EncodeToString(signerPublicKey), + Beta: 500, + Delta2: 4320, + } + templateJSON, err := json.Marshal(template) + if err != nil { + t.Fatal(err) + } + + destinationScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{ + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + }) + if err != nil { + t.Fatal(err) + } + + revealer := "0x4444444444444444444444444444444444444444" + reserve := "0x1111111111111111111111111111111111111111" + vault := "0x3333333333333333333333333333333333333333" + request := covenantsigner.RouteSubmitRequest{ + FacadeRequestID: "rf_qc_bad_beta", + IdempotencyKey: "idem_qc_bad_beta", + Route: covenantsigner.TemplateQcV1, + Strategy: "0x1234", + Reserve: reserve, + Epoch: 21, + MaturityHeight: 500, + ActiveOutpoint: covenantsigner.CovenantOutpoint{ + TxID: "0x" + strings.Repeat("11", 32), + }, + MigrationDestination: &covenantsigner.MigrationDestinationReservation{ + ReservationID: "cmdr_qc_bad_beta", + Reserve: reserve, + Epoch: 21, + Route: covenantsigner.ReservationRouteMigration, + Revealer: revealer, + Vault: vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusReserved, + DepositScript: "0x" + hex.EncodeToString(destinationScript), + }, + MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ + InputValueSats: 2_000_000, + DestinationValueSats: 1_997_500, + AnchorValueSats: 330, + FeeSats: 2_170, + InputSequence: 0xfffffffd, + LockTime: 500, + }, + ArtifactSignatures: []string{"0x090a"}, + Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, + ScriptTemplate: templateJSON, + Signing: covenantsigner.SigningRequirements{ + SignerRequired: true, + CustodianRequired: true, + }, + } + request.MigrationDestination.DepositScriptHash = testDepositScriptHash(t, destinationScript) + request.MigrationDestination.MigrationExtraData = testMigrationExtraData(revealer) + request.MigrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, request.MigrationDestination) + request.DestinationCommitmentHash = request.MigrationDestination.DestinationCommitmentHash + + result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ + RouteRequestID: "ors_qc_bad_beta", + Stage: covenantsigner.StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + if result.Status != covenantsigner.StepStatusFailed { + t.Fatalf("expected FAILED, got %s", result.Status) + } + if result.Reason != covenantsigner.ReasonInvalidInput { + t.Fatalf("unexpected failure reason: %s", result.Reason) + } + if !strings.Contains(result.Detail, "qc_v1 beta must be below maturity height") { + t.Fatalf("unexpected failure detail: %s", result.Detail) + } +} + +func TestCovenantSignerEngine_SubmitQcV1RejectsScriptHashMismatch(t *testing.T) { + node, bitcoinChain, walletPublicKey := setupCovenantSignerTestNode(t) + + service, err := covenantsigner.NewService( + newCovenantSignerMemoryHandle(), + newCovenantSignerEngine(node), + ) + if err != nil { + t.Fatal(err) + } + + depositorPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x42}, 32)) + depositorPublicKey := depositorPrivateKey.PubKey().SerializeCompressed() + custodianPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x24}, 32)) + custodianPublicKey := custodianPrivateKey.PubKey().SerializeCompressed() + signerPublicKey := (*btcec.PublicKey)(walletPublicKey).SerializeCompressed() + + template := &covenantsigner.QcV1Template{ + Template: covenantsigner.TemplateQcV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPublicKey), + CustodianPublicKey: "0x" + hex.EncodeToString(custodianPublicKey), + SignerPublicKey: "0x" + hex.EncodeToString(signerPublicKey), + Beta: 144, + Delta2: 4320, + } + templateJSON, err := json.Marshal(template) + if err != nil { + t.Fatal(err) + } + + maturityHeight := uint64(912345) + witnessScript, err := buildQcV1WitnessScript(template, maturityHeight) + if err != nil { + t.Fatal(err) + } + witnessScriptHash := bitcoin.WitnessScriptHash(witnessScript) + activeScriptPubKey, err := bitcoin.PayToWitnessScriptHash(witnessScriptHash) + if err != nil { + t.Fatal(err) + } + + destinationScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{ + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, + }) + if err != nil { + t.Fatal(err) + } + + prevTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 2_000_000, + PublicKeyScript: activeScriptPubKey, + }, + }, + Locktime: 0, + } + bitcoinChain.transactions = append(bitcoinChain.transactions, prevTransaction) + + revealer := "0x4444444444444444444444444444444444444444" + reserve := "0x1111111111111111111111111111111111111111" + vault := "0x3333333333333333333333333333333333333333" + migrationDestination := &covenantsigner.MigrationDestinationReservation{ + ReservationID: "cmdr_qc_bad_script_hash", + Reserve: reserve, + Epoch: 21, + Route: covenantsigner.ReservationRouteMigration, + Revealer: revealer, + Vault: vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusCommittedToEpoch, + DepositScript: "0x" + hex.EncodeToString(destinationScript), + } + migrationDestination.DepositScriptHash = testDepositScriptHash(t, destinationScript) + migrationDestination.MigrationExtraData = testMigrationExtraData(revealer) + migrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, migrationDestination) + + request := covenantsigner.RouteSubmitRequest{ + FacadeRequestID: "rf_qc_bad_script_hash", + IdempotencyKey: "idem_qc_bad_script_hash", + Route: covenantsigner.TemplateQcV1, + Strategy: "0x1234", + Reserve: reserve, + Epoch: 21, + MaturityHeight: maturityHeight, + ActiveOutpoint: covenantsigner.CovenantOutpoint{TxID: "0x" + prevTransaction.Hash().Hex(bitcoin.ReversedByteOrder), Vout: 0, ScriptHash: "0x" + strings.Repeat("aa", 32)}, + DestinationCommitmentHash: migrationDestination.DestinationCommitmentHash, + MigrationDestination: migrationDestination, + MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ + InputValueSats: 2_000_000, + DestinationValueSats: 1_997_500, + AnchorValueSats: 330, + FeeSats: 2_170, + InputSequence: 0xfffffffd, + LockTime: maturityHeight, + }, + ArtifactSignatures: []string{"0x090a"}, + Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, + ScriptTemplate: templateJSON, + Signing: covenantsigner.SigningRequirements{ + SignerRequired: true, + CustodianRequired: true, + }, + } + + result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ + RouteRequestID: "ors_qc_bad_script_hash", + Stage: covenantsigner.StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + if result.Status != covenantsigner.StepStatusFailed { + t.Fatalf("expected FAILED, got %s", result.Status) + } + if result.Reason != covenantsigner.ReasonInvalidInput { + t.Fatalf("unexpected failure reason: %s", result.Reason) + } + if !strings.Contains(result.Detail, "active outpoint script hash does not match qc_v1 template") { + t.Fatalf("unexpected failure detail: %s", result.Detail) + } +} + func TestCovenantSignerEngine_SubmitSelfV1RejectsZeroMaturityHeight(t *testing.T) { node, _, walletPublicKey := setupCovenantSignerTestNode(t) From bde9dab3bd6ec49ec3670f763d1d1a455a8d867b Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 11:37:40 -0500 Subject: [PATCH 012/143] fix(tbtc): gofmt covenant signer --- pkg/tbtc/covenant_signer.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index 6d6d33f386..d2dce6057f 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -662,13 +662,13 @@ func (cse *covenantSignerEngine) buildQcV1SignerHandoff( selectorWitnessItems := []string{"0x01", "0x"} payloadHash, err := computeQcV1SignerHandoffPayloadHash(map[string]any{ - "kind": qcV1SignerHandoffKind, - "unsignedTransactionHex": unsignedTransactionHex, - "witnessScript": witnessScriptHex, - "signerSignature": signatureHex, - "selectorWitnessItems": selectorWitnessItems, - "requiresDummy": true, - "sighashType": uint32(txscript.SigHashAll), + "kind": qcV1SignerHandoffKind, + "unsignedTransactionHex": unsignedTransactionHex, + "witnessScript": witnessScriptHex, + "signerSignature": signatureHex, + "selectorWitnessItems": selectorWitnessItems, + "requiresDummy": true, + "sighashType": uint32(txscript.SigHashAll), "destinationCommitmentHash": request.DestinationCommitmentHash, }) if err != nil { From 8404d481f5c79337e9c77cd37d3154e574624e59 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 17:05:45 -0500 Subject: [PATCH 013/143] Harden covenant signer service exposure --- cmd/flags.go | 12 ++ cmd/flags_test.go | 15 ++ pkg/covenantsigner/config.go | 8 + pkg/covenantsigner/covenantsigner_test.go | 175 +++++++++++++++++++++- pkg/covenantsigner/server.go | 72 ++++++++- pkg/covenantsigner/service.go | 13 +- 6 files changed, 283 insertions(+), 12 deletions(-) diff --git a/cmd/flags.go b/cmd/flags.go index fc581e7bab..acc1eab686 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -320,6 +320,18 @@ func initCovenantSignerFlags(cmd *cobra.Command, cfg *config.Config) { covenantsigner.Config{}.Port, "Covenant signer provider HTTP server listening port. Zero disables the service.", ) + cmd.Flags().StringVar( + &cfg.CovenantSigner.ListenAddress, + "covenantSigner.listenAddress", + covenantsigner.DefaultListenAddress, + "Covenant signer provider HTTP listen address. Defaults to loopback-only.", + ) + cmd.Flags().StringVar( + &cfg.CovenantSigner.AuthToken, + "covenantSigner.authToken", + covenantsigner.Config{}.AuthToken, + "Covenant signer provider static Bearer auth token. Required for non-loopback binds; prefer config file or env var over CLI in production.", + ) } // Initialize flags for Maintainer configuration. diff --git a/cmd/flags_test.go b/cmd/flags_test.go index 4bc23e68a5..593b257ed4 100644 --- a/cmd/flags_test.go +++ b/cmd/flags_test.go @@ -22,6 +22,7 @@ import ( ethereumEcdsa "github.com/keep-network/keep-core/pkg/chain/ethereum/ecdsa/gen" ethereumTbtc "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen" ethereumThreshold "github.com/keep-network/keep-core/pkg/chain/ethereum/threshold/gen" + "github.com/keep-network/keep-core/pkg/covenantsigner" ) var cmdFlagsTests = map[string]struct { @@ -197,6 +198,20 @@ var cmdFlagsTests = map[string]struct { expectedValueFromFlag: 9711, defaultValue: 0, }, + "covenantSigner.listenAddress": { + readValueFunc: func(c *config.Config) interface{} { return c.CovenantSigner.ListenAddress }, + flagName: "--covenantSigner.listenAddress", + flagValue: "0.0.0.0", + expectedValueFromFlag: "0.0.0.0", + defaultValue: covenantsigner.DefaultListenAddress, + }, + "covenantSigner.authToken": { + readValueFunc: func(c *config.Config) interface{} { return c.CovenantSigner.AuthToken }, + flagName: "--covenantSigner.authToken", + flagValue: "secret-token", + expectedValueFromFlag: "secret-token", + defaultValue: "", + }, "tbtc.preParamsPoolSize": { readValueFunc: func(c *config.Config) interface{} { return c.Tbtc.PreParamsPoolSize }, flagName: "--tbtc.preParamsPoolSize", diff --git a/pkg/covenantsigner/config.go b/pkg/covenantsigner/config.go index 75fb5c118e..b7b3af4f11 100644 --- a/pkg/covenantsigner/config.go +++ b/pkg/covenantsigner/config.go @@ -1,7 +1,15 @@ package covenantsigner +const DefaultListenAddress = "127.0.0.1" + // Config configures the covenant signer HTTP service. type Config struct { // Port enables the covenant signer provider HTTP surface when non-zero. Port int + // ListenAddress controls which interface the covenant signer HTTP service + // binds to. Empty defaults to loopback-only. + ListenAddress string + // AuthToken enables static Bearer authentication for signer endpoints. + // Non-loopback binds must set this. + AuthToken string } diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 756faaeb0c..d0e1014971 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -210,6 +210,87 @@ func TestServiceSubmitDeduplicatesByRouteRequestID(t *testing.T) { } } +func TestServiceSubmitReturnsExistingJobWhileInitialEngineCallIsInFlight(t *testing.T) { + handle := newMemoryHandle() + engineStarted := make(chan struct{}) + releaseEngine := make(chan struct{}) + + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + select { + case <-engineStarted: + default: + close(engineStarted) + } + + <-releaseEngine + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + input := SignerSubmitInput{ + RouteRequestID: "ors_inflight", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + } + + firstResultChan := make(chan StepResult, 1) + firstErrChan := make(chan error, 1) + go func() { + result, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + firstErrChan <- err + return + } + + firstResultChan <- result + }() + + <-engineStarted + + secondResultChan := make(chan StepResult, 1) + secondErrChan := make(chan error, 1) + go func() { + result, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + secondErrChan <- err + return + } + + secondResultChan <- result + }() + + var secondResult StepResult + select { + case err := <-secondErrChan: + t.Fatal(err) + case secondResult = <-secondResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected deduplicated submit to return while initial engine call is in flight") + } + + close(releaseEngine) + + var firstResult StepResult + select { + case err := <-firstErrChan: + t.Fatal(err) + case firstResult = <-firstResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected initial submit to finish after engine release") + } + + if firstResult.RequestID == "" { + t.Fatal("expected durable request id on initial submit") + } + if firstResult.RequestID != secondResult.RequestID { + t.Fatalf("expected in-flight dedupe to reuse request id, got %s vs %s", firstResult.RequestID, secondResult.RequestID) + } +} + func TestServicePollCanTransitionToReady(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ @@ -627,7 +708,7 @@ func TestServerHandlesSubmitAndPathPoll(t *testing.T) { t.Fatal(err) } - server := httptest.NewServer(newHandler(service)) + server := httptest.NewServer(newHandler(service, "")) defer server.Close() submitPayload := mustJSON(t, SignerSubmitInput{ @@ -681,7 +762,7 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { t.Fatal(err) } - server := httptest.NewServer(newHandler(service)) + server := httptest.NewServer(newHandler(service, "")) defer server.Close() payload := bytes.NewBufferString(`{ @@ -748,15 +829,101 @@ func TestInitializeRejectsInvalidOrUnavailablePort(t *testing.T) { if _, enabled, err := Initialize(ctx, Config{Port: -1}, handle, nil); err == nil || enabled { t.Fatalf("expected invalid negative port to fail, got enabled=%v err=%v", enabled, err) } + if _, enabled, err := Initialize( + ctx, + Config{Port: 9711, ListenAddress: "0.0.0.0"}, + handle, + nil, + ); err == nil || enabled { + t.Fatalf("expected non-loopback bind without auth token to fail, got enabled=%v err=%v", enabled, err) + } - listener, err := net.Listen("tcp", ":0") + listener, err := net.Listen("tcp", net.JoinHostPort(DefaultListenAddress, "0")) if err != nil { t.Fatal(err) } defer listener.Close() port := listener.Addr().(*net.TCPAddr).Port - if _, enabled, err := Initialize(ctx, Config{Port: port}, handle, nil); err == nil || enabled { + if _, enabled, err := Initialize( + ctx, + Config{Port: port, ListenAddress: DefaultListenAddress}, + handle, + nil, + ); err == nil || enabled { t.Fatalf("expected occupied port to fail, got enabled=%v err=%v", enabled, err) } } + +func TestServerRequiresBearerTokenWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "test-token")) + defer server.Close() + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http_auth", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + response, err := http.Get(server.URL + "/healthz") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("unexpected healthz status: %d", response.StatusCode) + } + + request, err := http.NewRequest( + http.MethodPost, + server.URL+"/v1/self_v1/signer/requests", + bytes.NewReader(submitPayload), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + + response, err = http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusUnauthorized { + body, _ := io.ReadAll(response.Body) + t.Fatalf("expected unauthorized submit without bearer token, got %d %s", response.StatusCode, string(body)) + } + + authorizedRequest, err := http.NewRequest( + http.MethodPost, + server.URL+"/v1/self_v1/signer/requests", + bytes.NewReader(submitPayload), + ) + if err != nil { + t.Fatal(err) + } + authorizedRequest.Header.Set("Content-Type", "application/json") + authorizedRequest.Header.Set("Authorization", "Bearer test-token") + + authorizedResponse, err := http.DefaultClient.Do(authorizedRequest) + if err != nil { + t.Fatal(err) + } + defer authorizedResponse.Body.Close() + + if authorizedResponse.StatusCode != http.StatusOK { + body, _ := io.ReadAll(authorizedResponse.Body) + t.Fatalf("unexpected authorized submit status: %d %s", authorizedResponse.StatusCode, string(body)) + } +} diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index c9f707f71c..63d3d45f00 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -2,11 +2,13 @@ package covenantsigner import ( "context" + "crypto/subtle" "encoding/json" "errors" "fmt" "net" "net/http" + "strconv" "strings" "time" @@ -34,6 +36,18 @@ func Initialize( return nil, false, fmt.Errorf("invalid covenant signer port [%d]", config.Port) } + listenAddress := config.ListenAddress + if strings.TrimSpace(listenAddress) == "" { + listenAddress = DefaultListenAddress + } + + if !isLoopbackListenAddress(listenAddress) && strings.TrimSpace(config.AuthToken) == "" { + return nil, false, fmt.Errorf( + "covenant signer authToken is required for non-loopback listenAddress [%s]", + listenAddress, + ) + } + service, err := NewService(handle, engine) if err != nil { return nil, false, err @@ -42,8 +56,8 @@ func Initialize( server := &Server{ service: service, httpServer: &http.Server{ - Addr: fmt.Sprintf(":%d", config.Port), - Handler: newHandler(service), + Addr: net.JoinHostPort(listenAddress, strconv.Itoa(config.Port)), + Handler: newHandler(service, config.AuthToken), ReadHeaderTimeout: 5 * time.Second, }, } @@ -64,13 +78,18 @@ func Initialize( } }() - logger.Infof("enabled covenant signer provider endpoint on port [%v]", config.Port) + logger.Infof( + "enabled covenant signer provider endpoint on [%v] auth=[%v]", + server.httpServer.Addr, + strings.TrimSpace(config.AuthToken) != "", + ) return server, true, nil } -func newHandler(service *Service) http.Handler { +func newHandler(service *Service, authToken string) http.Handler { mux := http.NewServeMux() + protectedHandler := withBearerAuth(mux, authToken) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -85,7 +104,50 @@ func newHandler(service *Service) http.Handler { mux.HandleFunc("/v1/self_v1/signer/requests/", pollPathHandler(service, TemplateSelfV1)) mux.HandleFunc("/v1/qc_v1/signer/requests/", pollPathHandler(service, TemplateQcV1)) - return mux + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + mux.ServeHTTP(w, r) + return + } + + protectedHandler.ServeHTTP(w, r) + }) +} + +func isLoopbackListenAddress(address string) bool { + trimmedAddress := strings.TrimSpace(address) + if trimmedAddress == "" || strings.EqualFold(trimmedAddress, "localhost") { + return true + } + + ip := net.ParseIP(trimmedAddress) + return ip != nil && ip.IsLoopback() +} + +func withBearerAuth(next http.Handler, authToken string) http.Handler { + trimmedToken := strings.TrimSpace(authToken) + if trimmedToken == "" { + return next + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorizationHeader := r.Header.Get("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(authorizationHeader, prefix) { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "missing bearer token", http.StatusUnauthorized) + return + } + + presentedToken := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, prefix)) + if subtle.ConstantTimeCompare([]byte(presentedToken), []byte(trimmedToken)) != 1 { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "invalid bearer token", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) } func decodeJSON[T any](w http.ResponseWriter, r *http.Request, target *T) bool { diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 22b0dbe24e..9902c9d75b 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -100,16 +100,16 @@ func mapJobResult(job *Job) StepResult { } func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubmitInput) (StepResult, error) { - s.mutex.Lock() - defer s.mutex.Unlock() - if err := validateSubmitInput(route, input); err != nil { return StepResult{}, err } + s.mutex.Lock() if existing, ok, err := s.store.GetByRouteRequest(route, input.RouteRequestID); err != nil { + s.mutex.Unlock() return StepResult{}, err } else if ok { + s.mutex.Unlock() return mapJobResult(existing), nil } @@ -122,12 +122,14 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm requestID, err := newRequestID(requestIDPrefix) if err != nil { + s.mutex.Unlock() return StepResult{}, err } now := s.now() requestDigest, err := requestDigest(input.Request) if err != nil { + s.mutex.Unlock() return StepResult{}, err } @@ -146,8 +148,10 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm } if err := s.store.Put(job); err != nil { + s.mutex.Unlock() return StepResult{}, err } + s.mutex.Unlock() transition, err := s.engine.OnSubmit(ctx, job) if err != nil { @@ -161,6 +165,9 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm } } + s.mutex.Lock() + defer s.mutex.Unlock() + applyTransition(job, transition, s.now()) if err := s.store.Put(job); err != nil { return StepResult{}, err From f237fa2ea86e683eddd31dd165960ea879a74514 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 18:46:57 -0500 Subject: [PATCH 014/143] Verify canonical migration plan commitments --- pkg/covenantsigner/covenantsigner_test.go | 99 ++++++++++++++++++++++- pkg/covenantsigner/types.go | 2 + pkg/covenantsigner/validation.go | 62 +++++++++++++- pkg/tbtc/covenant_signer_test.go | 67 +++++++++++++++ 4 files changed, 224 insertions(+), 6 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index d0e1014971..90ab281f7d 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net" "net/http" @@ -132,6 +133,7 @@ func baseRequest(route TemplateID) RouteSubmitRequest { DestinationCommitmentHash: migrationDestination.DestinationCommitmentHash, MigrationDestination: migrationDestination, MigrationTransactionPlan: &MigrationTransactionPlan{ + PlanVersion: migrationTransactionPlanVersion, InputValueSats: 1000000, DestinationValueSats: 998000, AnchorValueSats: canonicalAnchorValueSats, @@ -152,6 +154,12 @@ func baseRequest(route TemplateID) RouteSubmitRequest { request.Signing = SigningRequirements{SignerRequired: true, CustodianRequired: true} } + request.MigrationTransactionPlan.PlanCommitmentHash, _ = + computeMigrationTransactionPlanCommitmentHash( + request, + request.MigrationTransactionPlan, + ) + return request } @@ -580,6 +588,20 @@ func TestServiceRejectsInvalidMigrationTransactionPlanVariants(t *testing.T) { }, expectErr: "request.migrationTransactionPlan is required", }, + { + name: "missing plan version", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.PlanVersion = 0 + }, + expectErr: "request.migrationTransactionPlan.planVersion must equal 1", + }, + { + name: "wrong commitment hash", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.PlanCommitmentHash = "" + }, + expectErr: "request.migrationTransactionPlan.planCommitmentHash must be a 0x-prefixed even-length hex string", + }, { name: "zero input value", mutate: func(request *RouteSubmitRequest) { @@ -629,6 +651,13 @@ func TestServiceRejectsInvalidMigrationTransactionPlanVariants(t *testing.T) { }, expectErr: "request.migrationTransactionPlan.inputValueSats must cover anchorValueSats", }, + { + name: "tampered commitment hash", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.PlanCommitmentHash = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + expectErr: "request.migrationTransactionPlan.planCommitmentHash does not match canonical migration transaction plan", + }, { name: "accounting mismatch", mutate: func(request *RouteSubmitRequest) { @@ -655,6 +684,65 @@ func TestServiceRejectsInvalidMigrationTransactionPlanVariants(t *testing.T) { } } +func TestServiceRejectsMigrationTransactionPlanBoundToDifferentDestinationCommitment(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateSelfV1) + + mutatedDestination := validMigrationDestination() + mutatedDestination.Revealer = "0x4444444444444444444444444444444444444444" + mutatedDestination.MigrationExtraData = computeMigrationExtraData(mutatedDestination.Revealer) + mutatedDestination.DestinationCommitmentHash, err = computeDestinationCommitmentHash(mutatedDestination) + if err != nil { + t.Fatal(err) + } + + request.DestinationCommitmentHash = mutatedDestination.DestinationCommitmentHash + request.MigrationDestination = mutatedDestination + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_invalid_plan_destination_binding", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains(err.Error(), "request.migrationTransactionPlan.planCommitmentHash does not match canonical migration transaction plan") { + t.Fatalf("expected plan binding error, got %v", err) + } +} + +func TestMigrationTransactionPlanCommitmentHashMatchesCanonicalVector(t *testing.T) { + request := RouteSubmitRequest{ + Reserve: "0x2000000000000000000000000000000000000002", + Epoch: 12, + ActiveOutpoint: CovenantOutpoint{TxID: "0x1111111111111111111111111111111111111111111111111111111111111111", Vout: 1}, + DestinationCommitmentHash: "0xf1b1739d99ea890ea6d419d6db28f4d5fe0871c32619a0984c1bfdbe4025f768", + } + plan := &MigrationTransactionPlan{ + PlanVersion: migrationTransactionPlanVersion, + PlanCommitmentHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + InputValueSats: 1_000_000, + DestinationValueSats: 998_000, + AnchorValueSats: canonicalAnchorValueSats, + FeeSats: 1_670, + InputSequence: canonicalCovenantInputSequence, + LockTime: 950000, + } + + actual, err := computeMigrationTransactionPlanCommitmentHash(request, plan) + if err != nil { + t.Fatal(err) + } + + expected := "0x8dcafe57b888040d644e80dfd1b8b089dfd5016205d78316549ef71d032070f2" + if actual != expected { + t.Fatalf("unexpected plan commitment hash: %s", actual) + } +} + func TestStoreReloadPreservesJobs(t *testing.T) { handle := newMemoryHandle() store, err := NewStore(handle) @@ -765,7 +853,8 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { server := httptest.NewServer(newHandler(service, "")) defer server.Close() - payload := bytes.NewBufferString(`{ + base := baseRequest(TemplateSelfV1) + payload := bytes.NewBufferString(fmt.Sprintf(`{ "routeRequestId":"ors_http_unknown", "stage":"SIGNER_COORDINATION", "request":{ @@ -777,7 +866,7 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { "epoch":12, "maturityHeight":912345, "activeOutpoint":{"txid":"0x0102","vout":1,"scriptHash":"0x0304"}, - "destinationCommitmentHash":"0x3efc50372759413e0f1900a2340fbb947648c524e5ec3cb4cf8887ea2d7df474", + "destinationCommitmentHash":"%s", "migrationDestination":{ "reservationId":"cmdr_12345678", "reserve":"0x1111111111111111111111111111111111111111", @@ -790,9 +879,11 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { "depositScript":"0x0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "depositScriptHash":"0x8532ec6785e391b2af968b5728d574e271c7f46658f5ed10845d9ad5b23ac6d3", "migrationExtraData":"0x41435f4d49475241544556312222222222222222222222222222222222222222", - "destinationCommitmentHash":"0x3efc50372759413e0f1900a2340fbb947648c524e5ec3cb4cf8887ea2d7df474" + "destinationCommitmentHash":"%s" }, "migrationTransactionPlan":{ + "planVersion":1, + "planCommitmentHash":"%s", "inputValueSats":1000000, "destinationValueSats":998000, "anchorValueSats":330, @@ -807,7 +898,7 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { "futureField":"ignored" }, "futureTopLevel":"ignored" - }`) + }`, base.DestinationCommitmentHash, base.DestinationCommitmentHash, base.MigrationTransactionPlan.PlanCommitmentHash)) response, err := http.Post(server.URL+"/v1/self_v1/signer/requests", "application/json", payload) if err != nil { diff --git a/pkg/covenantsigner/types.go b/pkg/covenantsigner/types.go index b8b5ae35a2..3e4367ba0a 100644 --- a/pkg/covenantsigner/types.go +++ b/pkg/covenantsigner/types.go @@ -102,6 +102,8 @@ type MigrationDestinationReservation struct { } type MigrationTransactionPlan struct { + PlanVersion uint32 `json:"planVersion"` + PlanCommitmentHash string `json:"planCommitmentHash"` InputValueSats uint64 `json:"inputValueSats"` DestinationValueSats uint64 `json:"destinationValueSats"` AnchorValueSats uint64 `json:"anchorValueSats"` diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 74ac25b4fb..8d3bc54849 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -10,8 +10,9 @@ import ( ) const ( - canonicalCovenantInputSequence uint32 = 0xFFFFFFFD - canonicalAnchorValueSats uint64 = 330 + canonicalCovenantInputSequence uint32 = 0xFFFFFFFD + canonicalAnchorValueSats uint64 = 330 + migrationTransactionPlanVersion uint32 = 1 ) type inputError struct { @@ -93,6 +94,23 @@ type destinationCommitmentPayload struct { MigrationExtraData string `json:"migrationExtraData"` } +type migrationPlanCommitmentPayload struct { + // Field order is hash-significant and must stay aligned with the TypeScript + // migration transaction-plan commitment payload. + PlanVersion uint32 `json:"planVersion"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + ActiveOutpointTxID string `json:"activeOutpointTxid"` + ActiveOutpointVout uint32 `json:"activeOutpointVout"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + InputValueSats uint64 `json:"inputValueSats"` + DestinationValueSats uint64 `json:"destinationValueSats"` + AnchorValueSats uint64 `json:"anchorValueSats"` + FeeSats uint64 `json:"feeSats"` + InputSequence uint32 `json:"inputSequence"` + LockTime uint64 `json:"lockTime"` +} + func computeDestinationCommitmentHash( reservation *MigrationDestinationReservation, ) (string, error) { @@ -114,6 +132,32 @@ func computeDestinationCommitmentHash( return "0x" + hex.EncodeToString(sum[:]), nil } +func computeMigrationTransactionPlanCommitmentHash( + request RouteSubmitRequest, + plan *MigrationTransactionPlan, +) (string, error) { + payload, err := json.Marshal(migrationPlanCommitmentPayload{ + PlanVersion: plan.PlanVersion, + Reserve: normalizeLowerHex(request.Reserve), + Epoch: request.Epoch, + ActiveOutpointTxID: normalizeLowerHex(request.ActiveOutpoint.TxID), + ActiveOutpointVout: request.ActiveOutpoint.Vout, + DestinationCommitmentHash: normalizeLowerHex(request.DestinationCommitmentHash), + InputValueSats: plan.InputValueSats, + DestinationValueSats: plan.DestinationValueSats, + AnchorValueSats: plan.AnchorValueSats, + FeeSats: plan.FeeSats, + InputSequence: plan.InputSequence, + LockTime: plan.LockTime, + }) + if err != nil { + return "", err + } + + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]), nil +} + func validateMigrationDestination( request RouteSubmitRequest, reservation *MigrationDestinationReservation, @@ -193,6 +237,12 @@ func validateMigrationTransactionPlan( if plan == nil { return &inputError{"request.migrationTransactionPlan is required"} } + if plan.PlanVersion != migrationTransactionPlanVersion { + return &inputError{"request.migrationTransactionPlan.planVersion must equal 1"} + } + if err := validateHexString("request.migrationTransactionPlan.planCommitmentHash", plan.PlanCommitmentHash); err != nil { + return err + } if plan.InputValueSats == 0 { return &inputError{"request.migrationTransactionPlan.inputValueSats must be greater than zero"} } @@ -220,6 +270,14 @@ func validateMigrationTransactionPlan( return &inputError{"request.migrationTransactionPlan values must satisfy inputValueSats = destinationValueSats + anchorValueSats + feeSats"} } + expectedCommitmentHash, err := computeMigrationTransactionPlanCommitmentHash(request, plan) + if err != nil { + return err + } + if normalizeLowerHex(plan.PlanCommitmentHash) != expectedCommitmentHash { + return &inputError{"request.migrationTransactionPlan.planCommitmentHash does not match canonical migration transaction plan"} + } + return nil } diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index cb077d4ff4..b1e627f32d 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -195,6 +195,7 @@ func TestCovenantSignerEngine_SubmitSelfV1Ready(t *testing.T) { CustodianRequired: false, }, } + applyTestMigrationTransactionPlanCommitment(t, &request) result, err := service.Submit(context.Background(), covenantsigner.TemplateSelfV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_self_ready", @@ -423,6 +424,7 @@ func TestCovenantSignerEngine_SubmitQcV1HandoffReady(t *testing.T) { CustodianRequired: true, }, } + applyTestMigrationTransactionPlanCommitment(t, &request) result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_qc_ready", @@ -661,6 +663,7 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsInvalidBeta(t *testing.T) { request.MigrationDestination.MigrationExtraData = testMigrationExtraData(revealer) request.MigrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, request.MigrationDestination) request.DestinationCommitmentHash = request.MigrationDestination.DestinationCommitmentHash + applyTestMigrationTransactionPlanCommitment(t, &request) result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_qc_bad_beta", @@ -796,6 +799,7 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsScriptHashMismatch(t *testing.T) CustodianRequired: true, }, } + applyTestMigrationTransactionPlanCommitment(t, &request) result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_qc_bad_script_hash", @@ -898,6 +902,7 @@ func TestCovenantSignerEngine_SubmitSelfV1RejectsZeroMaturityHeight(t *testing.T request.MigrationDestination.MigrationExtraData = testMigrationExtraData(revealer) request.MigrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, request.MigrationDestination) request.DestinationCommitmentHash = request.MigrationDestination.DestinationCommitmentHash + applyTestMigrationTransactionPlanCommitment(t, &request) result, err := service.Submit(context.Background(), covenantsigner.TemplateSelfV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_self_zero", @@ -1068,3 +1073,65 @@ func testDestinationCommitmentHash( sum := sha256.Sum256(payload) return "0x" + hex.EncodeToString(sum[:]) } + +type testMigrationTransactionPlanCommitmentPayload struct { + PlanVersion uint32 `json:"planVersion"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + ActiveOutpointTxID string `json:"activeOutpointTxid"` + ActiveOutpointVout uint32 `json:"activeOutpointVout"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + InputValueSats uint64 `json:"inputValueSats"` + DestinationValueSats uint64 `json:"destinationValueSats"` + AnchorValueSats uint64 `json:"anchorValueSats"` + FeeSats uint64 `json:"feeSats"` + InputSequence uint32 `json:"inputSequence"` + LockTime uint64 `json:"lockTime"` +} + +func testMigrationTransactionPlanCommitmentHash( + t *testing.T, + request covenantsigner.RouteSubmitRequest, + plan *covenantsigner.MigrationTransactionPlan, +) string { + t.Helper() + + payload, err := json.Marshal(testMigrationTransactionPlanCommitmentPayload{ + PlanVersion: plan.PlanVersion, + Reserve: strings.ToLower(request.Reserve), + Epoch: request.Epoch, + ActiveOutpointTxID: strings.ToLower(request.ActiveOutpoint.TxID), + ActiveOutpointVout: request.ActiveOutpoint.Vout, + DestinationCommitmentHash: strings.ToLower(request.DestinationCommitmentHash), + InputValueSats: plan.InputValueSats, + DestinationValueSats: plan.DestinationValueSats, + AnchorValueSats: plan.AnchorValueSats, + FeeSats: plan.FeeSats, + InputSequence: plan.InputSequence, + LockTime: plan.LockTime, + }) + if err != nil { + t.Fatal(err) + } + + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]) +} + +func applyTestMigrationTransactionPlanCommitment( + t *testing.T, + request *covenantsigner.RouteSubmitRequest, +) { + t.Helper() + + if request.MigrationTransactionPlan == nil { + return + } + + request.MigrationTransactionPlan.PlanVersion = 1 + request.MigrationTransactionPlan.PlanCommitmentHash = testMigrationTransactionPlanCommitmentHash( + t, + *request, + request.MigrationTransactionPlan, + ) +} From a237d3eef671c31cd163c111ddfe1670f0d1ee8b Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 19:15:15 -0500 Subject: [PATCH 015/143] Fix signer poll race and locktime bounds --- pkg/covenantsigner/covenantsigner_test.go | 348 ++++++++++++++++++++-- pkg/covenantsigner/service.go | 105 +++++-- pkg/covenantsigner/types.go | 2 +- pkg/covenantsigner/validation.go | 11 +- pkg/tbtc/covenant_signer.go | 4 +- pkg/tbtc/covenant_signer_test.go | 8 +- 6 files changed, 417 insertions(+), 61 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 90ab281f7d..439baf22cb 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -299,6 +299,245 @@ func TestServiceSubmitReturnsExistingJobWhileInitialEngineCallIsInFlight(t *test } } +func TestServicePollReturnsNewerPersistedStateWhenItsTransitionBecomesStale(t *testing.T) { + handle := newMemoryHandle() + submitStarted := make(chan struct{}) + releaseSubmit := make(chan struct{}) + pollStarted := make(chan struct{}) + releasePoll := make(chan struct{}) + + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + select { + case <-submitStarted: + default: + close(submitStarted) + } + + <-releaseSubmit + return &Transition{ + State: JobStateArtifactReady, + Detail: "artifact ready", + PSBTHash: "0x090a", + TransactionHex: "0x0b0c", + }, nil + }, + poll: func(*Job) (*Transition, error) { + select { + case <-pollStarted: + default: + close(pollStarted) + } + + <-releasePoll + return &Transition{State: JobStatePending, Detail: "stale pending"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + input := SignerSubmitInput{ + RouteRequestID: "ors_poll_stale_pending", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + } + + submitResultChan := make(chan StepResult, 1) + submitErrChan := make(chan error, 1) + go func() { + result, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + submitErrChan <- err + return + } + + submitResultChan <- result + }() + + <-submitStarted + + storedJob, ok, err := service.store.GetByRouteRequest(TemplateSelfV1, input.RouteRequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected submitted job to exist while submit engine is in flight") + } + + pollResultChan := make(chan StepResult, 1) + pollErrChan := make(chan error, 1) + go func() { + result, err := service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: input.RouteRequestID, + RequestID: storedJob.RequestID, + Stage: StageSignerCoordination, + Request: input.Request, + }) + if err != nil { + pollErrChan <- err + return + } + + pollResultChan <- result + }() + + <-pollStarted + close(releaseSubmit) + + var submitResult StepResult + select { + case err := <-submitErrChan: + t.Fatal(err) + case submitResult = <-submitResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected submit to finish after engine release") + } + + close(releasePoll) + + var pollResult StepResult + select { + case err := <-pollErrChan: + t.Fatal(err) + case pollResult = <-pollResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected poll to finish after release") + } + + if pollResult.Status != StepStatusReady { + t.Fatalf("expected stale poll to return latest READY state, got %#v", pollResult) + } + if pollResult.PSBTHash != submitResult.PSBTHash || pollResult.TransactionHex != submitResult.TransactionHex { + t.Fatalf("expected poll to return persisted READY payload, got submit=%#v poll=%#v", submitResult, pollResult) + } + + persistedJob, ok, err := service.store.GetByRequestID(storedJob.RequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected persisted job after stale poll") + } + if persistedJob.State != JobStateArtifactReady { + t.Fatalf("expected persisted READY state, got %s", persistedJob.State) + } +} + +func TestServicePollDoesNotOverwriteNewerPersistedStateWithJobNotFound(t *testing.T) { + handle := newMemoryHandle() + submitStarted := make(chan struct{}) + releaseSubmit := make(chan struct{}) + pollStarted := make(chan struct{}) + releasePoll := make(chan struct{}) + + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + select { + case <-submitStarted: + default: + close(submitStarted) + } + + <-releaseSubmit + return &Transition{ + State: JobStateArtifactReady, + Detail: "artifact ready", + PSBTHash: "0x0d0e", + TransactionHex: "0x0f10", + }, nil + }, + poll: func(*Job) (*Transition, error) { + select { + case <-pollStarted: + default: + close(pollStarted) + } + + <-releasePoll + return nil, errJobNotFound + }, + }) + if err != nil { + t.Fatal(err) + } + + input := SignerSubmitInput{ + RouteRequestID: "ors_poll_stale_missing", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + } + + submitResultChan := make(chan StepResult, 1) + submitErrChan := make(chan error, 1) + go func() { + result, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + submitErrChan <- err + return + } + + submitResultChan <- result + }() + + <-submitStarted + + storedJob, ok, err := service.store.GetByRouteRequest(TemplateSelfV1, input.RouteRequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected submitted job to exist while submit engine is in flight") + } + + pollResultChan := make(chan StepResult, 1) + pollErrChan := make(chan error, 1) + go func() { + result, err := service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: input.RouteRequestID, + RequestID: storedJob.RequestID, + Stage: StageSignerCoordination, + Request: input.Request, + }) + if err != nil { + pollErrChan <- err + return + } + + pollResultChan <- result + }() + + <-pollStarted + close(releaseSubmit) + + var submitResult StepResult + select { + case err := <-submitErrChan: + t.Fatal(err) + case submitResult = <-submitResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected submit to finish after engine release") + } + + close(releasePoll) + + var pollResult StepResult + select { + case err := <-pollErrChan: + t.Fatal(err) + case pollResult = <-pollResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected poll to finish after release") + } + + if pollResult.Status != StepStatusReady { + t.Fatalf("expected stale job-not-found poll to return latest READY state, got %#v", pollResult) + } + if pollResult.PSBTHash != submitResult.PSBTHash || pollResult.TransactionHex != submitResult.TransactionHex { + t.Fatalf("expected poll to return persisted READY payload, got submit=%#v poll=%#v", submitResult, pollResult) + } +} + func TestServicePollCanTransitionToReady(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ @@ -633,10 +872,17 @@ func TestServiceRejectsInvalidMigrationTransactionPlanVariants(t *testing.T) { { name: "locktime mismatch", mutate: func(request *RouteSubmitRequest) { - request.MigrationTransactionPlan.LockTime = request.MaturityHeight + 1 + request.MigrationTransactionPlan.LockTime = uint32(request.MaturityHeight + 1) }, expectErr: "request.migrationTransactionPlan.lockTime must match request.maturityHeight", }, + { + name: "maturity height exceeds uint32", + mutate: func(request *RouteSubmitRequest) { + request.MaturityHeight = 0x1_0000_0000 + }, + expectErr: "request.maturityHeight must fit in uint32", + }, { name: "insufficient input for destination", mutate: func(request *RouteSubmitRequest) { @@ -714,32 +960,86 @@ func TestServiceRejectsMigrationTransactionPlanBoundToDifferentDestinationCommit } } -func TestMigrationTransactionPlanCommitmentHashMatchesCanonicalVector(t *testing.T) { - request := RouteSubmitRequest{ - Reserve: "0x2000000000000000000000000000000000000002", - Epoch: 12, - ActiveOutpoint: CovenantOutpoint{TxID: "0x1111111111111111111111111111111111111111111111111111111111111111", Vout: 1}, - DestinationCommitmentHash: "0xf1b1739d99ea890ea6d419d6db28f4d5fe0871c32619a0984c1bfdbe4025f768", - } - plan := &MigrationTransactionPlan{ - PlanVersion: migrationTransactionPlanVersion, - PlanCommitmentHash: "0x0000000000000000000000000000000000000000000000000000000000000000", - InputValueSats: 1_000_000, - DestinationValueSats: 998_000, - AnchorValueSats: canonicalAnchorValueSats, - FeeSats: 1_670, - InputSequence: canonicalCovenantInputSequence, - LockTime: 950000, +func TestMigrationTransactionPlanCommitmentHashMatchesCanonicalVectors(t *testing.T) { + testCases := []struct { + name string + request RouteSubmitRequest + plan *MigrationTransactionPlan + expected string + }{ + { + name: "canonical cross-stack vector", + request: RouteSubmitRequest{ + Reserve: "0x2000000000000000000000000000000000000002", + Epoch: 12, + ActiveOutpoint: CovenantOutpoint{TxID: "0x1111111111111111111111111111111111111111111111111111111111111111", Vout: 1}, + DestinationCommitmentHash: "0xf1b1739d99ea890ea6d419d6db28f4d5fe0871c32619a0984c1bfdbe4025f768", + }, + plan: &MigrationTransactionPlan{ + PlanVersion: migrationTransactionPlanVersion, + PlanCommitmentHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + InputValueSats: 1_000_000, + DestinationValueSats: 998_000, + AnchorValueSats: canonicalAnchorValueSats, + FeeSats: 1_670, + InputSequence: canonicalCovenantInputSequence, + LockTime: 950000, + }, + expected: "0x8dcafe57b888040d644e80dfd1b8b089dfd5016205d78316549ef71d032070f2", + }, + { + name: "mixed-case hex inputs normalize before hashing", + request: RouteSubmitRequest{ + Reserve: "0xAbCd00000000000000000000000000000000Ef01", + Epoch: 0, + ActiveOutpoint: CovenantOutpoint{TxID: "0xAaBbCcDdAaBbCcDdAaBbCcDdAaBbCcDdAaBbCcDdAaBbCcDdAaBbCcDdAaBbCcDd", Vout: 0}, + DestinationCommitmentHash: "0xFfEeDdCcBbAa00998877665544332211FfEeDdCcBbAa00998877665544332211", + }, + plan: &MigrationTransactionPlan{ + PlanVersion: migrationTransactionPlanVersion, + PlanCommitmentHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + InputValueSats: 100_000, + DestinationValueSats: 99_300, + AnchorValueSats: canonicalAnchorValueSats, + FeeSats: 370, + InputSequence: canonicalCovenantInputSequence, + LockTime: 1, + }, + expected: "0x626ce76714e04a41a5ec06a96082cac2ebd4d8f687fdc77766ffd9c0d11dac14", + }, + { + name: "max uint32 fields and large safe integer amounts remain stable", + request: RouteSubmitRequest{ + Reserve: "0x9999999999999999999999999999999999999999", + Epoch: 4294967295, + ActiveOutpoint: CovenantOutpoint{TxID: "0x0000000000000000000000000000000000000000000000000000000000000001", Vout: 4294967295}, + DestinationCommitmentHash: "0x00000000000000000000000000000000000000000000000000000000000000aa", + }, + plan: &MigrationTransactionPlan{ + PlanVersion: migrationTransactionPlanVersion, + PlanCommitmentHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + InputValueSats: 9_007_199_254_740_000, + DestinationValueSats: 9_007_199_254_737_000, + AnchorValueSats: canonicalAnchorValueSats, + FeeSats: 2670, + InputSequence: canonicalCovenantInputSequence, + LockTime: 0xffffffff, + }, + expected: "0x42983bef3abb9680093ca0254c780c6ed4e6178405649bf1846ebb381ca89e02", + }, } - actual, err := computeMigrationTransactionPlanCommitmentHash(request, plan) - if err != nil { - t.Fatal(err) - } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + actual, err := computeMigrationTransactionPlanCommitmentHash(testCase.request, testCase.plan) + if err != nil { + t.Fatal(err) + } - expected := "0x8dcafe57b888040d644e80dfd1b8b089dfd5016205d78316549ef71d032070f2" - if actual != expected { - t.Fatalf("unexpected plan commitment hash: %s", actual) + if actual != testCase.expected { + t.Fatalf("unexpected plan commitment hash: %s", actual) + } + }) } } diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 9902c9d75b..1d8ee89fce 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "reflect" "sync" "time" @@ -99,6 +100,48 @@ func mapJobResult(job *Job) StepResult { } } +func isTerminalJobState(state JobState) bool { + return state == JobStateArtifactReady || + state == JobStateHandoffReady || + state == JobStateFailed +} + +func sameJobRevision(current *Job, snapshot *Job) bool { + return current.RequestID == snapshot.RequestID && + current.State == snapshot.State && + current.Detail == snapshot.Detail && + current.Reason == snapshot.Reason && + current.PSBTHash == snapshot.PSBTHash && + current.TransactionHex == snapshot.TransactionHex && + current.UpdatedAt == snapshot.UpdatedAt && + current.CompletedAt == snapshot.CompletedAt && + current.FailedAt == snapshot.FailedAt && + reflect.DeepEqual(current.Handoff, snapshot.Handoff) +} + +func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, error) { + job, ok, err := s.store.GetByRequestID(input.RequestID) + if err != nil { + return nil, err + } + if !ok || job.Route != route { + return nil, errJobNotFound + } + if job.RouteRequestID != input.RouteRequestID { + return nil, &inputError{"routeRequestId does not match stored job"} + } + + digest, err := requestDigest(input.Request) + if err != nil { + return nil, err + } + if digest != job.RequestDigest { + return nil, &inputError{"request does not match stored job payload"} + } + + return job, nil +} + func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubmitInput) (StepResult, error) { if err := validateSubmitInput(route, input); err != nil { return StepResult{}, err @@ -181,51 +224,59 @@ func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollIn return StepResult{}, err } - job, ok, err := s.store.GetByRequestID(input.RequestID) + s.mutex.Lock() + job, err := s.loadPollJob(route, input) if err != nil { + s.mutex.Unlock() return StepResult{}, err } - if !ok || job.Route != route { - return StepResult{}, errJobNotFound + if isTerminalJobState(job.State) { + result := mapJobResult(job) + s.mutex.Unlock() + return result, nil } - if job.RouteRequestID != input.RouteRequestID { - return StepResult{}, &inputError{"routeRequestId does not match stored job"} + s.mutex.Unlock() + + transition, pollErr := s.engine.OnPoll(ctx, job) + if pollErr != nil { + if pollErr != errJobNotFound { + return StepResult{}, pollErr + } } - digest, err := requestDigest(input.Request) + s.mutex.Lock() + defer s.mutex.Unlock() + + currentJob, err := s.loadPollJob(route, input) if err != nil { return StepResult{}, err } - if digest != job.RequestDigest { - return StepResult{}, &inputError{"request does not match stored job payload"} - } - if job.State == JobStateArtifactReady || job.State == JobStateHandoffReady || job.State == JobStateFailed { - return mapJobResult(job), nil + // Another Submit/Poll already advanced the stored job while this poll was + // in-flight. Return the newer durable state instead of overwriting it with a + // stale transition computed from an older snapshot. + if !sameJobRevision(currentJob, job) || isTerminalJobState(currentJob.State) { + return mapJobResult(currentJob), nil } - transition, err := s.engine.OnPoll(ctx, job) - if err != nil { - if err == errJobNotFound { - applyTransition(job, &Transition{ - State: JobStateFailed, - Reason: ReasonJobNotFound, - Detail: "signer job no longer exists", - }, s.now()) - if storeErr := s.store.Put(job); storeErr != nil { - return StepResult{}, storeErr - } - return mapJobResult(job), nil + if pollErr == errJobNotFound { + applyTransition(currentJob, &Transition{ + State: JobStateFailed, + Reason: ReasonJobNotFound, + Detail: "signer job no longer exists", + }, s.now()) + if storeErr := s.store.Put(currentJob); storeErr != nil { + return StepResult{}, storeErr } - return StepResult{}, err + return mapJobResult(currentJob), nil } if transition != nil { - applyTransition(job, transition, s.now()) - if err := s.store.Put(job); err != nil { + applyTransition(currentJob, transition, s.now()) + if err := s.store.Put(currentJob); err != nil { return StepResult{}, err } } - return mapJobResult(job), nil + return mapJobResult(currentJob), nil } diff --git a/pkg/covenantsigner/types.go b/pkg/covenantsigner/types.go index 3e4367ba0a..d759d588ae 100644 --- a/pkg/covenantsigner/types.go +++ b/pkg/covenantsigner/types.go @@ -109,7 +109,7 @@ type MigrationTransactionPlan struct { AnchorValueSats uint64 `json:"anchorValueSats"` FeeSats uint64 `json:"feeSats"` InputSequence uint32 `json:"inputSequence"` - LockTime uint64 `json:"lockTime"` + LockTime uint32 `json:"lockTime"` } type SigningRequirements struct { diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 8d3bc54849..9ff846e79f 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "math" "strings" ) @@ -96,7 +97,8 @@ type destinationCommitmentPayload struct { type migrationPlanCommitmentPayload struct { // Field order is hash-significant and must stay aligned with the TypeScript - // migration transaction-plan commitment payload. + // migration transaction-plan commitment payload. planCommitmentHash is + // intentionally omitted because it is the output of this computation. PlanVersion uint32 `json:"planVersion"` Reserve string `json:"reserve"` Epoch uint64 `json:"epoch"` @@ -108,7 +110,7 @@ type migrationPlanCommitmentPayload struct { AnchorValueSats uint64 `json:"anchorValueSats"` FeeSats uint64 `json:"feeSats"` InputSequence uint32 `json:"inputSequence"` - LockTime uint64 `json:"lockTime"` + LockTime uint32 `json:"lockTime"` } func computeDestinationCommitmentHash( @@ -255,7 +257,10 @@ func validateMigrationTransactionPlan( if plan.InputSequence != canonicalCovenantInputSequence { return &inputError{"request.migrationTransactionPlan.inputSequence must equal 0xFFFFFFFD"} } - if plan.LockTime != request.MaturityHeight { + if request.MaturityHeight > math.MaxUint32 { + return &inputError{"request.maturityHeight must fit in uint32"} + } + if uint64(plan.LockTime) != request.MaturityHeight { return &inputError{"request.migrationTransactionPlan.lockTime must match request.maturityHeight"} } if plan.InputValueSats < plan.DestinationValueSats { diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index d2dce6057f..0195d79ae3 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -523,7 +523,7 @@ func (cse *covenantSignerEngine) buildAndSignSelfV1Transaction( if err := builder.SetInputSequence(0, request.MigrationTransactionPlan.InputSequence); err != nil { return nil, fmt.Errorf("cannot set covenant input sequence: %v", err) } - builder.SetLocktime(uint32(request.MigrationTransactionPlan.LockTime)) + builder.SetLocktime(request.MigrationTransactionPlan.LockTime) builder.AddOutput(&bitcoin.TransactionOutput{ Value: destinationValue, PublicKeyScript: destinationScript, @@ -614,7 +614,7 @@ func (cse *covenantSignerEngine) buildQcV1SignerHandoff( if err := builder.SetInputSequence(0, request.MigrationTransactionPlan.InputSequence); err != nil { return nil, fmt.Errorf("cannot set covenant input sequence: %v", err) } - builder.SetLocktime(uint32(request.MigrationTransactionPlan.LockTime)) + builder.SetLocktime(request.MigrationTransactionPlan.LockTime) builder.AddOutput(&bitcoin.TransactionOutput{ Value: destinationValue, PublicKeyScript: destinationScript, diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index b1e627f32d..dad1a7a62e 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -185,7 +185,7 @@ func TestCovenantSignerEngine_SubmitSelfV1Ready(t *testing.T) { AnchorValueSats: anchorValueSats, FeeSats: feeSats, InputSequence: 0xfffffffd, - LockTime: maturityHeight, + LockTime: uint32(maturityHeight), }, ArtifactSignatures: []string{"0x0708"}, Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, @@ -414,7 +414,7 @@ func TestCovenantSignerEngine_SubmitQcV1HandoffReady(t *testing.T) { AnchorValueSats: anchorValueSats, FeeSats: feeSats, InputSequence: 0xfffffffd, - LockTime: maturityHeight, + LockTime: uint32(maturityHeight), }, ArtifactSignatures: []string{"0x090a"}, Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, @@ -789,7 +789,7 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsScriptHashMismatch(t *testing.T) AnchorValueSats: 330, FeeSats: 2_170, InputSequence: 0xfffffffd, - LockTime: maturityHeight, + LockTime: uint32(maturityHeight), }, ArtifactSignatures: []string{"0x090a"}, Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, @@ -1086,7 +1086,7 @@ type testMigrationTransactionPlanCommitmentPayload struct { AnchorValueSats uint64 `json:"anchorValueSats"` FeeSats uint64 `json:"feeSats"` InputSequence uint32 `json:"inputSequence"` - LockTime uint64 `json:"lockTime"` + LockTime uint32 `json:"lockTime"` } func testMigrationTransactionPlanCommitmentHash( From 243857c3dd2ad00e364206f60696040e079e0b9a Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 10 Mar 2026 13:06:06 -0500 Subject: [PATCH 016/143] Fix gosec G118 findings --- pkg/covenantsigner/server.go | 8 +++++++- pkg/generator/scheduler.go | 1 + pkg/tbtc/dkg_loop.go | 1 + pkg/tbtc/node.go | 2 ++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 63d3d45f00..17d768690f 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -69,7 +69,13 @@ func Initialize( go func() { <-ctx.Done() - _ = server.httpServer.Shutdown(context.Background()) + shutdownCtx, cancelShutdown := context.WithTimeout( + context.WithoutCancel(ctx), + 5*time.Second, + ) + defer cancelShutdown() + + _ = server.httpServer.Shutdown(shutdownCtx) }() go func() { diff --git a/pkg/generator/scheduler.go b/pkg/generator/scheduler.go index 73c9d25350..3642133be6 100644 --- a/pkg/generator/scheduler.go +++ b/pkg/generator/scheduler.go @@ -112,6 +112,7 @@ func (s *Scheduler) resume() { // This function should be executed only be the Scheduler and when the // workMutex is locked. func (s *Scheduler) startWorker(workerFn func(context.Context)) { + // #nosec G118 -- cancelFn is stored in s.stops and invoked by stop(). ctx, cancelFn := context.WithCancel(context.Background()) s.stops = append(s.stops, cancelFn) diff --git a/pkg/tbtc/dkg_loop.go b/pkg/tbtc/dkg_loop.go index 4b7955abc9..bcd02e02a9 100644 --- a/pkg/tbtc/dkg_loop.go +++ b/pkg/tbtc/dkg_loop.go @@ -199,6 +199,7 @@ func (drl *dkgRetryLoop) start( drl.memberIndex, fmt.Sprintf("%v-%v", drl.seed, drl.attemptCounter), ) + cancelAnnounceCtx() if err != nil { drl.logger.Warnf( "[member:%v] announcement for attempt [%v] "+ diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index f8f40b9f7c..8ce03ed130 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -1429,6 +1429,8 @@ func withCancelOnBlock( block uint64, waitForBlockFn waitForBlockFn, ) (context.Context, context.CancelFunc) { + // #nosec G118 -- cancelBlockCtx is returned to the caller and also invoked + // by the waiter goroutine when the target block is reached. blockCtx, cancelBlockCtx := context.WithCancel(ctx) go func() { From 475f6883e416c025cbe8366864499115c172e8d1 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 10 Mar 2026 13:46:16 -0500 Subject: [PATCH 017/143] Harden covenant signer route controls --- cmd/flags.go | 6 + cmd/flags_test.go | 7 + pkg/covenantsigner/config.go | 3 + pkg/covenantsigner/covenantsigner_test.go | 200 +++++++++++++++++++++- pkg/covenantsigner/server.go | 22 ++- pkg/covenantsigner/service.go | 21 ++- pkg/covenantsigner/validation.go | 3 + 7 files changed, 249 insertions(+), 13 deletions(-) diff --git a/cmd/flags.go b/cmd/flags.go index acc1eab686..9899b814d1 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -332,6 +332,12 @@ func initCovenantSignerFlags(cmd *cobra.Command, cfg *config.Config) { covenantsigner.Config{}.AuthToken, "Covenant signer provider static Bearer auth token. Required for non-loopback binds; prefer config file or env var over CLI in production.", ) + cmd.Flags().BoolVar( + &cfg.CovenantSigner.EnableSelfV1, + "covenantSigner.enableSelfV1", + false, + "Expose self_v1 covenant signer HTTP routes. Keep disabled for a qc_v1-first launch unless self_v1 is explicitly approved.", + ) } // Initialize flags for Maintainer configuration. diff --git a/cmd/flags_test.go b/cmd/flags_test.go index 593b257ed4..559640bac5 100644 --- a/cmd/flags_test.go +++ b/cmd/flags_test.go @@ -212,6 +212,13 @@ var cmdFlagsTests = map[string]struct { expectedValueFromFlag: "secret-token", defaultValue: "", }, + "covenantSigner.enableSelfV1": { + readValueFunc: func(c *config.Config) interface{} { return c.CovenantSigner.EnableSelfV1 }, + flagName: "--covenantSigner.enableSelfV1", + flagValue: "", + expectedValueFromFlag: true, + defaultValue: false, + }, "tbtc.preParamsPoolSize": { readValueFunc: func(c *config.Config) interface{} { return c.Tbtc.PreParamsPoolSize }, flagName: "--tbtc.preParamsPoolSize", diff --git a/pkg/covenantsigner/config.go b/pkg/covenantsigner/config.go index b7b3af4f11..07772e75f1 100644 --- a/pkg/covenantsigner/config.go +++ b/pkg/covenantsigner/config.go @@ -12,4 +12,7 @@ type Config struct { // AuthToken enables static Bearer authentication for signer endpoints. // Non-loopback binds must set this. AuthToken string + // EnableSelfV1 exposes the self_v1 signer HTTP routes. Keep this disabled + // for a qc_v1-first launch unless self_v1 has cleared its own go-live gate. + EnableSelfV1 bool } diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 439baf22cb..e344160859 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -424,6 +424,131 @@ func TestServicePollReturnsNewerPersistedStateWhenItsTransitionBecomesStale(t *t } } +func TestServiceSubmitReturnsNewerPersistedStateWhenItsTransitionBecomesStale(t *testing.T) { + handle := newMemoryHandle() + submitStarted := make(chan struct{}) + releaseSubmit := make(chan struct{}) + pollStarted := make(chan struct{}) + releasePoll := make(chan struct{}) + + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + select { + case <-submitStarted: + default: + close(submitStarted) + } + + <-releaseSubmit + return &Transition{State: JobStatePending, Detail: "stale pending"}, nil + }, + poll: func(*Job) (*Transition, error) { + select { + case <-pollStarted: + default: + close(pollStarted) + } + + <-releasePoll + return &Transition{ + State: JobStateArtifactReady, + Detail: "artifact ready", + PSBTHash: "0x090a", + TransactionHex: "0x0b0c", + }, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + input := SignerSubmitInput{ + RouteRequestID: "ors_submit_stale_pending", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + } + + submitResultChan := make(chan StepResult, 1) + submitErrChan := make(chan error, 1) + go func() { + result, err := service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + submitErrChan <- err + return + } + + submitResultChan <- result + }() + + <-submitStarted + + storedJob, ok, err := service.store.GetByRouteRequest(TemplateSelfV1, input.RouteRequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected submitted job to exist while submit engine is in flight") + } + + pollResultChan := make(chan StepResult, 1) + pollErrChan := make(chan error, 1) + go func() { + result, err := service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: input.RouteRequestID, + RequestID: storedJob.RequestID, + Stage: StageSignerCoordination, + Request: input.Request, + }) + if err != nil { + pollErrChan <- err + return + } + + pollResultChan <- result + }() + + <-pollStarted + close(releasePoll) + + var pollResult StepResult + select { + case err := <-pollErrChan: + t.Fatal(err) + case pollResult = <-pollResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected poll to finish after release") + } + + close(releaseSubmit) + + var submitResult StepResult + select { + case err := <-submitErrChan: + t.Fatal(err) + case submitResult = <-submitResultChan: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected submit to finish after release") + } + + if submitResult.Status != StepStatusReady { + t.Fatalf("expected stale submit to return latest READY state, got %#v", submitResult) + } + if submitResult.PSBTHash != pollResult.PSBTHash || submitResult.TransactionHex != pollResult.TransactionHex { + t.Fatalf("expected submit to return persisted READY payload, got submit=%#v poll=%#v", submitResult, pollResult) + } + + persistedJob, ok, err := service.store.GetByRequestID(storedJob.RequestID) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected persisted job after stale submit") + } + if persistedJob.State != JobStateArtifactReady { + t.Fatalf("expected persisted READY state, got %s", persistedJob.State) + } +} + func TestServicePollDoesNotOverwriteNewerPersistedStateWithJobNotFound(t *testing.T) { handle := newMemoryHandle() submitStarted := make(chan struct{}) @@ -855,6 +980,16 @@ func TestServiceRejectsInvalidMigrationTransactionPlanVariants(t *testing.T) { }, expectErr: "request.migrationTransactionPlan.destinationValueSats must be greater than zero", }, + { + name: "zero fee", + mutate: func(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.FeeSats = 0 + request.MigrationTransactionPlan.DestinationValueSats = + request.MigrationTransactionPlan.InputValueSats - + request.MigrationTransactionPlan.AnchorValueSats + }, + expectErr: "request.migrationTransactionPlan.feeSats must be greater than zero", + }, { name: "wrong anchor value", mutate: func(request *RouteSubmitRequest) { @@ -1096,7 +1231,7 @@ func TestServerHandlesSubmitAndPathPoll(t *testing.T) { t.Fatal(err) } - server := httptest.NewServer(newHandler(service, "")) + server := httptest.NewServer(newHandler(service, "", true)) defer server.Close() submitPayload := mustJSON(t, SignerSubmitInput{ @@ -1150,7 +1285,7 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { t.Fatal(err) } - server := httptest.NewServer(newHandler(service, "")) + server := httptest.NewServer(newHandler(service, "", true)) defer server.Close() base := baseRequest(TemplateSelfV1) @@ -1246,6 +1381,12 @@ func TestInitializeRejectsInvalidOrUnavailablePort(t *testing.T) { } } +func TestIsLoopbackListenAddressAcceptsBracketedIPv6Loopback(t *testing.T) { + if !isLoopbackListenAddress("[::1]") { + t.Fatal("expected bracketed IPv6 loopback address to be recognized") + } +} + func TestServerRequiresBearerTokenWhenConfigured(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ @@ -1257,7 +1398,7 @@ func TestServerRequiresBearerTokenWhenConfigured(t *testing.T) { t.Fatal(err) } - server := httptest.NewServer(newHandler(service, "test-token")) + server := httptest.NewServer(newHandler(service, "test-token", true)) defer server.Close() submitPayload := mustJSON(t, SignerSubmitInput{ @@ -1318,3 +1459,56 @@ func TestServerRequiresBearerTokenWhenConfigured(t *testing.T) { t.Fatalf("unexpected authorized submit status: %d %s", authorizedResponse.StatusCode, string(body)) } } + +func TestServerCanKeepSelfV1RoutesDark(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "", false)) + defer server.Close() + + response, err := http.Post( + server.URL+"/v1/self_v1/signer/requests", + "application/json", + bytes.NewReader(mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http_self_dark", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + })), + ) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(response.Body) + t.Fatalf("expected disabled self_v1 route to return 404, got %d %s", response.StatusCode, string(body)) + } + + qcResponse, err := http.Post( + server.URL+"/v1/qc_v1/signer/requests", + "application/json", + bytes.NewReader(mustJSON(t, SignerSubmitInput{ + RouteRequestID: "orq_http_qc", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + })), + ) + if err != nil { + t.Fatal(err) + } + defer qcResponse.Body.Close() + + if qcResponse.StatusCode != http.StatusOK { + body, _ := io.ReadAll(qcResponse.Body) + t.Fatalf("expected qc_v1 route to remain available, got %d %s", qcResponse.StatusCode, string(body)) + } +} diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 17d768690f..931eff908d 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -57,7 +57,7 @@ func Initialize( service: service, httpServer: &http.Server{ Addr: net.JoinHostPort(listenAddress, strconv.Itoa(config.Port)), - Handler: newHandler(service, config.AuthToken), + Handler: newHandler(service, config.AuthToken, config.EnableSelfV1), ReadHeaderTimeout: 5 * time.Second, }, } @@ -85,15 +85,16 @@ func Initialize( }() logger.Infof( - "enabled covenant signer provider endpoint on [%v] auth=[%v]", + "enabled covenant signer provider endpoint on [%v] auth=[%v] self_v1=[%v]", server.httpServer.Addr, strings.TrimSpace(config.AuthToken) != "", + config.EnableSelfV1, ) return server, true, nil } -func newHandler(service *Service, authToken string) http.Handler { +func newHandler(service *Service, authToken string, enableSelfV1 bool) http.Handler { mux := http.NewServeMux() protectedHandler := withBearerAuth(mux, authToken) @@ -103,12 +104,14 @@ func newHandler(service *Service, authToken string) http.Handler { _, _ = w.Write([]byte(`{"status":"ok"}`)) }) - mux.HandleFunc("POST /v1/self_v1/signer/requests", submitHandler(service, TemplateSelfV1)) mux.HandleFunc("POST /v1/qc_v1/signer/requests", submitHandler(service, TemplateQcV1)) - mux.HandleFunc("POST /v1/self_v1/signer/requests:poll", pollBodyHandler(service, TemplateSelfV1)) mux.HandleFunc("POST /v1/qc_v1/signer/requests:poll", pollBodyHandler(service, TemplateQcV1)) - mux.HandleFunc("/v1/self_v1/signer/requests/", pollPathHandler(service, TemplateSelfV1)) mux.HandleFunc("/v1/qc_v1/signer/requests/", pollPathHandler(service, TemplateQcV1)) + if enableSelfV1 { + mux.HandleFunc("POST /v1/self_v1/signer/requests", submitHandler(service, TemplateSelfV1)) + mux.HandleFunc("POST /v1/self_v1/signer/requests:poll", pollBodyHandler(service, TemplateSelfV1)) + mux.HandleFunc("/v1/self_v1/signer/requests/", pollPathHandler(service, TemplateSelfV1)) + } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/healthz" { @@ -126,7 +129,12 @@ func isLoopbackListenAddress(address string) bool { return true } - ip := net.ParseIP(trimmedAddress) + normalizedAddress := trimmedAddress + if strings.HasPrefix(normalizedAddress, "[") && strings.HasSuffix(normalizedAddress, "]") { + normalizedAddress = normalizedAddress[1 : len(normalizedAddress)-1] + } + + ip := net.ParseIP(normalizedAddress) return ip != nil && ip.IsLoopback() } diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 1d8ee89fce..f09b537b37 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -211,12 +211,27 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm s.mutex.Lock() defer s.mutex.Unlock() - applyTransition(job, transition, s.now()) - if err := s.store.Put(job); err != nil { + currentJob, ok, err := s.store.GetByRequestID(requestID) + if err != nil { return StepResult{}, err } + if !ok { + return StepResult{}, errJobNotFound + } - return mapJobResult(job), nil + // Another poll already advanced the stored job while submit was waiting on + // signer work. Return the newer durable state instead of overwriting it with + // a transition computed from an older snapshot. + if !sameJobRevision(currentJob, job) || isTerminalJobState(currentJob.State) { + return mapJobResult(currentJob), nil + } + + applyTransition(currentJob, transition, s.now()) + if err := s.store.Put(currentJob); err != nil { + return StepResult{}, err + } + + return mapJobResult(currentJob), nil } func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollInput) (StepResult, error) { diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 9ff846e79f..f28b603c0d 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -251,6 +251,9 @@ func validateMigrationTransactionPlan( if plan.DestinationValueSats == 0 { return &inputError{"request.migrationTransactionPlan.destinationValueSats must be greater than zero"} } + if plan.FeeSats == 0 { + return &inputError{"request.migrationTransactionPlan.feeSats must be greater than zero"} + } if plan.AnchorValueSats != canonicalAnchorValueSats { return &inputError{"request.migrationTransactionPlan.anchorValueSats must equal the canonical 330 sat anchor"} } From 3e3747b61b778b20a509a6ac8202f21c49c1f24a Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 11:37:57 -0500 Subject: [PATCH 018/143] Validate role-tagged covenant approvals --- pkg/covenantsigner/covenantsigner_test.go | 154 ++++++++++++++++++++++ pkg/covenantsigner/types.go | 27 ++++ pkg/covenantsigner/validation.go | 153 ++++++++++++++++++++- 3 files changed, 327 insertions(+), 7 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index e344160859..3f96873b90 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -163,6 +163,32 @@ func baseRequest(route TemplateID) RouteSubmitRequest { return request } +func validArtifactApprovals(request RouteSubmitRequest) *ArtifactApprovalEnvelope { + approvals := []ArtifactRoleApproval{ + {Role: ArtifactApprovalRoleDepositor, Signature: "0xd0d0"}, + {Role: ArtifactApprovalRoleSigner, Signature: "0x5050"}, + } + + if request.Route == TemplateQcV1 { + approvals = []ArtifactRoleApproval{ + {Role: ArtifactApprovalRoleDepositor, Signature: "0xd0d0"}, + {Role: ArtifactApprovalRoleCustodian, Signature: "0xc0c0"}, + {Role: ArtifactApprovalRoleSigner, Signature: "0x5050"}, + } + } + + return &ArtifactApprovalEnvelope{ + Payload: ArtifactApprovalPayload{ + ApprovalVersion: artifactApprovalVersion, + Route: request.Route, + ScriptTemplateID: request.Route, + DestinationCommitmentHash: request.DestinationCommitmentHash, + PlanCommitmentHash: request.MigrationTransactionPlan.PlanCommitmentHash, + }, + Approvals: approvals, + } +} + func validMigrationDestination() *MigrationDestinationReservation { reservation := &MigrationDestinationReservation{ ReservationID: "cmdr_12345678", @@ -1095,6 +1121,134 @@ func TestServiceRejectsMigrationTransactionPlanBoundToDifferentDestinationCommit } } +func TestServiceAcceptsArtifactApprovalsWithCanonicalLegacySignatures(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateQcV1) + request.ArtifactApprovals = validArtifactApprovals(request) + request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ + request.ArtifactApprovals.Approvals[2], + request.ArtifactApprovals.Approvals[0], + request.ArtifactApprovals.Approvals[1], + } + request.ArtifactSignatures = []string{"0xd0d0", "0xc0c0", "0x5050"} + + result, err := service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_artifact_approvals", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + if result.Status != StepStatusPending { + t.Fatalf("expected PENDING, got %#v", result) + } + + job, ok, err := service.store.GetByRouteRequest(TemplateQcV1, "orq_artifact_approvals") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected stored job") + } + if job.Request.ArtifactApprovals == nil { + t.Fatal("expected stored artifact approvals") + } +} + +func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + testCases := []struct { + name string + route TemplateID + mutate func(request *RouteSubmitRequest) + expectErr string + }{ + { + name: "missing qc custodian approval", + route: TemplateQcV1, + mutate: func(request *RouteSubmitRequest) { + request.ArtifactApprovals = validArtifactApprovals(*request) + request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ + request.ArtifactApprovals.Approvals[0], + request.ArtifactApprovals.Approvals[2], + } + request.ArtifactSignatures = []string{"0xd0d0", "0x5050"} + }, + expectErr: "request.artifactApprovals.approvals must include role C for qc_v1", + }, + { + name: "self route rejects custodian approval role", + route: TemplateSelfV1, + mutate: func(request *RouteSubmitRequest) { + request.ArtifactApprovals = validArtifactApprovals(*request) + request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ + request.ArtifactApprovals.Approvals[0], + {Role: ArtifactApprovalRoleCustodian, Signature: "0xc0c0"}, + request.ArtifactApprovals.Approvals[1], + } + request.ArtifactSignatures = []string{"0xd0d0", "0xc0c0", "0x5050"} + }, + expectErr: "request.artifactApprovals.approvals[1].role is not allowed for self_v1", + }, + { + name: "plan commitment mismatch", + route: TemplateQcV1, + mutate: func(request *RouteSubmitRequest) { + request.ArtifactApprovals = validArtifactApprovals(*request) + request.ArtifactApprovals.Payload.PlanCommitmentHash = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + request.ArtifactSignatures = []string{"0xd0d0", "0xc0c0", "0x5050"} + }, + expectErr: "request.artifactApprovals.payload.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash", + }, + { + name: "legacy signature mismatch", + route: TemplateQcV1, + mutate: func(request *RouteSubmitRequest) { + request.ArtifactApprovals = validArtifactApprovals(*request) + request.ArtifactSignatures = []string{"0x5050", "0xd0d0", "0xc0c0"} + }, + expectErr: "request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals", + }, + { + name: "legacy signatures remain required when approvals are present", + route: TemplateSelfV1, + mutate: func(request *RouteSubmitRequest) { + request.ArtifactApprovals = validArtifactApprovals(*request) + request.ArtifactSignatures = nil + }, + expectErr: "request.artifactSignatures must not be empty", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + request := baseRequest(testCase.route) + testCase.mutate(&request) + + _, err := service.Submit(context.Background(), testCase.route, SignerSubmitInput{ + RouteRequestID: "ors_invalid_artifact_approval_" + strings.ReplaceAll(testCase.name, " ", "_"), + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains(err.Error(), testCase.expectErr) { + t.Fatalf("expected %q, got %v", testCase.expectErr, err) + } + }) + } +} + func TestMigrationTransactionPlanCommitmentHashMatchesCanonicalVectors(t *testing.T) { testCases := []struct { name string diff --git a/pkg/covenantsigner/types.go b/pkg/covenantsigner/types.go index d759d588ae..3cba41c6ba 100644 --- a/pkg/covenantsigner/types.go +++ b/pkg/covenantsigner/types.go @@ -112,6 +112,32 @@ type MigrationTransactionPlan struct { LockTime uint32 `json:"lockTime"` } +type ArtifactApprovalRole string + +const ( + ArtifactApprovalRoleDepositor ArtifactApprovalRole = "D" + ArtifactApprovalRoleCustodian ArtifactApprovalRole = "C" + ArtifactApprovalRoleSigner ArtifactApprovalRole = "S" +) + +type ArtifactApprovalPayload struct { + ApprovalVersion uint32 `json:"approvalVersion"` + Route TemplateID `json:"route"` + ScriptTemplateID TemplateID `json:"scriptTemplateId"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + PlanCommitmentHash string `json:"planCommitmentHash"` +} + +type ArtifactRoleApproval struct { + Role ArtifactApprovalRole `json:"role"` + Signature string `json:"signature"` +} + +type ArtifactApprovalEnvelope struct { + Payload ArtifactApprovalPayload `json:"payload"` + Approvals []ArtifactRoleApproval `json:"approvals"` +} + type SigningRequirements struct { SignerRequired bool `json:"signerRequired"` CustodianRequired bool `json:"custodianRequired"` @@ -129,6 +155,7 @@ type RouteSubmitRequest struct { DestinationCommitmentHash string `json:"destinationCommitmentHash"` MigrationDestination *MigrationDestinationReservation `json:"migrationDestination,omitempty"` MigrationTransactionPlan *MigrationTransactionPlan `json:"migrationTransactionPlan,omitempty"` + ArtifactApprovals *ArtifactApprovalEnvelope `json:"artifactApprovals,omitempty"` ArtifactSignatures []string `json:"artifactSignatures"` Artifacts map[RecoveryPathID]ArtifactRecord `json:"artifacts"` ScriptTemplate json.RawMessage `json:"scriptTemplate"` diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index f28b603c0d..9d00b5631e 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -14,6 +14,7 @@ const ( canonicalCovenantInputSequence uint32 = 0xFFFFFFFD canonicalAnchorValueSats uint64 = 330 migrationTransactionPlanVersion uint32 = 1 + artifactApprovalVersion uint32 = 1 ) type inputError struct { @@ -289,6 +290,149 @@ func validateMigrationTransactionPlan( return nil } +func validateArtifactSignatures(signatures []string) ([]string, error) { + if len(signatures) == 0 { + return nil, &inputError{"request.artifactSignatures must not be empty"} + } + + normalizedSignatures := make([]string, len(signatures)) + for i, signature := range signatures { + if err := validateHexString( + fmt.Sprintf("request.artifactSignatures[%d]", i), + signature, + ); err != nil { + return nil, err + } + + normalizedSignatures[i] = normalizeLowerHex(signature) + } + + return normalizedSignatures, nil +} + +func requiredArtifactApprovalRoles(route TemplateID) ([]ArtifactApprovalRole, error) { + switch route { + case TemplateQcV1: + return []ArtifactApprovalRole{ + ArtifactApprovalRoleDepositor, + ArtifactApprovalRoleCustodian, + ArtifactApprovalRoleSigner, + }, nil + case TemplateSelfV1: + return []ArtifactApprovalRole{ + ArtifactApprovalRoleDepositor, + ArtifactApprovalRoleSigner, + }, nil + default: + return nil, &inputError{"unsupported request.route"} + } +} + +func validateArtifactApprovals(route TemplateID, request RouteSubmitRequest) error { + normalizedLegacySignatures, err := validateArtifactSignatures(request.ArtifactSignatures) + if err != nil { + return err + } + + if request.ArtifactApprovals == nil { + return nil + } + + if request.ArtifactApprovals.Payload.ApprovalVersion != artifactApprovalVersion { + return &inputError{"request.artifactApprovals.payload.approvalVersion must equal 1"} + } + if request.ArtifactApprovals.Payload.Route != route { + return &inputError{"request.artifactApprovals.payload.route must match request.route"} + } + if request.ArtifactApprovals.Payload.ScriptTemplateID != route { + return &inputError{"request.artifactApprovals.payload.scriptTemplateId must match request.route"} + } + if err := validateHexString( + "request.artifactApprovals.payload.destinationCommitmentHash", + request.ArtifactApprovals.Payload.DestinationCommitmentHash, + ); err != nil { + return err + } + if err := validateHexString( + "request.artifactApprovals.payload.planCommitmentHash", + request.ArtifactApprovals.Payload.PlanCommitmentHash, + ); err != nil { + return err + } + if normalizeLowerHex(request.ArtifactApprovals.Payload.DestinationCommitmentHash) != + normalizeLowerHex(request.DestinationCommitmentHash) { + return &inputError{"request.artifactApprovals.payload.destinationCommitmentHash must match request.destinationCommitmentHash"} + } + if normalizeLowerHex(request.ArtifactApprovals.Payload.PlanCommitmentHash) != + normalizeLowerHex(request.MigrationTransactionPlan.PlanCommitmentHash) { + return &inputError{"request.artifactApprovals.payload.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash"} + } + if len(request.ArtifactApprovals.Approvals) == 0 { + return &inputError{"request.artifactApprovals.approvals must not be empty"} + } + + requiredRoles, err := requiredArtifactApprovalRoles(route) + if err != nil { + return err + } + + allowedRoles := make(map[ArtifactApprovalRole]struct{}, len(requiredRoles)) + for _, role := range requiredRoles { + allowedRoles[role] = struct{}{} + } + + approvalsByRole := make(map[ArtifactApprovalRole]string, len(requiredRoles)) + for i, approval := range request.ArtifactApprovals.Approvals { + if _, ok := allowedRoles[approval.Role]; !ok { + return &inputError{fmt.Sprintf( + "request.artifactApprovals.approvals[%d].role is not allowed for %s", + i, + route, + )} + } + if _, ok := approvalsByRole[approval.Role]; ok { + return &inputError{fmt.Sprintf( + "request.artifactApprovals.approvals[%d].role duplicates role %s", + i, + approval.Role, + )} + } + if err := validateHexString( + fmt.Sprintf("request.artifactApprovals.approvals[%d].signature", i), + approval.Signature, + ); err != nil { + return err + } + + approvalsByRole[approval.Role] = normalizeLowerHex(approval.Signature) + } + + derivedLegacySignatures := make([]string, len(requiredRoles)) + for i, role := range requiredRoles { + signature, ok := approvalsByRole[role] + if !ok { + return &inputError{fmt.Sprintf( + "request.artifactApprovals.approvals must include role %s for %s", + role, + route, + )} + } + + derivedLegacySignatures[i] = signature + } + + if len(normalizedLegacySignatures) != len(derivedLegacySignatures) { + return &inputError{"request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals"} + } + for i := range derivedLegacySignatures { + if normalizedLegacySignatures[i] != derivedLegacySignatures[i] { + return &inputError{"request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals"} + } + } + + return nil +} + func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { if request.FacadeRequestID == "" { return &inputError{"request.facadeRequestId is required"} @@ -328,13 +472,8 @@ func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { if err := validateMigrationTransactionPlan(request, request.MigrationTransactionPlan); err != nil { return err } - if len(request.ArtifactSignatures) == 0 { - return &inputError{"request.artifactSignatures must not be empty"} - } - for i, signature := range request.ArtifactSignatures { - if err := validateHexString(fmt.Sprintf("request.artifactSignatures[%d]", i), signature); err != nil { - return err - } + if err := validateArtifactApprovals(route, request); err != nil { + return err } switch route { From 916d0cbb912c6073d9b97b4e127fbc1c75cbbeb0 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 12:00:58 -0500 Subject: [PATCH 019/143] Normalize covenant signer request digests --- pkg/covenantsigner/covenantsigner_test.go | 179 ++++++++++++++++++ pkg/covenantsigner/service.go | 9 +- pkg/covenantsigner/validation.go | 216 +++++++++++++++++++--- 3 files changed, 379 insertions(+), 25 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 3f96873b90..4a0e6f5b12 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -189,6 +189,102 @@ func validArtifactApprovals(request RouteSubmitRequest) *ArtifactApprovalEnvelop } } +func canonicalArtifactApprovalRequest(route TemplateID) RouteSubmitRequest { + request := baseRequest(route) + request.ArtifactApprovals = validArtifactApprovals(request) + request.ArtifactSignatures = []string{"0xd0d0", "0x5050"} + if route == TemplateQcV1 { + request.ArtifactSignatures = []string{"0xd0d0", "0xc0c0", "0x5050"} + } + + return request +} + +func upperHexBody(value string) string { + if !strings.HasPrefix(value, "0x") { + return strings.ToUpper(value) + } + + return "0x" + strings.ToUpper(strings.TrimPrefix(value, "0x")) +} + +func equivalentArtifactApprovalVariant(route TemplateID) RouteSubmitRequest { + request := canonicalArtifactApprovalRequest(route) + + request.Strategy = upperHexBody(request.Strategy) + request.Reserve = upperHexBody(request.Reserve) + request.ActiveOutpoint.TxID = upperHexBody(request.ActiveOutpoint.TxID) + request.ActiveOutpoint.ScriptHash = upperHexBody(request.ActiveOutpoint.ScriptHash) + request.DestinationCommitmentHash = upperHexBody(request.DestinationCommitmentHash) + request.MigrationDestination.Reserve = upperHexBody(request.MigrationDestination.Reserve) + request.MigrationDestination.Revealer = upperHexBody(request.MigrationDestination.Revealer) + request.MigrationDestination.Vault = upperHexBody(request.MigrationDestination.Vault) + request.MigrationDestination.DepositScript = upperHexBody(request.MigrationDestination.DepositScript) + request.MigrationDestination.DepositScriptHash = upperHexBody(request.MigrationDestination.DepositScriptHash) + request.MigrationDestination.MigrationExtraData = upperHexBody(request.MigrationDestination.MigrationExtraData) + request.MigrationDestination.DestinationCommitmentHash = upperHexBody(request.MigrationDestination.DestinationCommitmentHash) + request.MigrationTransactionPlan.PlanCommitmentHash = upperHexBody(request.MigrationTransactionPlan.PlanCommitmentHash) + for i := range request.ArtifactSignatures { + request.ArtifactSignatures[i] = upperHexBody(request.ArtifactSignatures[i]) + } + + if route == TemplateQcV1 { + request.ScriptTemplate = mustTemplate(QcV1Template{ + Template: TemplateQcV1, + DepositorPublicKey: upperHexBody("0x021111"), + CustodianPublicKey: upperHexBody("0x023333"), + SignerPublicKey: upperHexBody("0x022222"), + Beta: 144, + Delta2: 4320, + }) + request.ArtifactApprovals.Payload.DestinationCommitmentHash = upperHexBody( + request.ArtifactApprovals.Payload.DestinationCommitmentHash, + ) + request.ArtifactApprovals.Payload.PlanCommitmentHash = upperHexBody( + request.ArtifactApprovals.Payload.PlanCommitmentHash, + ) + request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ + { + Role: ArtifactApprovalRoleSigner, + Signature: upperHexBody("0x5050"), + }, + { + Role: ArtifactApprovalRoleDepositor, + Signature: upperHexBody("0xd0d0"), + }, + { + Role: ArtifactApprovalRoleCustodian, + Signature: upperHexBody("0xc0c0"), + }, + } + } else { + request.ScriptTemplate = mustTemplate(SelfV1Template{ + Template: TemplateSelfV1, + DepositorPublicKey: upperHexBody("0x021111"), + SignerPublicKey: upperHexBody("0x022222"), + Delta2: 4320, + }) + request.ArtifactApprovals.Payload.DestinationCommitmentHash = upperHexBody( + request.ArtifactApprovals.Payload.DestinationCommitmentHash, + ) + request.ArtifactApprovals.Payload.PlanCommitmentHash = upperHexBody( + request.ArtifactApprovals.Payload.PlanCommitmentHash, + ) + request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ + { + Role: ArtifactApprovalRoleSigner, + Signature: upperHexBody("0x5050"), + }, + { + Role: ArtifactApprovalRoleDepositor, + Signature: upperHexBody("0xd0d0"), + }, + } + } + + return request +} + func validMigrationDestination() *MigrationDestinationReservation { reservation := &MigrationDestinationReservation{ ReservationID: "cmdr_12345678", @@ -1249,6 +1345,89 @@ func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { } } +func TestRequestDigestNormalizesEquivalentArtifactApprovalVariants(t *testing.T) { + canonicalDigest, err := requestDigest(canonicalArtifactApprovalRequest(TemplateQcV1)) + if err != nil { + t.Fatal(err) + } + + variantDigest, err := requestDigest(equivalentArtifactApprovalVariant(TemplateQcV1)) + if err != nil { + t.Fatal(err) + } + + if canonicalDigest != variantDigest { + t.Fatalf("expected matching request digest, got %s vs %s", canonicalDigest, variantDigest) + } +} + +func TestServicePollAcceptsEquivalentArtifactApprovalRequestVariants(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + submitRequest := equivalentArtifactApprovalVariant(TemplateQcV1) + submitResult, err := service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_equivalent_digest", + Stage: StageSignerCoordination, + Request: submitRequest, + }) + if err != nil { + t.Fatal(err) + } + + pollResult, err := service.Poll(context.Background(), TemplateQcV1, SignerPollInput{ + RouteRequestID: "orq_equivalent_digest", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: canonicalArtifactApprovalRequest(TemplateQcV1), + }) + if err != nil { + t.Fatal(err) + } + + if pollResult.Status != StepStatusPending { + t.Fatalf("expected PENDING, got %#v", pollResult) + } +} + +func TestServiceStoresNormalizedArtifactApprovalRequest(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + request := equivalentArtifactApprovalVariant(TemplateQcV1) + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_normalized_store", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + job, ok, err := service.store.GetByRouteRequest(TemplateQcV1, "orq_normalized_store") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected stored job") + } + + expected := canonicalArtifactApprovalRequest(TemplateQcV1) + if !reflect.DeepEqual(job.Request, expected) { + t.Fatalf("expected normalized request %#v, got %#v", expected, job.Request) + } +} + func TestMigrationTransactionPlanCommitmentHashMatchesCanonicalVectors(t *testing.T) { testCases := []struct { name string diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index f09b537b37..3434fa06dd 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -147,6 +147,11 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm return StepResult{}, err } + normalizedRequest, err := normalizeRouteSubmitRequest(input.Request) + if err != nil { + return StepResult{}, err + } + s.mutex.Lock() if existing, ok, err := s.store.GetByRouteRequest(route, input.RouteRequestID); err != nil { s.mutex.Unlock() @@ -170,7 +175,7 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm } now := s.now() - requestDigest, err := requestDigest(input.Request) + requestDigest, err := requestDigest(normalizedRequest) if err != nil { s.mutex.Unlock() return StepResult{}, err @@ -187,7 +192,7 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm Detail: "accepted for covenant signing", CreatedAt: now.Format(time.RFC3339Nano), UpdatedAt: now.Format(time.RFC3339Nano), - Request: input.Request, + Request: normalizedRequest, } if err := s.store.Put(job); err != nil { diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 9d00b5631e..75c8fba634 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -32,7 +32,12 @@ func strictUnmarshal(data []byte, target any) error { } func requestDigest(request RouteSubmitRequest) (string, error) { - payload, err := json.Marshal(request) + normalizedRequest, err := normalizeRouteSubmitRequest(request) + if err != nil { + return "", err + } + + payload, err := json.Marshal(normalizedRequest) if err != nil { return "", err } @@ -329,51 +334,65 @@ func requiredArtifactApprovalRoles(route TemplateID) ([]ArtifactApprovalRole, er } func validateArtifactApprovals(route TemplateID, request RouteSubmitRequest) error { + _, _, err := normalizeArtifactApprovals(route, request) + return err +} + +func normalizeArtifactApprovals( + route TemplateID, + request RouteSubmitRequest, +) (*ArtifactApprovalEnvelope, []string, error) { normalizedLegacySignatures, err := validateArtifactSignatures(request.ArtifactSignatures) if err != nil { - return err + return nil, nil, err } if request.ArtifactApprovals == nil { - return nil + return nil, normalizedLegacySignatures, nil } if request.ArtifactApprovals.Payload.ApprovalVersion != artifactApprovalVersion { - return &inputError{"request.artifactApprovals.payload.approvalVersion must equal 1"} + return nil, nil, &inputError{"request.artifactApprovals.payload.approvalVersion must equal 1"} } if request.ArtifactApprovals.Payload.Route != route { - return &inputError{"request.artifactApprovals.payload.route must match request.route"} + return nil, nil, &inputError{"request.artifactApprovals.payload.route must match request.route"} } if request.ArtifactApprovals.Payload.ScriptTemplateID != route { - return &inputError{"request.artifactApprovals.payload.scriptTemplateId must match request.route"} + return nil, nil, &inputError{"request.artifactApprovals.payload.scriptTemplateId must match request.route"} } if err := validateHexString( "request.artifactApprovals.payload.destinationCommitmentHash", request.ArtifactApprovals.Payload.DestinationCommitmentHash, ); err != nil { - return err + return nil, nil, err } if err := validateHexString( "request.artifactApprovals.payload.planCommitmentHash", request.ArtifactApprovals.Payload.PlanCommitmentHash, ); err != nil { - return err + return nil, nil, err } - if normalizeLowerHex(request.ArtifactApprovals.Payload.DestinationCommitmentHash) != - normalizeLowerHex(request.DestinationCommitmentHash) { - return &inputError{"request.artifactApprovals.payload.destinationCommitmentHash must match request.destinationCommitmentHash"} + + normalizedDestinationCommitmentHash := normalizeLowerHex( + request.ArtifactApprovals.Payload.DestinationCommitmentHash, + ) + if normalizedDestinationCommitmentHash != normalizeLowerHex(request.DestinationCommitmentHash) { + return nil, nil, &inputError{"request.artifactApprovals.payload.destinationCommitmentHash must match request.destinationCommitmentHash"} } - if normalizeLowerHex(request.ArtifactApprovals.Payload.PlanCommitmentHash) != - normalizeLowerHex(request.MigrationTransactionPlan.PlanCommitmentHash) { - return &inputError{"request.artifactApprovals.payload.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash"} + + normalizedPlanCommitmentHash := normalizeLowerHex( + request.ArtifactApprovals.Payload.PlanCommitmentHash, + ) + if normalizedPlanCommitmentHash != normalizeLowerHex(request.MigrationTransactionPlan.PlanCommitmentHash) { + return nil, nil, &inputError{"request.artifactApprovals.payload.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash"} } if len(request.ArtifactApprovals.Approvals) == 0 { - return &inputError{"request.artifactApprovals.approvals must not be empty"} + return nil, nil, &inputError{"request.artifactApprovals.approvals must not be empty"} } requiredRoles, err := requiredArtifactApprovalRoles(route) if err != nil { - return err + return nil, nil, err } allowedRoles := make(map[ArtifactApprovalRole]struct{}, len(requiredRoles)) @@ -384,14 +403,14 @@ func validateArtifactApprovals(route TemplateID, request RouteSubmitRequest) err approvalsByRole := make(map[ArtifactApprovalRole]string, len(requiredRoles)) for i, approval := range request.ArtifactApprovals.Approvals { if _, ok := allowedRoles[approval.Role]; !ok { - return &inputError{fmt.Sprintf( + return nil, nil, &inputError{fmt.Sprintf( "request.artifactApprovals.approvals[%d].role is not allowed for %s", i, route, )} } if _, ok := approvalsByRole[approval.Role]; ok { - return &inputError{fmt.Sprintf( + return nil, nil, &inputError{fmt.Sprintf( "request.artifactApprovals.approvals[%d].role duplicates role %s", i, approval.Role, @@ -401,17 +420,27 @@ func validateArtifactApprovals(route TemplateID, request RouteSubmitRequest) err fmt.Sprintf("request.artifactApprovals.approvals[%d].signature", i), approval.Signature, ); err != nil { - return err + return nil, nil, err } approvalsByRole[approval.Role] = normalizeLowerHex(approval.Signature) } derivedLegacySignatures := make([]string, len(requiredRoles)) + normalizedApprovals := &ArtifactApprovalEnvelope{ + Payload: ArtifactApprovalPayload{ + ApprovalVersion: artifactApprovalVersion, + Route: route, + ScriptTemplateID: route, + DestinationCommitmentHash: normalizedDestinationCommitmentHash, + PlanCommitmentHash: normalizedPlanCommitmentHash, + }, + Approvals: make([]ArtifactRoleApproval, len(requiredRoles)), + } for i, role := range requiredRoles { signature, ok := approvalsByRole[role] if !ok { - return &inputError{fmt.Sprintf( + return nil, nil, &inputError{fmt.Sprintf( "request.artifactApprovals.approvals must include role %s for %s", role, route, @@ -419,18 +448,159 @@ func validateArtifactApprovals(route TemplateID, request RouteSubmitRequest) err } derivedLegacySignatures[i] = signature + normalizedApprovals.Approvals[i] = ArtifactRoleApproval{ + Role: role, + Signature: signature, + } } if len(normalizedLegacySignatures) != len(derivedLegacySignatures) { - return &inputError{"request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals"} + return nil, nil, &inputError{"request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals"} } for i := range derivedLegacySignatures { if normalizedLegacySignatures[i] != derivedLegacySignatures[i] { - return &inputError{"request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals"} + return nil, nil, &inputError{"request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals"} } } - return nil + return normalizedApprovals, derivedLegacySignatures, nil +} + +func normalizeArtifactRecord(record ArtifactRecord) ArtifactRecord { + normalized := ArtifactRecord{ + PSBTHash: normalizeLowerHex(record.PSBTHash), + DestinationCommitmentHash: normalizeLowerHex(record.DestinationCommitmentHash), + } + if record.TransactionHex != "" { + normalized.TransactionHex = normalizeLowerHex(record.TransactionHex) + } + if record.TransactionID != "" { + normalized.TransactionID = normalizeLowerHex(record.TransactionID) + } + + return normalized +} + +func normalizeArtifacts(artifacts map[RecoveryPathID]ArtifactRecord) map[RecoveryPathID]ArtifactRecord { + if artifacts == nil { + return nil + } + + normalized := make(map[RecoveryPathID]ArtifactRecord, len(artifacts)) + for pathID, artifact := range artifacts { + normalized[pathID] = normalizeArtifactRecord(artifact) + } + + return normalized +} + +func normalizeMigrationDestination( + destination *MigrationDestinationReservation, +) *MigrationDestinationReservation { + if destination == nil { + return nil + } + + return &MigrationDestinationReservation{ + ReservationID: destination.ReservationID, + Reserve: normalizeLowerHex(destination.Reserve), + Epoch: destination.Epoch, + Route: destination.Route, + Revealer: normalizeLowerHex(destination.Revealer), + Vault: normalizeLowerHex(destination.Vault), + Network: strings.TrimSpace(destination.Network), + Status: destination.Status, + DepositScript: normalizeLowerHex(destination.DepositScript), + DepositScriptHash: normalizeLowerHex(destination.DepositScriptHash), + MigrationExtraData: normalizeLowerHex(destination.MigrationExtraData), + DestinationCommitmentHash: normalizeLowerHex(destination.DestinationCommitmentHash), + } +} + +func normalizeMigrationTransactionPlan( + plan *MigrationTransactionPlan, +) *MigrationTransactionPlan { + if plan == nil { + return nil + } + + return &MigrationTransactionPlan{ + PlanVersion: plan.PlanVersion, + PlanCommitmentHash: normalizeLowerHex(plan.PlanCommitmentHash), + InputValueSats: plan.InputValueSats, + DestinationValueSats: plan.DestinationValueSats, + AnchorValueSats: plan.AnchorValueSats, + FeeSats: plan.FeeSats, + InputSequence: plan.InputSequence, + LockTime: plan.LockTime, + } +} + +func normalizeScriptTemplate(route TemplateID, rawTemplate json.RawMessage) (json.RawMessage, error) { + switch route { + case TemplateSelfV1: + template := &SelfV1Template{} + if err := strictUnmarshal(rawTemplate, template); err != nil { + return nil, err + } + template.DepositorPublicKey = normalizeLowerHex(template.DepositorPublicKey) + template.SignerPublicKey = normalizeLowerHex(template.SignerPublicKey) + return json.Marshal(template) + case TemplateQcV1: + template := &QcV1Template{} + if err := strictUnmarshal(rawTemplate, template); err != nil { + return nil, err + } + template.DepositorPublicKey = normalizeLowerHex(template.DepositorPublicKey) + template.CustodianPublicKey = normalizeLowerHex(template.CustodianPublicKey) + template.SignerPublicKey = normalizeLowerHex(template.SignerPublicKey) + return json.Marshal(template) + default: + return nil, &inputError{"unsupported request.route"} + } +} + +func normalizeRouteSubmitRequest(request RouteSubmitRequest) (RouteSubmitRequest, error) { + normalizedArtifactApprovals, normalizedArtifactSignatures, err := normalizeArtifactApprovals( + request.Route, + request, + ) + if err != nil { + return RouteSubmitRequest{}, err + } + + normalizedScriptTemplate, err := normalizeScriptTemplate(request.Route, request.ScriptTemplate) + if err != nil { + return RouteSubmitRequest{}, err + } + + return RouteSubmitRequest{ + FacadeRequestID: request.FacadeRequestID, + IdempotencyKey: request.IdempotencyKey, + Route: request.Route, + Strategy: normalizeLowerHex(request.Strategy), + Reserve: normalizeLowerHex(request.Reserve), + Epoch: request.Epoch, + MaturityHeight: request.MaturityHeight, + ActiveOutpoint: CovenantOutpoint{ + TxID: normalizeLowerHex(request.ActiveOutpoint.TxID), + Vout: request.ActiveOutpoint.Vout, + ScriptHash: func() string { + if request.ActiveOutpoint.ScriptHash == "" { + return "" + } + return normalizeLowerHex(request.ActiveOutpoint.ScriptHash) + }(), + }, + DestinationCommitmentHash: normalizeLowerHex(request.DestinationCommitmentHash), + MigrationDestination: normalizeMigrationDestination(request.MigrationDestination), + MigrationTransactionPlan: normalizeMigrationTransactionPlan(request.MigrationTransactionPlan), + ArtifactApprovals: normalizedArtifactApprovals, + ArtifactSignatures: normalizedArtifactSignatures, + Artifacts: normalizeArtifacts(request.Artifacts), + ScriptTemplate: normalizedScriptTemplate, + Signing: request.Signing, + }, nil } func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { From d3c589a853ea7a4538abaa7462bad38a326a5f56 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 12:59:53 -0500 Subject: [PATCH 020/143] Harden covenant signer request digests --- pkg/covenantsigner/covenantsigner_test.go | 54 +++++++++++++++++++++++ pkg/covenantsigner/service.go | 2 +- pkg/covenantsigner/validation.go | 24 +++++++++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 4a0e6f5b12..ccd9c02a6f 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -1361,6 +1361,47 @@ func TestRequestDigestNormalizesEquivalentArtifactApprovalVariants(t *testing.T) } } +func TestRequestDigestDoesNotEscapeHTMLSensitiveCharacters(t *testing.T) { + request := canonicalArtifactApprovalRequest(TemplateSelfV1) + request.FacadeRequestID = "rf_&sink" + request.IdempotencyKey = "idem_>bridge" + + normalizedRequest, err := normalizeRouteSubmitRequest(request) + if err != nil { + t.Fatal(err) + } + + payload, err := marshalCanonicalJSON(normalizedRequest) + if err != nil { + t.Fatal(err) + } + + if !bytes.Contains(payload, []byte(`"facadeRequestId":"rf_&sink"`)) { + t.Fatalf("expected raw HTML-sensitive characters in payload, got %s", payload) + } + if bytes.Contains(payload, []byte(`\u003c`)) || + bytes.Contains(payload, []byte(`\u003e`)) || + bytes.Contains(payload, []byte(`\u0026`)) { + t.Fatalf("expected unescaped HTML-sensitive characters in payload, got %s", payload) + } + + digestFromRawRequest, err := requestDigest(request) + if err != nil { + t.Fatal(err) + } + digestFromNormalizedRequest, err := requestDigestFromNormalized(normalizedRequest) + if err != nil { + t.Fatal(err) + } + if digestFromRawRequest != digestFromNormalizedRequest { + t.Fatalf( + "expected matching digests, got %s vs %s", + digestFromRawRequest, + digestFromNormalizedRequest, + ) + } +} + func TestServicePollAcceptsEquivalentArtifactApprovalRequestVariants(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ @@ -1428,6 +1469,19 @@ func TestServiceStoresNormalizedArtifactApprovalRequest(t *testing.T) { } } +func TestRequestDigestRejectsArtifactApprovalsWithoutMigrationTransactionPlan(t *testing.T) { + request := canonicalArtifactApprovalRequest(TemplateSelfV1) + request.MigrationTransactionPlan = nil + + _, err := requestDigest(request) + if err == nil || !strings.Contains( + err.Error(), + "request.migrationTransactionPlan is required when request.artifactApprovals is present", + ) { + t.Fatalf("expected missing plan error, got %v", err) + } +} + func TestMigrationTransactionPlanCommitmentHashMatchesCanonicalVectors(t *testing.T) { testCases := []struct { name string diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 3434fa06dd..f1a18cef0b 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -175,7 +175,7 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm } now := s.now() - requestDigest, err := requestDigest(normalizedRequest) + requestDigest, err := requestDigestFromNormalized(normalizedRequest) if err != nil { s.mutex.Unlock() return StepResult{}, err diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 75c8fba634..7441438022 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -31,13 +31,32 @@ func strictUnmarshal(data []byte, target any) error { return decoder.Decode(target) } +func marshalCanonicalJSON(value any) ([]byte, error) { + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetEscapeHTML(false) + + if err := encoder.Encode(value); err != nil { + return nil, err + } + + return bytes.TrimSuffix(buffer.Bytes(), []byte("\n")), nil +} + +// requestDigest accepts raw requests because Poll validates equivalence against +// whatever the caller resubmits. Submit should use requestDigestFromNormalized +// after it has already normalized the request once for storage. func requestDigest(request RouteSubmitRequest) (string, error) { normalizedRequest, err := normalizeRouteSubmitRequest(request) if err != nil { return "", err } - payload, err := json.Marshal(normalizedRequest) + return requestDigestFromNormalized(normalizedRequest) +} + +func requestDigestFromNormalized(request RouteSubmitRequest) (string, error) { + payload, err := marshalCanonicalJSON(request) if err != nil { return "", err } @@ -350,6 +369,9 @@ func normalizeArtifactApprovals( if request.ArtifactApprovals == nil { return nil, normalizedLegacySignatures, nil } + if request.MigrationTransactionPlan == nil { + return nil, nil, &inputError{"request.migrationTransactionPlan is required when request.artifactApprovals is present"} + } if request.ArtifactApprovals.Payload.ApprovalVersion != artifactApprovalVersion { return nil, nil, &inputError{"request.artifactApprovals.payload.approvalVersion must equal 1"} From 709b681faed9330d5c6403093dc07c340e5ee919 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 12:21:13 -0500 Subject: [PATCH 021/143] Add covenant signer contract vectors --- pkg/covenantsigner/covenantsigner_test.go | 212 ++++++++++++++++++ ...covenant_recovery_approval_vectors_v1.json | 165 ++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index ccd9c02a6f..7f92e06c61 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "net/http/httptest" + "os" "reflect" "strings" "testing" @@ -94,6 +95,52 @@ func mustJSON(t *testing.T, value any) []byte { return data } +type approvalContractVector struct { + CanonicalSubmitRequest json.RawMessage `json:"canonicalSubmitRequest"` + ExpectedRequestDigest string `json:"expectedRequestDigest"` +} + +type approvalContractVectorsFile struct { + Version int `json:"version"` + Scope string `json:"scope"` + Vectors map[string]approvalContractVector `json:"vectors"` +} + +func loadApprovalContractVector( + t *testing.T, + route TemplateID, +) (RouteSubmitRequest, string) { + t.Helper() + + data, err := os.ReadFile("testdata/covenant_recovery_approval_vectors_v1.json") + if err != nil { + t.Fatal(err) + } + + vectors := approvalContractVectorsFile{} + if err := strictUnmarshal(data, &vectors); err != nil { + t.Fatal(err) + } + if vectors.Version != 1 { + t.Fatalf("unexpected vector version: %d", vectors.Version) + } + if vectors.Scope != "covenant_recovery_approval_contract_v1" { + t.Fatalf("unexpected vector scope: %s", vectors.Scope) + } + + vector, ok := vectors.Vectors[string(route)] + if !ok { + t.Fatalf("missing vector for route %s", route) + } + + request := RouteSubmitRequest{} + if err := strictUnmarshal(vector.CanonicalSubmitRequest, &request); err != nil { + t.Fatal(err) + } + + return request, vector.ExpectedRequestDigest +} + func validSelfTemplate() json.RawMessage { return mustTemplate(SelfV1Template{ Template: TemplateSelfV1, @@ -200,6 +247,116 @@ func canonicalArtifactApprovalRequest(route TemplateID) RouteSubmitRequest { return request } +func cloneRouteSubmitRequest( + t *testing.T, + request RouteSubmitRequest, +) RouteSubmitRequest { + t.Helper() + + cloned := RouteSubmitRequest{} + if err := strictUnmarshal(mustJSON(t, request), &cloned); err != nil { + t.Fatal(err) + } + + return cloned +} + +func equivalentArtifactApprovalVariantFromRequest( + t *testing.T, + request RouteSubmitRequest, +) RouteSubmitRequest { + t.Helper() + + variant := cloneRouteSubmitRequest(t, request) + variant.Strategy = upperHexBody(variant.Strategy) + variant.Reserve = upperHexBody(variant.Reserve) + variant.ActiveOutpoint.TxID = upperHexBody(variant.ActiveOutpoint.TxID) + if variant.ActiveOutpoint.ScriptHash != "" { + variant.ActiveOutpoint.ScriptHash = upperHexBody(variant.ActiveOutpoint.ScriptHash) + } + variant.DestinationCommitmentHash = upperHexBody(variant.DestinationCommitmentHash) + + if variant.MigrationDestination != nil { + variant.MigrationDestination.Reserve = upperHexBody(variant.MigrationDestination.Reserve) + variant.MigrationDestination.Revealer = upperHexBody(variant.MigrationDestination.Revealer) + variant.MigrationDestination.Vault = upperHexBody(variant.MigrationDestination.Vault) + variant.MigrationDestination.DepositScript = upperHexBody(variant.MigrationDestination.DepositScript) + variant.MigrationDestination.DepositScriptHash = upperHexBody(variant.MigrationDestination.DepositScriptHash) + variant.MigrationDestination.MigrationExtraData = upperHexBody(variant.MigrationDestination.MigrationExtraData) + variant.MigrationDestination.DestinationCommitmentHash = upperHexBody( + variant.MigrationDestination.DestinationCommitmentHash, + ) + } + + if variant.MigrationTransactionPlan != nil { + variant.MigrationTransactionPlan.PlanCommitmentHash = upperHexBody( + variant.MigrationTransactionPlan.PlanCommitmentHash, + ) + } + + for i := range variant.ArtifactSignatures { + variant.ArtifactSignatures[i] = upperHexBody(variant.ArtifactSignatures[i]) + } + + for pathID, artifact := range variant.Artifacts { + artifact.PSBTHash = upperHexBody(artifact.PSBTHash) + artifact.DestinationCommitmentHash = upperHexBody(artifact.DestinationCommitmentHash) + if artifact.TransactionHex != "" { + artifact.TransactionHex = upperHexBody(artifact.TransactionHex) + } + if artifact.TransactionID != "" { + artifact.TransactionID = upperHexBody(artifact.TransactionID) + } + variant.Artifacts[pathID] = artifact + } + + if variant.ArtifactApprovals != nil { + variant.ArtifactApprovals.Payload.DestinationCommitmentHash = upperHexBody( + variant.ArtifactApprovals.Payload.DestinationCommitmentHash, + ) + variant.ArtifactApprovals.Payload.PlanCommitmentHash = upperHexBody( + variant.ArtifactApprovals.Payload.PlanCommitmentHash, + ) + + reorderedApprovals := make( + []ArtifactRoleApproval, + len(variant.ArtifactApprovals.Approvals), + ) + for i := range variant.ArtifactApprovals.Approvals { + approval := variant.ArtifactApprovals.Approvals[len(variant.ArtifactApprovals.Approvals)-1-i] + reorderedApprovals[i] = ArtifactRoleApproval{ + Role: approval.Role, + Signature: upperHexBody(approval.Signature), + } + } + variant.ArtifactApprovals.Approvals = reorderedApprovals + } + + switch variant.Route { + case TemplateQcV1: + template := &QcV1Template{} + if err := strictUnmarshal(variant.ScriptTemplate, template); err != nil { + t.Fatal(err) + } + template.DepositorPublicKey = upperHexBody(template.DepositorPublicKey) + template.CustodianPublicKey = upperHexBody(template.CustodianPublicKey) + template.SignerPublicKey = upperHexBody(template.SignerPublicKey) + variant.ScriptTemplate = mustTemplate(template) + case TemplateSelfV1: + template := &SelfV1Template{} + if err := strictUnmarshal(variant.ScriptTemplate, template); err != nil { + t.Fatal(err) + } + template.DepositorPublicKey = upperHexBody(template.DepositorPublicKey) + template.SignerPublicKey = upperHexBody(template.SignerPublicKey) + variant.ScriptTemplate = mustTemplate(template) + default: + t.Fatalf("unsupported route %s", variant.Route) + } + + return variant +} + func upperHexBody(value string) string { if !strings.HasPrefix(value, "0x") { return strings.ToUpper(value) @@ -1482,6 +1639,61 @@ func TestRequestDigestRejectsArtifactApprovalsWithoutMigrationTransactionPlan(t } } +func TestApprovalContractVectorsMatchExpectedRequestDigests(t *testing.T) { + for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} { + t.Run(string(route), func(t *testing.T) { + request, expectedDigest := loadApprovalContractVector(t, route) + + digest, err := requestDigest(request) + if err != nil { + t.Fatal(err) + } + + if digest != expectedDigest { + t.Fatalf("expected digest %s, got %s", expectedDigest, digest) + } + }) + } +} + +func TestApprovalContractVectorsNormalizeEquivalentVariants(t *testing.T) { + for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} { + t.Run(string(route), func(t *testing.T) { + canonicalRequest, expectedDigest := loadApprovalContractVector(t, route) + + normalizedCanonical, err := normalizeRouteSubmitRequest(canonicalRequest) + if err != nil { + t.Fatal(err) + } + + variantRequest := equivalentArtifactApprovalVariantFromRequest( + t, + canonicalRequest, + ) + normalizedVariant, err := normalizeRouteSubmitRequest(variantRequest) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(normalizedVariant, normalizedCanonical) { + t.Fatalf( + "expected normalized variant %#v, got %#v", + normalizedCanonical, + normalizedVariant, + ) + } + + digest, err := requestDigest(variantRequest) + if err != nil { + t.Fatal(err) + } + if digest != expectedDigest { + t.Fatalf("expected digest %s, got %s", expectedDigest, digest) + } + }) + } +} + func TestMigrationTransactionPlanCommitmentHashMatchesCanonicalVectors(t *testing.T) { testCases := []struct { name string diff --git a/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json new file mode 100644 index 0000000000..1ee2333ec3 --- /dev/null +++ b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json @@ -0,0 +1,165 @@ +{ + "version": 1, + "scope": "covenant_recovery_approval_contract_v1", + "vectors": { + "qc_v1": { + "canonicalSubmitRequest": { + "facadeRequestId": "rf_vector_qc_v1", + "idempotencyKey": "idem-qc-vector-v1", + "route": "qc_v1", + "strategy": "0x1111111111111111111111111111111111111111", + "reserve": "0x2222222222222222222222222222222222222222", + "epoch": 12, + "maturityHeight": 950000, + "activeOutpoint": { + "txid": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "vout": 1, + "scriptHash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", + "migrationDestination": { + "reservationId": "cmdr_12345678", + "reserve": "0x2222222222222222222222222222222222222222", + "epoch": 12, + "route": "MIGRATION", + "revealer": "0x2222222222222222222222222222222222222222", + "vault": "0x3333333333333333333333333333333333333333", + "network": "regtest", + "status": "RESERVED", + "depositScript": "0x0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "depositScriptHash": "0x8532ec6785e391b2af968b5728d574e271c7f46658f5ed10845d9ad5b23ac6d3", + "migrationExtraData": "0x41435f4d49475241544556312222222222222222222222222222222222222222", + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9" + }, + "migrationTransactionPlan": { + "planVersion": 1, + "planCommitmentHash": "0xc14b6b7c58211ceaee8f57a39c07481d9835ef959dbd6a02908312db4cf3c969", + "inputValueSats": 1000000, + "destinationValueSats": 998000, + "anchorValueSats": 330, + "feeSats": 1670, + "inputSequence": 4294967293, + "lockTime": 950000 + }, + "artifactApprovals": { + "payload": { + "approvalVersion": 1, + "route": "qc_v1", + "scriptTemplateId": "qc_v1", + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", + "planCommitmentHash": "0xc14b6b7c58211ceaee8f57a39c07481d9835ef959dbd6a02908312db4cf3c969" + }, + "approvals": [ + { + "role": "D", + "signature": "0xd0d0" + }, + { + "role": "C", + "signature": "0xc0c0" + }, + { + "role": "S", + "signature": "0x5050" + } + ] + }, + "artifactSignatures": [ + "0xd0d0", + "0xc0c0", + "0x5050" + ], + "artifacts": {}, + "scriptTemplate": { + "template": "qc_v1", + "depositorPublicKey": "0x02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "custodianPublicKey": "0x02dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "signerPublicKey": "0x02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "beta": 144, + "delta2": 4320 + }, + "signing": { + "signerRequired": true, + "custodianRequired": true + } + }, + "expectedRequestDigest": "0x4bb14155042065021708e80e35470a27640d68fc3e2a642c3cb2823595ea66b1" + }, + "self_v1": { + "canonicalSubmitRequest": { + "facadeRequestId": "rf_vector_self_v1", + "idempotencyKey": "idem-self-vector-v1", + "route": "self_v1", + "strategy": "0x1111111111111111111111111111111111111111", + "reserve": "0x2222222222222222222222222222222222222222", + "epoch": 12, + "maturityHeight": 950000, + "activeOutpoint": { + "txid": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "vout": 1, + "scriptHash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", + "migrationDestination": { + "reservationId": "cmdr_12345678", + "reserve": "0x2222222222222222222222222222222222222222", + "epoch": 12, + "route": "MIGRATION", + "revealer": "0x2222222222222222222222222222222222222222", + "vault": "0x3333333333333333333333333333333333333333", + "network": "regtest", + "status": "RESERVED", + "depositScript": "0x0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "depositScriptHash": "0x8532ec6785e391b2af968b5728d574e271c7f46658f5ed10845d9ad5b23ac6d3", + "migrationExtraData": "0x41435f4d49475241544556312222222222222222222222222222222222222222", + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9" + }, + "migrationTransactionPlan": { + "planVersion": 1, + "planCommitmentHash": "0xc14b6b7c58211ceaee8f57a39c07481d9835ef959dbd6a02908312db4cf3c969", + "inputValueSats": 1000000, + "destinationValueSats": 998000, + "anchorValueSats": 330, + "feeSats": 1670, + "inputSequence": 4294967293, + "lockTime": 950000 + }, + "artifactApprovals": { + "payload": { + "approvalVersion": 1, + "route": "self_v1", + "scriptTemplateId": "self_v1", + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", + "planCommitmentHash": "0xc14b6b7c58211ceaee8f57a39c07481d9835ef959dbd6a02908312db4cf3c969" + }, + "approvals": [ + { + "role": "D", + "signature": "0xd0d0" + }, + { + "role": "S", + "signature": "0x5050" + } + ] + }, + "artifactSignatures": [ + "0xd0d0", + "0x5050" + ], + "artifacts": {}, + "scriptTemplate": { + "template": "self_v1", + "depositorPublicKey": "0x02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "signerPublicKey": "0x02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "delta2": 4320 + }, + "signing": { + "signerRequired": true, + "custodianRequired": false + } + }, + "expectedRequestDigest": "0x38c86be37817a1d4ec87bf5ec41a9022f44a03e08d7195c4280b7b91eae5bce2" + } + } +} From 5c02c8d27f872ecec34cb58524f3da969bbdc67e Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 13:29:03 -0500 Subject: [PATCH 022/143] Add mixed-case covenant signer coverage --- pkg/covenantsigner/covenantsigner_test.go | 193 +++++++++++++++++++--- 1 file changed, 166 insertions(+), 27 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 7f92e06c61..5e12f16c90 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -247,6 +247,52 @@ func canonicalArtifactApprovalRequest(route TemplateID) RouteSubmitRequest { return request } +const ( + mixedCaseCoverageStrategy = "0xaabbccddeeff00112233445566778899aabbccdd" + mixedCaseCoverageReserve = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" + mixedCaseCoverageRevealer = "0xdecafbaddecafbaddecafbaddecafbaddecafbad" + mixedCaseCoverageVault = "0xbeadfeedbeadfeedbeadfeedbeadfeedbeadfeed" +) + +func canonicalMixedCaseCoverageArtifactApprovalRequest( + t *testing.T, + route TemplateID, +) RouteSubmitRequest { + t.Helper() + + request := canonicalArtifactApprovalRequest(route) + request.Strategy = mixedCaseCoverageStrategy + request.Reserve = mixedCaseCoverageReserve + request.MigrationDestination.Reserve = mixedCaseCoverageReserve + request.MigrationDestination.Revealer = mixedCaseCoverageRevealer + request.MigrationDestination.Vault = mixedCaseCoverageVault + request.MigrationDestination.MigrationExtraData = computeMigrationExtraData( + request.MigrationDestination.Revealer, + ) + + destinationCommitmentHash, err := computeDestinationCommitmentHash( + request.MigrationDestination, + ) + if err != nil { + t.Fatal(err) + } + request.MigrationDestination.DestinationCommitmentHash = destinationCommitmentHash + request.DestinationCommitmentHash = destinationCommitmentHash + + planCommitmentHash, err := computeMigrationTransactionPlanCommitmentHash( + request, + request.MigrationTransactionPlan, + ) + if err != nil { + t.Fatal(err) + } + request.MigrationTransactionPlan.PlanCommitmentHash = planCommitmentHash + request.ArtifactApprovals.Payload.DestinationCommitmentHash = destinationCommitmentHash + request.ArtifactApprovals.Payload.PlanCommitmentHash = planCommitmentHash + + return request +} + func cloneRouteSubmitRequest( t *testing.T, request RouteSubmitRequest, @@ -261,60 +307,61 @@ func cloneRouteSubmitRequest( return cloned } -func equivalentArtifactApprovalVariantFromRequest( +func artifactApprovalVariantFromRequest( t *testing.T, request RouteSubmitRequest, + transformHex func(string) string, ) RouteSubmitRequest { t.Helper() variant := cloneRouteSubmitRequest(t, request) - variant.Strategy = upperHexBody(variant.Strategy) - variant.Reserve = upperHexBody(variant.Reserve) - variant.ActiveOutpoint.TxID = upperHexBody(variant.ActiveOutpoint.TxID) + variant.Strategy = transformHex(variant.Strategy) + variant.Reserve = transformHex(variant.Reserve) + variant.ActiveOutpoint.TxID = transformHex(variant.ActiveOutpoint.TxID) if variant.ActiveOutpoint.ScriptHash != "" { - variant.ActiveOutpoint.ScriptHash = upperHexBody(variant.ActiveOutpoint.ScriptHash) + variant.ActiveOutpoint.ScriptHash = transformHex(variant.ActiveOutpoint.ScriptHash) } - variant.DestinationCommitmentHash = upperHexBody(variant.DestinationCommitmentHash) + variant.DestinationCommitmentHash = transformHex(variant.DestinationCommitmentHash) if variant.MigrationDestination != nil { - variant.MigrationDestination.Reserve = upperHexBody(variant.MigrationDestination.Reserve) - variant.MigrationDestination.Revealer = upperHexBody(variant.MigrationDestination.Revealer) - variant.MigrationDestination.Vault = upperHexBody(variant.MigrationDestination.Vault) - variant.MigrationDestination.DepositScript = upperHexBody(variant.MigrationDestination.DepositScript) - variant.MigrationDestination.DepositScriptHash = upperHexBody(variant.MigrationDestination.DepositScriptHash) - variant.MigrationDestination.MigrationExtraData = upperHexBody(variant.MigrationDestination.MigrationExtraData) - variant.MigrationDestination.DestinationCommitmentHash = upperHexBody( + variant.MigrationDestination.Reserve = transformHex(variant.MigrationDestination.Reserve) + variant.MigrationDestination.Revealer = transformHex(variant.MigrationDestination.Revealer) + variant.MigrationDestination.Vault = transformHex(variant.MigrationDestination.Vault) + variant.MigrationDestination.DepositScript = transformHex(variant.MigrationDestination.DepositScript) + variant.MigrationDestination.DepositScriptHash = transformHex(variant.MigrationDestination.DepositScriptHash) + variant.MigrationDestination.MigrationExtraData = transformHex(variant.MigrationDestination.MigrationExtraData) + variant.MigrationDestination.DestinationCommitmentHash = transformHex( variant.MigrationDestination.DestinationCommitmentHash, ) } if variant.MigrationTransactionPlan != nil { - variant.MigrationTransactionPlan.PlanCommitmentHash = upperHexBody( + variant.MigrationTransactionPlan.PlanCommitmentHash = transformHex( variant.MigrationTransactionPlan.PlanCommitmentHash, ) } for i := range variant.ArtifactSignatures { - variant.ArtifactSignatures[i] = upperHexBody(variant.ArtifactSignatures[i]) + variant.ArtifactSignatures[i] = transformHex(variant.ArtifactSignatures[i]) } for pathID, artifact := range variant.Artifacts { - artifact.PSBTHash = upperHexBody(artifact.PSBTHash) - artifact.DestinationCommitmentHash = upperHexBody(artifact.DestinationCommitmentHash) + artifact.PSBTHash = transformHex(artifact.PSBTHash) + artifact.DestinationCommitmentHash = transformHex(artifact.DestinationCommitmentHash) if artifact.TransactionHex != "" { - artifact.TransactionHex = upperHexBody(artifact.TransactionHex) + artifact.TransactionHex = transformHex(artifact.TransactionHex) } if artifact.TransactionID != "" { - artifact.TransactionID = upperHexBody(artifact.TransactionID) + artifact.TransactionID = transformHex(artifact.TransactionID) } variant.Artifacts[pathID] = artifact } if variant.ArtifactApprovals != nil { - variant.ArtifactApprovals.Payload.DestinationCommitmentHash = upperHexBody( + variant.ArtifactApprovals.Payload.DestinationCommitmentHash = transformHex( variant.ArtifactApprovals.Payload.DestinationCommitmentHash, ) - variant.ArtifactApprovals.Payload.PlanCommitmentHash = upperHexBody( + variant.ArtifactApprovals.Payload.PlanCommitmentHash = transformHex( variant.ArtifactApprovals.Payload.PlanCommitmentHash, ) @@ -326,7 +373,7 @@ func equivalentArtifactApprovalVariantFromRequest( approval := variant.ArtifactApprovals.Approvals[len(variant.ArtifactApprovals.Approvals)-1-i] reorderedApprovals[i] = ArtifactRoleApproval{ Role: approval.Role, - Signature: upperHexBody(approval.Signature), + Signature: transformHex(approval.Signature), } } variant.ArtifactApprovals.Approvals = reorderedApprovals @@ -338,17 +385,17 @@ func equivalentArtifactApprovalVariantFromRequest( if err := strictUnmarshal(variant.ScriptTemplate, template); err != nil { t.Fatal(err) } - template.DepositorPublicKey = upperHexBody(template.DepositorPublicKey) - template.CustodianPublicKey = upperHexBody(template.CustodianPublicKey) - template.SignerPublicKey = upperHexBody(template.SignerPublicKey) + template.DepositorPublicKey = transformHex(template.DepositorPublicKey) + template.CustodianPublicKey = transformHex(template.CustodianPublicKey) + template.SignerPublicKey = transformHex(template.SignerPublicKey) variant.ScriptTemplate = mustTemplate(template) case TemplateSelfV1: template := &SelfV1Template{} if err := strictUnmarshal(variant.ScriptTemplate, template); err != nil { t.Fatal(err) } - template.DepositorPublicKey = upperHexBody(template.DepositorPublicKey) - template.SignerPublicKey = upperHexBody(template.SignerPublicKey) + template.DepositorPublicKey = transformHex(template.DepositorPublicKey) + template.SignerPublicKey = transformHex(template.SignerPublicKey) variant.ScriptTemplate = mustTemplate(template) default: t.Fatalf("unsupported route %s", variant.Route) @@ -357,6 +404,14 @@ func equivalentArtifactApprovalVariantFromRequest( return variant } +func equivalentArtifactApprovalVariantFromRequest( + t *testing.T, + request RouteSubmitRequest, +) RouteSubmitRequest { + t.Helper() + return artifactApprovalVariantFromRequest(t, request, upperHexBody) +} + func upperHexBody(value string) string { if !strings.HasPrefix(value, "0x") { return strings.ToUpper(value) @@ -365,6 +420,37 @@ func upperHexBody(value string) string { return "0x" + strings.ToUpper(strings.TrimPrefix(value, "0x")) } +func mixedCaseHexBody(value string) string { + if !strings.HasPrefix(value, "0x") { + return value + } + + body := strings.ToLower(strings.TrimPrefix(value, "0x")) + lettersSeen := 0 + variant := strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'f' { + if lettersSeen%2 == 0 { + lettersSeen++ + return r - ('a' - 'A') + } + + lettersSeen++ + } + + return r + }, body) + + return "0x" + variant +} + +func mixedCaseArtifactApprovalVariantFromRequest( + t *testing.T, + request RouteSubmitRequest, +) RouteSubmitRequest { + t.Helper() + return artifactApprovalVariantFromRequest(t, request, mixedCaseHexBody) +} + func equivalentArtifactApprovalVariant(route TemplateID) RouteSubmitRequest { request := canonicalArtifactApprovalRequest(route) @@ -1694,6 +1780,59 @@ func TestApprovalContractVectorsNormalizeEquivalentVariants(t *testing.T) { } } +func TestRequestDigestNormalizesMixedCaseArtifactApprovalVariants(t *testing.T) { + for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} { + t.Run(string(route), func(t *testing.T) { + canonicalRequest := canonicalMixedCaseCoverageArtifactApprovalRequest(t, route) + mixedCaseRequest := mixedCaseArtifactApprovalVariantFromRequest( + t, + canonicalRequest, + ) + + if mixedCaseRequest.Reserve == canonicalRequest.Reserve { + t.Fatalf( + "expected mixed-case reserve variant, got %s", + mixedCaseRequest.Reserve, + ) + } + + normalizedCanonical, err := normalizeRouteSubmitRequest(canonicalRequest) + if err != nil { + t.Fatal(err) + } + normalizedMixedCase, err := normalizeRouteSubmitRequest(mixedCaseRequest) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(normalizedMixedCase, normalizedCanonical) { + t.Fatalf( + "expected normalized mixed-case request %#v, got %#v", + normalizedCanonical, + normalizedMixedCase, + ) + } + + canonicalDigest, err := requestDigest(canonicalRequest) + if err != nil { + t.Fatal(err) + } + mixedCaseDigest, err := requestDigest(mixedCaseRequest) + if err != nil { + t.Fatal(err) + } + + if mixedCaseDigest != canonicalDigest { + t.Fatalf( + "expected matching digest %s, got %s", + canonicalDigest, + mixedCaseDigest, + ) + } + }) + } +} + func TestMigrationTransactionPlanCommitmentHashMatchesCanonicalVectors(t *testing.T) { testCases := []struct { name string From df212397ec8376eba7cd63cefc3c465b8fb12919 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 13:51:10 -0500 Subject: [PATCH 023/143] Verify depositor and custodian approval signatures --- pkg/covenantsigner/covenantsigner_test.go | 352 ++++++++++++++++++---- pkg/covenantsigner/validation.go | 257 +++++++++++++++- pkg/tbtc/covenant_signer_test.go | 144 +++++++++ 3 files changed, 694 insertions(+), 59 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 5e12f16c90..287a3afd67 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -3,6 +3,7 @@ package covenantsigner import ( "bytes" "context" + "encoding/hex" "encoding/json" "fmt" "io" @@ -15,6 +16,7 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/btcec" "github.com/keep-network/keep-common/pkg/persistence" ) @@ -141,11 +143,106 @@ func loadApprovalContractVector( return request, vector.ExpectedRequestDigest } +const ( + testDepositorPrivateKeyHex = "0x1111111111111111111111111111111111111111111111111111111111111111" + testSignerPrivateKeyHex = "0x2222222222222222222222222222222222222222222222222222222222222222" + testCustodianPrivateKeyHex = "0x3333333333333333333333333333333333333333333333333333333333333333" +) + +var ( + testDepositorPrivateKey = mustDeterministicTestPrivateKey(testDepositorPrivateKeyHex) + testSignerPrivateKey = mustDeterministicTestPrivateKey(testSignerPrivateKeyHex) + testCustodianPrivateKey = mustDeterministicTestPrivateKey(testCustodianPrivateKeyHex) + testDepositorPublicKey = mustCompressedPublicKeyHex(testDepositorPrivateKey) + testSignerPublicKey = mustCompressedPublicKeyHex(testSignerPrivateKey) + testCustodianPublicKey = mustCompressedPublicKeyHex(testCustodianPrivateKey) +) + +func mustDeterministicTestPrivateKey(encoded string) *btcec.PrivateKey { + rawPrivateKey, err := hex.DecodeString(strings.TrimPrefix(encoded, "0x")) + if err != nil { + panic(err) + } + + privateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), rawPrivateKey) + return privateKey +} + +func mustCompressedPublicKeyHex(privateKey *btcec.PrivateKey) string { + return "0x" + hex.EncodeToString(privateKey.PubKey().SerializeCompressed()) +} + +func mustArtifactApprovalSignature( + privateKey *btcec.PrivateKey, + payload ArtifactApprovalPayload, +) string { + digest, err := artifactApprovalDigest(payload) + if err != nil { + panic(err) + } + + signature, err := privateKey.Sign(digest) + if err != nil { + panic(err) + } + + return "0x" + hex.EncodeToString(signature.Serialize()) +} + +func artifactApprovalSignatureByRole( + artifactApprovals *ArtifactApprovalEnvelope, + role ArtifactApprovalRole, +) string { + for _, approval := range artifactApprovals.Approvals { + if approval.Role == role { + return approval.Signature + } + } + + panic(fmt.Sprintf("missing approval role %s", role)) +} + +func setArtifactApprovalSignature( + artifactApprovals *ArtifactApprovalEnvelope, + role ArtifactApprovalRole, + signature string, +) { + for i, approval := range artifactApprovals.Approvals { + if approval.Role == role { + artifactApprovals.Approvals[i].Signature = signature + return + } + } + + panic(fmt.Sprintf("missing approval role %s", role)) +} + +func canonicalArtifactSignatures( + route TemplateID, + artifactApprovals *ArtifactApprovalEnvelope, +) []string { + if artifactApprovals == nil { + return nil + } + + requiredRoles, err := requiredArtifactApprovalRoles(route) + if err != nil { + panic(err) + } + + signatures := make([]string, len(requiredRoles)) + for i, role := range requiredRoles { + signatures[i] = artifactApprovalSignatureByRole(artifactApprovals, role) + } + + return signatures +} + func validSelfTemplate() json.RawMessage { return mustTemplate(SelfV1Template{ Template: TemplateSelfV1, - DepositorPublicKey: "0x021111", - SignerPublicKey: "0x022222", + DepositorPublicKey: testDepositorPublicKey, + SignerPublicKey: testSignerPublicKey, Delta2: 4320, }) } @@ -153,9 +250,9 @@ func validSelfTemplate() json.RawMessage { func validQcTemplate() json.RawMessage { return mustTemplate(QcV1Template{ Template: TemplateQcV1, - DepositorPublicKey: "0x021111", - CustodianPublicKey: "0x023333", - SignerPublicKey: "0x022222", + DepositorPublicKey: testDepositorPublicKey, + CustodianPublicKey: testCustodianPublicKey, + SignerPublicKey: testSignerPublicKey, Beta: 144, Delta2: 4320, }) @@ -188,8 +285,7 @@ func baseRequest(route TemplateID) RouteSubmitRequest { InputSequence: canonicalCovenantInputSequence, LockTime: 912345, }, - ArtifactSignatures: []string{"0x0708"}, - Artifacts: map[RecoveryPathID]ArtifactRecord{}, + Artifacts: map[RecoveryPathID]ArtifactRecord{}, } switch route { @@ -206,45 +302,60 @@ func baseRequest(route TemplateID) RouteSubmitRequest { request, request.MigrationTransactionPlan, ) + request.ArtifactApprovals = validArtifactApprovals(request) + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) return request } func validArtifactApprovals(request RouteSubmitRequest) *ArtifactApprovalEnvelope { + payload := ArtifactApprovalPayload{ + ApprovalVersion: artifactApprovalVersion, + Route: request.Route, + ScriptTemplateID: request.Route, + DestinationCommitmentHash: request.DestinationCommitmentHash, + PlanCommitmentHash: request.MigrationTransactionPlan.PlanCommitmentHash, + } + approvals := []ArtifactRoleApproval{ - {Role: ArtifactApprovalRoleDepositor, Signature: "0xd0d0"}, - {Role: ArtifactApprovalRoleSigner, Signature: "0x5050"}, + { + Role: ArtifactApprovalRoleDepositor, + Signature: mustArtifactApprovalSignature(testDepositorPrivateKey, payload), + }, + { + Role: ArtifactApprovalRoleSigner, + Signature: mustArtifactApprovalSignature(testSignerPrivateKey, payload), + }, } if request.Route == TemplateQcV1 { approvals = []ArtifactRoleApproval{ - {Role: ArtifactApprovalRoleDepositor, Signature: "0xd0d0"}, - {Role: ArtifactApprovalRoleCustodian, Signature: "0xc0c0"}, - {Role: ArtifactApprovalRoleSigner, Signature: "0x5050"}, + { + Role: ArtifactApprovalRoleDepositor, + Signature: mustArtifactApprovalSignature(testDepositorPrivateKey, payload), + }, + { + Role: ArtifactApprovalRoleCustodian, + Signature: mustArtifactApprovalSignature(testCustodianPrivateKey, payload), + }, + { + Role: ArtifactApprovalRoleSigner, + Signature: mustArtifactApprovalSignature(testSignerPrivateKey, payload), + }, } } return &ArtifactApprovalEnvelope{ - Payload: ArtifactApprovalPayload{ - ApprovalVersion: artifactApprovalVersion, - Route: request.Route, - ScriptTemplateID: request.Route, - DestinationCommitmentHash: request.DestinationCommitmentHash, - PlanCommitmentHash: request.MigrationTransactionPlan.PlanCommitmentHash, - }, + Payload: payload, Approvals: approvals, } } func canonicalArtifactApprovalRequest(route TemplateID) RouteSubmitRequest { - request := baseRequest(route) - request.ArtifactApprovals = validArtifactApprovals(request) - request.ArtifactSignatures = []string{"0xd0d0", "0x5050"} - if route == TemplateQcV1 { - request.ArtifactSignatures = []string{"0xd0d0", "0xc0c0", "0x5050"} - } - - return request + return baseRequest(route) } const ( @@ -474,9 +585,9 @@ func equivalentArtifactApprovalVariant(route TemplateID) RouteSubmitRequest { if route == TemplateQcV1 { request.ScriptTemplate = mustTemplate(QcV1Template{ Template: TemplateQcV1, - DepositorPublicKey: upperHexBody("0x021111"), - CustodianPublicKey: upperHexBody("0x023333"), - SignerPublicKey: upperHexBody("0x022222"), + DepositorPublicKey: upperHexBody(testDepositorPublicKey), + CustodianPublicKey: upperHexBody(testCustodianPublicKey), + SignerPublicKey: upperHexBody(testSignerPublicKey), Beta: 144, Delta2: 4320, }) @@ -488,23 +599,38 @@ func equivalentArtifactApprovalVariant(route TemplateID) RouteSubmitRequest { ) request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ { - Role: ArtifactApprovalRoleSigner, - Signature: upperHexBody("0x5050"), + Role: ArtifactApprovalRoleSigner, + Signature: upperHexBody( + artifactApprovalSignatureByRole( + request.ArtifactApprovals, + ArtifactApprovalRoleSigner, + ), + ), }, { - Role: ArtifactApprovalRoleDepositor, - Signature: upperHexBody("0xd0d0"), + Role: ArtifactApprovalRoleDepositor, + Signature: upperHexBody( + artifactApprovalSignatureByRole( + request.ArtifactApprovals, + ArtifactApprovalRoleDepositor, + ), + ), }, { - Role: ArtifactApprovalRoleCustodian, - Signature: upperHexBody("0xc0c0"), + Role: ArtifactApprovalRoleCustodian, + Signature: upperHexBody( + artifactApprovalSignatureByRole( + request.ArtifactApprovals, + ArtifactApprovalRoleCustodian, + ), + ), }, } } else { request.ScriptTemplate = mustTemplate(SelfV1Template{ Template: TemplateSelfV1, - DepositorPublicKey: upperHexBody("0x021111"), - SignerPublicKey: upperHexBody("0x022222"), + DepositorPublicKey: upperHexBody(testDepositorPublicKey), + SignerPublicKey: upperHexBody(testSignerPublicKey), Delta2: 4320, }) request.ArtifactApprovals.Payload.DestinationCommitmentHash = upperHexBody( @@ -515,12 +641,22 @@ func equivalentArtifactApprovalVariant(route TemplateID) RouteSubmitRequest { ) request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ { - Role: ArtifactApprovalRoleSigner, - Signature: upperHexBody("0x5050"), + Role: ArtifactApprovalRoleSigner, + Signature: upperHexBody( + artifactApprovalSignatureByRole( + request.ArtifactApprovals, + ArtifactApprovalRoleSigner, + ), + ), }, { - Role: ArtifactApprovalRoleDepositor, - Signature: upperHexBody("0xd0d0"), + Role: ArtifactApprovalRoleDepositor, + Signature: upperHexBody( + artifactApprovalSignatureByRole( + request.ArtifactApprovals, + ArtifactApprovalRoleDepositor, + ), + ), }, } } @@ -1468,13 +1604,11 @@ func TestServiceAcceptsArtifactApprovalsWithCanonicalLegacySignatures(t *testing } request := baseRequest(TemplateQcV1) - request.ArtifactApprovals = validArtifactApprovals(request) request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ request.ArtifactApprovals.Approvals[2], request.ArtifactApprovals.Approvals[0], request.ArtifactApprovals.Approvals[1], } - request.ArtifactSignatures = []string{"0xd0d0", "0xc0c0", "0x5050"} result, err := service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ RouteRequestID: "orq_artifact_approvals", @@ -1518,12 +1652,14 @@ func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { name: "missing qc custodian approval", route: TemplateQcV1, mutate: func(request *RouteSubmitRequest) { - request.ArtifactApprovals = validArtifactApprovals(*request) request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ request.ArtifactApprovals.Approvals[0], request.ArtifactApprovals.Approvals[2], } - request.ArtifactSignatures = []string{"0xd0d0", "0x5050"} + request.ArtifactSignatures = []string{ + request.ArtifactSignatures[0], + request.ArtifactSignatures[2], + } }, expectErr: "request.artifactApprovals.approvals must include role C for qc_v1", }, @@ -1531,13 +1667,17 @@ func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { name: "self route rejects custodian approval role", route: TemplateSelfV1, mutate: func(request *RouteSubmitRequest) { - request.ArtifactApprovals = validArtifactApprovals(*request) request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ request.ArtifactApprovals.Approvals[0], - {Role: ArtifactApprovalRoleCustodian, Signature: "0xc0c0"}, + { + Role: ArtifactApprovalRoleCustodian, + Signature: mustArtifactApprovalSignature( + testCustodianPrivateKey, + request.ArtifactApprovals.Payload, + ), + }, request.ArtifactApprovals.Approvals[1], } - request.ArtifactSignatures = []string{"0xd0d0", "0xc0c0", "0x5050"} }, expectErr: "request.artifactApprovals.approvals[1].role is not allowed for self_v1", }, @@ -1545,18 +1685,27 @@ func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { name: "plan commitment mismatch", route: TemplateQcV1, mutate: func(request *RouteSubmitRequest) { - request.ArtifactApprovals = validArtifactApprovals(*request) request.ArtifactApprovals.Payload.PlanCommitmentHash = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - request.ArtifactSignatures = []string{"0xd0d0", "0xc0c0", "0x5050"} }, expectErr: "request.artifactApprovals.payload.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash", }, + { + name: "artifact approvals required", + route: TemplateSelfV1, + mutate: func(request *RouteSubmitRequest) { + request.ArtifactApprovals = nil + }, + expectErr: "request.artifactApprovals is required", + }, { name: "legacy signature mismatch", route: TemplateQcV1, mutate: func(request *RouteSubmitRequest) { - request.ArtifactApprovals = validArtifactApprovals(*request) - request.ArtifactSignatures = []string{"0x5050", "0xd0d0", "0xc0c0"} + request.ArtifactSignatures = []string{ + request.ArtifactSignatures[2], + request.ArtifactSignatures[0], + request.ArtifactSignatures[1], + } }, expectErr: "request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals", }, @@ -1564,11 +1713,48 @@ func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { name: "legacy signatures remain required when approvals are present", route: TemplateSelfV1, mutate: func(request *RouteSubmitRequest) { - request.ArtifactApprovals = validArtifactApprovals(*request) request.ArtifactSignatures = nil }, expectErr: "request.artifactSignatures must not be empty", }, + { + name: "depositor signature does not verify", + route: TemplateSelfV1, + mutate: func(request *RouteSubmitRequest) { + setArtifactApprovalSignature( + request.ArtifactApprovals, + ArtifactApprovalRoleDepositor, + artifactApprovalSignatureByRole( + request.ArtifactApprovals, + ArtifactApprovalRoleSigner, + ), + ) + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) + }, + expectErr: "request.artifactApprovals.approvals[0].signature does not verify against the required public key", + }, + { + name: "custodian signature does not verify", + route: TemplateQcV1, + mutate: func(request *RouteSubmitRequest) { + setArtifactApprovalSignature( + request.ArtifactApprovals, + ArtifactApprovalRoleCustodian, + artifactApprovalSignatureByRole( + request.ArtifactApprovals, + ArtifactApprovalRoleDepositor, + ), + ) + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) + }, + expectErr: "request.artifactApprovals.approvals[1].signature does not verify against the required public key", + }, } for _, testCase := range testCases { @@ -1725,6 +1911,29 @@ func TestRequestDigestRejectsArtifactApprovalsWithoutMigrationTransactionPlan(t } } +func TestArtifactApprovalDigestMatchesPhase1Contract(t *testing.T) { + expectedDigests := map[TemplateID]string{ + TemplateQcV1: "0x4e1c72624e85c41d8d8a050d75704dc881ec6cd2dcfe1d240052887feef87ad8", + TemplateSelfV1: "0x960d7082d6eac550d7647d8fbeb90781e6cbd001b4d433e6635aa447dd937e79", + } + + for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} { + t.Run(string(route), func(t *testing.T) { + request := canonicalArtifactApprovalRequest(route) + + digest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + if err != nil { + t.Fatal(err) + } + + actualDigest := "0x" + hex.EncodeToString(digest) + if actualDigest != expectedDigests[route] { + t.Fatalf("expected digest %s, got %s", expectedDigests[route], actualDigest) + } + }) + } +} + func TestApprovalContractVectorsMatchExpectedRequestDigests(t *testing.T) { for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} { t.Run(string(route), func(t *testing.T) { @@ -2027,6 +2236,10 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { defer server.Close() base := baseRequest(TemplateSelfV1) + template := &SelfV1Template{} + if err := strictUnmarshal(base.ScriptTemplate, template); err != nil { + t.Fatal(err) + } payload := bytes.NewBufferString(fmt.Sprintf(`{ "routeRequestId":"ors_http_unknown", "stage":"SIGNER_COORDINATION", @@ -2064,14 +2277,39 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { "inputSequence":4294967293, "lockTime":912345 }, - "artifactSignatures":["0x0708"], + "artifactApprovals":{ + "payload":{ + "approvalVersion":1, + "route":"self_v1", + "scriptTemplateId":"self_v1", + "destinationCommitmentHash":"%s", + "planCommitmentHash":"%s" + }, + "approvals":[ + {"role":"D","signature":"%s"}, + {"role":"S","signature":"%s"} + ] + }, + "artifactSignatures":["%s","%s"], "artifacts":{}, - "scriptTemplate":{"template":"self_v1","depositorPublicKey":"0x021111","signerPublicKey":"0x022222","delta2":4320}, + "scriptTemplate":{"template":"self_v1","depositorPublicKey":"%s","signerPublicKey":"%s","delta2":4320}, "signing":{"signerRequired":true,"custodianRequired":false}, "futureField":"ignored" }, "futureTopLevel":"ignored" - }`, base.DestinationCommitmentHash, base.DestinationCommitmentHash, base.MigrationTransactionPlan.PlanCommitmentHash)) + }`, + base.DestinationCommitmentHash, + base.DestinationCommitmentHash, + base.MigrationTransactionPlan.PlanCommitmentHash, + base.ArtifactApprovals.Payload.DestinationCommitmentHash, + base.ArtifactApprovals.Payload.PlanCommitmentHash, + base.ArtifactApprovals.Approvals[0].Signature, + base.ArtifactApprovals.Approvals[1].Signature, + base.ArtifactSignatures[0], + base.ArtifactSignatures[1], + template.DepositorPublicKey, + template.SignerPublicKey, + )) response, err := http.Post(server.URL+"/v1/self_v1/signer/requests", "application/json", payload) if err != nil { diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 7441438022..94552b73e9 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -2,12 +2,18 @@ package covenantsigner import ( "bytes" + "crypto/ecdsa" "crypto/sha256" + "encoding/binary" "encoding/hex" "encoding/json" "fmt" "math" + "math/big" "strings" + + "github.com/btcsuite/btcd/btcec" + "github.com/ethereum/go-ethereum/crypto" ) const ( @@ -17,6 +23,15 @@ const ( artifactApprovalVersion uint32 = 1 ) +var artifactApprovalTypeHash = crypto.Keccak256Hash([]byte( + "ArtifactApproval(" + + "uint8 approvalVersion," + + "bytes32 route," + + "bytes32 scriptTemplateId," + + "bytes32 destinationCommitmentHash," + + "bytes32 planCommitmentHash)", +)) + type inputError struct { message string } @@ -89,10 +104,166 @@ func validateAddressString(name string, value string) error { return nil } +func validateBytes32HexString(name string, value string) error { + if err := validateHexString(name, value); err != nil { + return err + } + + if len(value) != 66 { + return &inputError{fmt.Sprintf("%s must be a 32-byte 0x-prefixed hex string", name)} + } + + return nil +} + +func decodeBytes32HexString(name string, value string) ([32]byte, error) { + var decoded [32]byte + + if err := validateBytes32HexString(name, value); err != nil { + return decoded, err + } + + rawValue, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + if err != nil { + return decoded, &inputError{fmt.Sprintf("%s must be valid hex", name)} + } + + copy(decoded[:], rawValue) + return decoded, nil +} + func normalizeLowerHex(value string) string { return strings.ToLower(value) } +func abiEncodeUint32Word(value uint32) [32]byte { + var encoded [32]byte + binary.BigEndian.PutUint32(encoded[28:], value) + return encoded +} + +func keccakTemplateIdentifier(id TemplateID) [32]byte { + hash := crypto.Keccak256Hash([]byte(id)) + + var encoded [32]byte + copy(encoded[:], hash.Bytes()) + + return encoded +} + +// artifactApprovalDigest pins the current phase-1 approval payload contract to +// a deterministic EIP-712-compatible struct hash, without yet committing to a +// chain-specific domain separator. +func artifactApprovalDigest(payload ArtifactApprovalPayload) ([]byte, error) { + destinationCommitmentHash, err := decodeBytes32HexString( + "request.artifactApprovals.payload.destinationCommitmentHash", + payload.DestinationCommitmentHash, + ) + if err != nil { + return nil, err + } + + planCommitmentHash, err := decodeBytes32HexString( + "request.artifactApprovals.payload.planCommitmentHash", + payload.PlanCommitmentHash, + ) + if err != nil { + return nil, err + } + + encoded := make([]byte, 32*6) + approvalVersionWord := abiEncodeUint32Word(payload.ApprovalVersion) + routeIdentifier := keccakTemplateIdentifier(payload.Route) + scriptTemplateIdentifier := keccakTemplateIdentifier(payload.ScriptTemplateID) + + copy(encoded[0:32], artifactApprovalTypeHash.Bytes()) + copy(encoded[32:64], approvalVersionWord[:]) + copy(encoded[64:96], routeIdentifier[:]) + copy(encoded[96:128], scriptTemplateIdentifier[:]) + copy(encoded[128:160], destinationCommitmentHash[:]) + copy(encoded[160:192], planCommitmentHash[:]) + + digest := crypto.Keccak256Hash(encoded) + return digest.Bytes(), nil +} + +func parseCompressedSecp256k1PublicKey( + name string, + value string, +) (*btcec.PublicKey, error) { + if err := validateHexString(name, value); err != nil { + return nil, err + } + + rawValue, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + if err != nil { + return nil, &inputError{fmt.Sprintf("%s must be valid hex", name)} + } + + if len(rawValue) != 33 || (rawValue[0] != 0x02 && rawValue[0] != 0x03) { + return nil, &inputError{fmt.Sprintf("%s must be a compressed secp256k1 public key", name)} + } + + publicKey, err := btcec.ParsePubKey(rawValue, btcec.S256()) + if err != nil { + return nil, &inputError{fmt.Sprintf("%s must be a compressed secp256k1 public key", name)} + } + + return publicKey, nil +} + +func verifyCompactSecp256k1Signature( + publicKey *btcec.PublicKey, + digest []byte, + signature []byte, +) bool { + return ecdsa.Verify( + publicKey.ToECDSA(), + digest, + new(big.Int).SetBytes(signature[:32]), + new(big.Int).SetBytes(signature[32:]), + ) +} + +func verifySecp256k1Signature( + name string, + publicKey *btcec.PublicKey, + digest []byte, + signature string, +) error { + rawSignature, err := hex.DecodeString(strings.TrimPrefix(signature, "0x")) + if err != nil { + return &inputError{fmt.Sprintf("%s must be valid hex", name)} + } + + switch { + case len(rawSignature) == 64: + if verifyCompactSecp256k1Signature(publicKey, digest, rawSignature) { + return nil + } + case len(rawSignature) == 65 && + (rawSignature[64] == 0 || rawSignature[64] == 1 || rawSignature[64] == 27 || rawSignature[64] == 28): + if verifyCompactSecp256k1Signature(publicKey, digest, rawSignature[:64]) { + return nil + } + default: + parsedSignature, err := btcec.ParseDERSignature(rawSignature, btcec.S256()) + if err != nil { + return &inputError{ + fmt.Sprintf( + "%s must be a DER or 64/65-byte secp256k1 signature", + name, + ), + } + } + if parsedSignature.Verify(digest, publicKey) { + return nil + } + } + + return &inputError{fmt.Sprintf("%s does not verify against the required public key", name)} +} + func computeMigrationExtraData(revealer string) string { return "0x" + hex.EncodeToString([]byte("AC_MIGRATEV1")) + strings.TrimPrefix(normalizeLowerHex(revealer), "0x") } @@ -382,13 +553,13 @@ func normalizeArtifactApprovals( if request.ArtifactApprovals.Payload.ScriptTemplateID != route { return nil, nil, &inputError{"request.artifactApprovals.payload.scriptTemplateId must match request.route"} } - if err := validateHexString( + if err := validateBytes32HexString( "request.artifactApprovals.payload.destinationCommitmentHash", request.ArtifactApprovals.Payload.DestinationCommitmentHash, ); err != nil { return nil, nil, err } - if err := validateHexString( + if err := validateBytes32HexString( "request.artifactApprovals.payload.planCommitmentHash", request.ArtifactApprovals.Payload.PlanCommitmentHash, ); err != nil { @@ -488,6 +659,71 @@ func normalizeArtifactApprovals( return normalizedApprovals, derivedLegacySignatures, nil } +func validateArtifactApprovalAuthenticity( + request RouteSubmitRequest, + depositorPublicKey string, + custodianPublicKey string, +) error { + payloadDigest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + if err != nil { + return err + } + + depositorKey, err := parseCompressedSecp256k1PublicKey( + "request.scriptTemplate.depositorPublicKey", + depositorPublicKey, + ) + if err != nil { + return err + } + + var custodianKey *btcec.PublicKey + if custodianPublicKey != "" { + custodianKey, err = parseCompressedSecp256k1PublicKey( + "request.scriptTemplate.custodianPublicKey", + custodianPublicKey, + ) + if err != nil { + return err + } + } + + for i, approval := range request.ArtifactApprovals.Approvals { + signaturePath := fmt.Sprintf( + "request.artifactApprovals.approvals[%d].signature", + i, + ) + + switch approval.Role { + case ArtifactApprovalRoleDepositor: + if err := verifySecp256k1Signature( + signaturePath, + depositorKey, + payloadDigest, + approval.Signature, + ); err != nil { + return err + } + case ArtifactApprovalRoleCustodian: + if custodianKey == nil { + return &inputError{ + "request.artifactApprovals.approvals includes unexpected custodian role", + } + } + if err := verifySecp256k1Signature( + signaturePath, + custodianKey, + payloadDigest, + approval.Signature, + ); err != nil { + return err + } + } + } + + return nil +} + func normalizeArtifactRecord(record ArtifactRecord) ArtifactRecord { normalized := ArtifactRecord{ PSBTHash: normalizeLowerHex(record.PSBTHash), @@ -664,6 +900,9 @@ func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { if err := validateMigrationTransactionPlan(request, request.MigrationTransactionPlan); err != nil { return err } + if request.ArtifactApprovals == nil { + return &inputError{"request.artifactApprovals is required"} + } if err := validateArtifactApprovals(route, request); err != nil { return err } @@ -686,6 +925,13 @@ func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { if err := validateHexString("request.scriptTemplate.signerPublicKey", template.SignerPublicKey); err != nil { return err } + if err := validateArtifactApprovalAuthenticity( + request, + template.DepositorPublicKey, + "", + ); err != nil { + return err + } case TemplateQcV1: if !request.Signing.SignerRequired || !request.Signing.CustodianRequired { return &inputError{"request.signing must set signerRequired=true and custodianRequired=true for qc_v1"} @@ -706,6 +952,13 @@ func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { if err := validateHexString("request.scriptTemplate.signerPublicKey", template.SignerPublicKey); err != nil { return err } + if err := validateArtifactApprovalAuthenticity( + request, + template.DepositorPublicKey, + template.CustodianPublicKey, + ); err != nil { + return err + } default: return &inputError{"unsupported request.route"} } diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index dad1a7a62e..c65381c7a1 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -5,6 +5,7 @@ import ( "context" "crypto/ecdsa" "crypto/sha256" + "encoding/binary" "encoding/hex" "encoding/json" "math" @@ -14,6 +15,7 @@ import ( "github.com/btcsuite/btcd/btcec" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/ethereum/go-ethereum/crypto" "github.com/keep-network/keep-common/pkg/persistence" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" @@ -196,6 +198,7 @@ func TestCovenantSignerEngine_SubmitSelfV1Ready(t *testing.T) { }, } applyTestMigrationTransactionPlanCommitment(t, &request) + applyTestArtifactApprovals(t, &request, depositorPrivateKey, nil) result, err := service.Submit(context.Background(), covenantsigner.TemplateSelfV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_self_ready", @@ -425,6 +428,7 @@ func TestCovenantSignerEngine_SubmitQcV1HandoffReady(t *testing.T) { }, } applyTestMigrationTransactionPlanCommitment(t, &request) + applyTestArtifactApprovals(t, &request, depositorPrivateKey, custodianPrivateKey) result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_qc_ready", @@ -664,6 +668,7 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsInvalidBeta(t *testing.T) { request.MigrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, request.MigrationDestination) request.DestinationCommitmentHash = request.MigrationDestination.DestinationCommitmentHash applyTestMigrationTransactionPlanCommitment(t, &request) + applyTestArtifactApprovals(t, &request, depositorPrivateKey, custodianPrivateKey) result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_qc_bad_beta", @@ -800,6 +805,7 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsScriptHashMismatch(t *testing.T) }, } applyTestMigrationTransactionPlanCommitment(t, &request) + applyTestArtifactApprovals(t, &request, depositorPrivateKey, custodianPrivateKey) result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_qc_bad_script_hash", @@ -903,6 +909,7 @@ func TestCovenantSignerEngine_SubmitSelfV1RejectsZeroMaturityHeight(t *testing.T request.MigrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, request.MigrationDestination) request.DestinationCommitmentHash = request.MigrationDestination.DestinationCommitmentHash applyTestMigrationTransactionPlanCommitment(t, &request) + applyTestArtifactApprovals(t, &request, depositorPrivateKey, nil) result, err := service.Submit(context.Background(), covenantsigner.TemplateSelfV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_self_zero", @@ -1118,6 +1125,143 @@ func testMigrationTransactionPlanCommitmentHash( return "0x" + hex.EncodeToString(sum[:]) } +var testArtifactApprovalTypeHash = crypto.Keccak256Hash([]byte( + "ArtifactApproval(" + + "uint8 approvalVersion," + + "bytes32 route," + + "bytes32 scriptTemplateId," + + "bytes32 destinationCommitmentHash," + + "bytes32 planCommitmentHash)", +)) + +func testArtifactApprovalDigest( + t *testing.T, + payload covenantsigner.ArtifactApprovalPayload, +) []byte { + t.Helper() + + decodeBytes32 := func(name string, value string) [32]byte { + t.Helper() + + rawValue, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + if err != nil { + t.Fatalf("cannot decode %s: %v", name, err) + } + if len(rawValue) != 32 { + t.Fatalf("expected %s to be 32 bytes, got %d", name, len(rawValue)) + } + + var decoded [32]byte + copy(decoded[:], rawValue) + return decoded + } + + encodeUint32 := func(value uint32) [32]byte { + var encoded [32]byte + binary.BigEndian.PutUint32(encoded[28:], value) + return encoded + } + + keccakTemplateIdentifier := func(value covenantsigner.TemplateID) [32]byte { + hash := crypto.Keccak256Hash([]byte(value)) + var encoded [32]byte + copy(encoded[:], hash.Bytes()) + return encoded + } + + destinationCommitmentHash := decodeBytes32( + "destinationCommitmentHash", + payload.DestinationCommitmentHash, + ) + planCommitmentHash := decodeBytes32( + "planCommitmentHash", + payload.PlanCommitmentHash, + ) + approvalVersionWord := encodeUint32(payload.ApprovalVersion) + routeIdentifier := keccakTemplateIdentifier(payload.Route) + scriptTemplateIdentifier := keccakTemplateIdentifier(payload.ScriptTemplateID) + + encoded := make([]byte, 32*6) + copy(encoded[0:32], testArtifactApprovalTypeHash.Bytes()) + copy(encoded[32:64], approvalVersionWord[:]) + copy(encoded[64:96], routeIdentifier[:]) + copy(encoded[96:128], scriptTemplateIdentifier[:]) + copy(encoded[128:160], destinationCommitmentHash[:]) + copy(encoded[160:192], planCommitmentHash[:]) + + digest := crypto.Keccak256Hash(encoded) + return digest.Bytes() +} + +func testSignArtifactApproval( + t *testing.T, + privateKey *btcec.PrivateKey, + payload covenantsigner.ArtifactApprovalPayload, +) string { + t.Helper() + + signature, err := privateKey.Sign(testArtifactApprovalDigest(t, payload)) + if err != nil { + t.Fatal(err) + } + + return "0x" + hex.EncodeToString(signature.Serialize()) +} + +func applyTestArtifactApprovals( + t *testing.T, + request *covenantsigner.RouteSubmitRequest, + depositorPrivateKey *btcec.PrivateKey, + custodianPrivateKey *btcec.PrivateKey, +) { + t.Helper() + + payload := covenantsigner.ArtifactApprovalPayload{ + ApprovalVersion: 1, + Route: request.Route, + ScriptTemplateID: request.Route, + DestinationCommitmentHash: request.DestinationCommitmentHash, + PlanCommitmentHash: request.MigrationTransactionPlan.PlanCommitmentHash, + } + + approvals := []covenantsigner.ArtifactRoleApproval{ + { + Role: covenantsigner.ArtifactApprovalRoleDepositor, + Signature: testSignArtifactApproval(t, depositorPrivateKey, payload), + }, + { + Role: covenantsigner.ArtifactApprovalRoleSigner, + Signature: "0x5151", + }, + } + + if request.Route == covenantsigner.TemplateQcV1 { + approvals = []covenantsigner.ArtifactRoleApproval{ + { + Role: covenantsigner.ArtifactApprovalRoleDepositor, + Signature: testSignArtifactApproval(t, depositorPrivateKey, payload), + }, + { + Role: covenantsigner.ArtifactApprovalRoleCustodian, + Signature: testSignArtifactApproval(t, custodianPrivateKey, payload), + }, + { + Role: covenantsigner.ArtifactApprovalRoleSigner, + Signature: "0x5151", + }, + } + } + + request.ArtifactApprovals = &covenantsigner.ArtifactApprovalEnvelope{ + Payload: payload, + Approvals: approvals, + } + request.ArtifactSignatures = make([]string, len(approvals)) + for i, approval := range approvals { + request.ArtifactSignatures[i] = approval.Signature + } +} + func applyTestMigrationTransactionPlanCommitment( t *testing.T, request *covenantsigner.RouteSubmitRequest, From fed4e8c73f03052a702c801e5890cbef1a275b6a Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 14:35:11 -0500 Subject: [PATCH 024/143] Clarify signer approval deferral and canonicalize commitment JSON --- pkg/covenantsigner/covenantsigner_test.go | 36 +++++++++++++++++++++++ pkg/covenantsigner/validation.go | 10 +++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 287a3afd67..c9c0188971 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -1831,6 +1831,42 @@ func TestRequestDigestDoesNotEscapeHTMLSensitiveCharacters(t *testing.T) { } } +func TestDestinationCommitmentHashDoesNotEscapeHTMLSensitiveCharacters(t *testing.T) { + destination := validMigrationDestination() + destination.Network = "regtest&sink" + + payload, err := marshalCanonicalJSON(destinationCommitmentPayload{ + Reserve: normalizeLowerHex(destination.Reserve), + Epoch: destination.Epoch, + Route: string(destination.Route), + Revealer: normalizeLowerHex(destination.Revealer), + Vault: normalizeLowerHex(destination.Vault), + Network: strings.TrimSpace(destination.Network), + DepositScriptHash: normalizeLowerHex(destination.DepositScriptHash), + MigrationExtraData: normalizeLowerHex(destination.MigrationExtraData), + }) + if err != nil { + t.Fatal(err) + } + + if !bytes.Contains(payload, []byte(`"network":"regtest&sink"`)) { + t.Fatalf("expected raw HTML-sensitive characters in payload, got %s", payload) + } + if bytes.Contains(payload, []byte(`\u003c`)) || + bytes.Contains(payload, []byte(`\u003e`)) || + bytes.Contains(payload, []byte(`\u0026`)) { + t.Fatalf("expected unescaped HTML-sensitive characters in payload, got %s", payload) + } + + hash, err := computeDestinationCommitmentHash(destination) + if err != nil { + t.Fatal(err) + } + if hash == "" { + t.Fatal("expected destination commitment hash") + } +} + func TestServicePollAcceptsEquivalentArtifactApprovalRequestVariants(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 94552b73e9..0b154a11d6 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -312,7 +312,7 @@ type migrationPlanCommitmentPayload struct { func computeDestinationCommitmentHash( reservation *MigrationDestinationReservation, ) (string, error) { - payload, err := json.Marshal(destinationCommitmentPayload{ + payload, err := marshalCanonicalJSON(destinationCommitmentPayload{ Reserve: normalizeLowerHex(reservation.Reserve), Epoch: reservation.Epoch, Route: string(reservation.Route), @@ -334,7 +334,7 @@ func computeMigrationTransactionPlanCommitmentHash( request RouteSubmitRequest, plan *MigrationTransactionPlan, ) (string, error) { - payload, err := json.Marshal(migrationPlanCommitmentPayload{ + payload, err := marshalCanonicalJSON(migrationPlanCommitmentPayload{ PlanVersion: plan.PlanVersion, Reserve: normalizeLowerHex(request.Reserve), Epoch: request.Epoch, @@ -718,6 +718,12 @@ func validateArtifactApprovalAuthenticity( ); err != nil { return err } + case ArtifactApprovalRoleSigner: + // Phase 1 keeps S structurally required but not cryptographically + // verified. Signer approval must eventually bind to quorum or + // signer-service trust roots rather than the single signer key in the + // script template. + continue } } From bb086a9d971594d75880ebf90e2e9bf7ade9a97f Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 19:52:16 -0500 Subject: [PATCH 025/143] Verify migration plan quotes in covenant signer --- pkg/covenantsigner/config.go | 4 + pkg/covenantsigner/covenantsigner_test.go | 260 ++++++++++++- pkg/covenantsigner/server.go | 6 +- pkg/covenantsigner/service.go | 65 +++- pkg/covenantsigner/types.go | 35 ++ pkg/covenantsigner/validation.go | 429 +++++++++++++++++++++- 6 files changed, 769 insertions(+), 30 deletions(-) diff --git a/pkg/covenantsigner/config.go b/pkg/covenantsigner/config.go index 07772e75f1..b02e51f178 100644 --- a/pkg/covenantsigner/config.go +++ b/pkg/covenantsigner/config.go @@ -15,4 +15,8 @@ type Config struct { // EnableSelfV1 exposes the self_v1 signer HTTP routes. Keep this disabled // for a qc_v1-first launch unless self_v1 has cleared its own go-live gate. EnableSelfV1 bool + // MigrationPlanQuoteTrustRoots configures the destination-service plan-quote + // trust roots used to verify migration plan quotes when the quote authority + // path is enabled. + MigrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot `mapstructure:"migrationPlanQuoteTrustRoots"` } diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index c9c0188971..085fe79d9c 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -3,8 +3,11 @@ package covenantsigner import ( "bytes" "context" + "crypto/ed25519" + "crypto/x509" "encoding/hex" "encoding/json" + "encoding/pem" "fmt" "io" "net" @@ -150,12 +153,18 @@ const ( ) var ( - testDepositorPrivateKey = mustDeterministicTestPrivateKey(testDepositorPrivateKeyHex) - testSignerPrivateKey = mustDeterministicTestPrivateKey(testSignerPrivateKeyHex) - testCustodianPrivateKey = mustDeterministicTestPrivateKey(testCustodianPrivateKeyHex) - testDepositorPublicKey = mustCompressedPublicKeyHex(testDepositorPrivateKey) - testSignerPublicKey = mustCompressedPublicKeyHex(testSignerPrivateKey) - testCustodianPublicKey = mustCompressedPublicKeyHex(testCustodianPrivateKey) + testDepositorPrivateKey = mustDeterministicTestPrivateKey(testDepositorPrivateKeyHex) + testSignerPrivateKey = mustDeterministicTestPrivateKey(testSignerPrivateKeyHex) + testCustodianPrivateKey = mustDeterministicTestPrivateKey(testCustodianPrivateKeyHex) + testDepositorPublicKey = mustCompressedPublicKeyHex(testDepositorPrivateKey) + testSignerPublicKey = mustCompressedPublicKeyHex(testSignerPrivateKey) + testCustodianPublicKey = mustCompressedPublicKeyHex(testCustodianPrivateKey) + testMigrationPlanQuoteSeed = bytes.Repeat([]byte{0x44}, ed25519.SeedSize) + testMigrationPlanQuotePrivateKey = ed25519.NewKeyFromSeed(testMigrationPlanQuoteSeed) + testMigrationPlanQuoteTrustRoot = MigrationPlanQuoteTrustRoot{ + KeyID: "test-plan-quote-key", + PublicKeyPEM: mustMigrationPlanQuoteTrustRootPEM(testMigrationPlanQuotePrivateKey.Public().(ed25519.PublicKey)), + } ) func mustDeterministicTestPrivateKey(encoded string) *btcec.PrivateKey { @@ -172,6 +181,18 @@ func mustCompressedPublicKeyHex(privateKey *btcec.PrivateKey) string { return "0x" + hex.EncodeToString(privateKey.PubKey().SerializeCompressed()) } +func mustMigrationPlanQuoteTrustRootPEM(publicKey ed25519.PublicKey) string { + encodedPublicKey, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + panic(err) + } + + return string(pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: encodedPublicKey, + })) +} + func mustArtifactApprovalSignature( privateKey *btcec.PrivateKey, payload ArtifactApprovalPayload, @@ -684,6 +705,65 @@ func validMigrationDestination() *MigrationDestinationReservation { return reservation } +func validMigrationPlanQuote( + request RouteSubmitRequest, +) *MigrationDestinationPlanQuote { + quote := &MigrationDestinationPlanQuote{ + QuoteID: "cmdq_12345678", + QuoteVersion: migrationPlanQuoteVersion, + ReservationID: request.MigrationDestination.ReservationID, + Reserve: request.Reserve, + Epoch: request.Epoch, + Route: ReservationRouteMigration, + Revealer: request.MigrationDestination.Revealer, + Vault: request.MigrationDestination.Vault, + Network: request.MigrationDestination.Network, + DestinationCommitmentHash: request.DestinationCommitmentHash, + ActiveOutpointTxID: request.ActiveOutpoint.TxID, + ActiveOutpointVout: request.ActiveOutpoint.Vout, + PlanCommitmentHash: request.MigrationTransactionPlan.PlanCommitmentHash, + MigrationTransactionPlan: normalizeMigrationTransactionPlan(request.MigrationTransactionPlan), + IdempotencyKey: "0x75a998ac6951c2776f3a85f6430fb41321c28c1113a71a52c754806c7a3de9c9", + ExpiresInSeconds: 900, + IssuedAt: "2099-03-09T00:00:00.000Z", + ExpiresAt: "2099-03-09T00:15:00.000Z", + Signature: MigrationDestinationPlanQuoteSignature{ + SignatureVersion: migrationPlanQuoteSignatureVersion, + Algorithm: migrationPlanQuoteSignatureAlgorithm, + KeyID: testMigrationPlanQuoteTrustRoot.KeyID, + }, + } + + signingHash, err := migrationPlanQuoteSigningHash(quote) + if err != nil { + panic(err) + } + quote.Signature.Signature = "0x" + hex.EncodeToString( + ed25519.Sign(testMigrationPlanQuotePrivateKey, signingHash), + ) + + return quote +} + +func requestWithValidMigrationPlanQuote(route TemplateID) RouteSubmitRequest { + request := baseRequest(route) + request.ActiveOutpoint.TxID = "0x" + strings.Repeat("aa", 32) + request.ActiveOutpoint.ScriptHash = "0x" + strings.Repeat("bb", 32) + request.MigrationTransactionPlan.PlanCommitmentHash, _ = + computeMigrationTransactionPlanCommitmentHash( + request, + request.MigrationTransactionPlan, + ) + request.ArtifactApprovals = validArtifactApprovals(request) + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) + request.MigrationPlanQuote = validMigrationPlanQuote(request) + + return request +} + func TestServiceSubmitDeduplicatesByRouteRequestID(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ @@ -1867,6 +1947,174 @@ func TestDestinationCommitmentHashDoesNotEscapeHTMLSensitiveCharacters(t *testin } } +func TestMigrationPlanQuoteSigningHashMatchesTbtcVectors(t *testing.T) { + baseRequest := canonicalArtifactApprovalRequest(TemplateSelfV1) + baseRequest.MigrationPlanQuote = &MigrationDestinationPlanQuote{ + QuoteID: "cmdq_testvector", + QuoteVersion: migrationPlanQuoteVersion, + ReservationID: "cmdr_testvector", + Reserve: "0x1111111111111111111111111111111111111111", + Epoch: 7, + Route: ReservationRouteMigration, + Revealer: "0x2222222222222222222222222222222222222222", + Vault: "0x3333333333333333333333333333333333333333", + Network: "regtest", + DestinationCommitmentHash: "0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7", + ActiveOutpointTxID: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ActiveOutpointVout: 1, + PlanCommitmentHash: "0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09", + MigrationTransactionPlan: &MigrationTransactionPlan{ + PlanVersion: migrationTransactionPlanVersion, + PlanCommitmentHash: "0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09", + InputValueSats: 100000, + DestinationValueSats: 99250, + AnchorValueSats: canonicalAnchorValueSats, + FeeSats: 420, + InputSequence: canonicalCovenantInputSequence, + LockTime: 950000, + }, + IdempotencyKey: "0x75a998ac6951c2776f3a85f6430fb41321c28c1113a71a52c754806c7a3de9c9", + ExpiresInSeconds: 900, + IssuedAt: "2026-03-09T00:00:00.000Z", + ExpiresAt: "2026-03-09T00:15:00.000Z", + Signature: MigrationDestinationPlanQuoteSignature{ + SignatureVersion: migrationPlanQuoteSignatureVersion, + Algorithm: migrationPlanQuoteSignatureAlgorithm, + KeyID: testMigrationPlanQuoteTrustRoot.KeyID, + Signature: "0x00", + }, + } + + payload, err := migrationPlanQuoteSigningPayloadBytes(baseRequest.MigrationPlanQuote) + if err != nil { + t.Fatal(err) + } + if string(payload) != "{\"quoteVersion\":1,\"quoteId\":\"cmdq_testvector\",\"reservationId\":\"cmdr_testvector\",\"reserve\":\"0x1111111111111111111111111111111111111111\",\"epoch\":7,\"route\":\"MIGRATION\",\"revealer\":\"0x2222222222222222222222222222222222222222\",\"vault\":\"0x3333333333333333333333333333333333333333\",\"network\":\"regtest\",\"destinationCommitmentHash\":\"0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7\",\"activeOutpointTxid\":\"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"activeOutpointVout\":1,\"planCommitmentHash\":\"0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09\",\"issuedAt\":\"2026-03-09T00:00:00.000Z\",\"expiresAt\":\"2026-03-09T00:15:00.000Z\",\"expiresInSeconds\":900}" { + t.Fatalf("unexpected signing payload: %s", payload) + } + + signingHash, err := migrationPlanQuoteSigningHash(baseRequest.MigrationPlanQuote) + if err != nil { + t.Fatal(err) + } + if "0x"+hex.EncodeToString(signingHash) != "0x4707935286fa15edf3f95485297307734b122f7dc1761e6fc023e9d5cc7a935a" { + t.Fatalf("unexpected signing hash: 0x%s", hex.EncodeToString(signingHash)) + } + + mixedCaseQuote := *baseRequest.MigrationPlanQuote + mixedCaseQuote.Reserve = "0xAaBbCcDdEeFf00112233445566778899AaBbCcDd" + mixedCaseQuote.Revealer = "0xAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCd" + mixedCaseQuote.Vault = "0x0011AaBbCcDdEeFf0011AaBbCcDdEeFf0011AaBb" + + mixedCaseHash, err := migrationPlanQuoteSigningHash(&mixedCaseQuote) + if err != nil { + t.Fatal(err) + } + if "0x"+hex.EncodeToString(mixedCaseHash) != "0x13a05f7e9caa244c446b65c2812095210cb321451d9eb9b735e60ffdd76e693d" { + t.Fatalf("unexpected mixed-case signing hash: 0x%s", hex.EncodeToString(mixedCaseHash)) + } +} + +func TestServiceRequiresMigrationPlanQuoteWhenTrustRootsConfigured(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithMigrationPlanQuoteTrustRoots([]MigrationPlanQuoteTrustRoot{ + testMigrationPlanQuoteTrustRoot, + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_quote_required", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err == nil || !strings.Contains( + err.Error(), + "request.migrationPlanQuote is required when migrationPlanQuoteTrustRoots are configured", + ) { + t.Fatalf("expected missing quote error, got %v", err) + } +} + +func TestServiceAcceptsValidMigrationPlanQuoteWhenTrustRootsConfigured(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithMigrationPlanQuoteTrustRoots([]MigrationPlanQuoteTrustRoot{ + testMigrationPlanQuoteTrustRoot, + }), + ) + if err != nil { + t.Fatal(err) + } + + request := requestWithValidMigrationPlanQuote(TemplateSelfV1) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_quote_valid", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestServicePollAcceptsStoredMigrationPlanQuoteAfterQuoteExpiry(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }, + WithMigrationPlanQuoteTrustRoots([]MigrationPlanQuoteTrustRoot{ + testMigrationPlanQuoteTrustRoot, + }), + ) + if err != nil { + t.Fatal(err) + } + service.now = func() time.Time { + return time.Date(2099, time.March, 9, 0, 10, 0, 0, time.UTC) + } + + request := requestWithValidMigrationPlanQuote(TemplateSelfV1) + + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_quote_poll", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + service.now = func() time.Time { + return time.Date(2099, time.March, 9, 0, 16, 0, 0, time.UTC) + } + + pollResult, err := service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "ors_quote_poll", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + if pollResult.Status != StepStatusPending { + t.Fatalf("expected pending poll result, got %#v", pollResult) + } +} + func TestServicePollAcceptsEquivalentArtifactApprovalRequestVariants(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 931eff908d..bd1bf8034a 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -48,7 +48,11 @@ func Initialize( ) } - service, err := NewService(handle, engine) + service, err := NewService( + handle, + engine, + WithMigrationPlanQuoteTrustRoots(config.MigrationPlanQuoteTrustRoots), + ) if err != nil { return nil, false, err } diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index f1a18cef0b..a50ca0c838 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -13,13 +13,30 @@ import ( ) type Service struct { - store *Store - engine Engine - now func() time.Time - mutex sync.Mutex + store *Store + engine Engine + now func() time.Time + mutex sync.Mutex + migrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot } -func NewService(handle persistence.BasicHandle, engine Engine) (*Service, error) { +type ServiceOption func(*Service) + +func WithMigrationPlanQuoteTrustRoots( + trustRoots []MigrationPlanQuoteTrustRoot, +) ServiceOption { + cloned := append([]MigrationPlanQuoteTrustRoot{}, trustRoots...) + + return func(service *Service) { + service.migrationPlanQuoteTrustRoots = cloned + } +} + +func NewService( + handle persistence.BasicHandle, + engine Engine, + options ...ServiceOption, +) (*Service, error) { if engine == nil { engine = NewPassiveEngine() } @@ -29,11 +46,16 @@ func NewService(handle persistence.BasicHandle, engine Engine) (*Service, error) return nil, err } - return &Service{ + service := &Service{ store: store, engine: engine, now: func() time.Time { return time.Now().UTC() }, - }, nil + } + for _, option := range options { + option(service) + } + + return service, nil } func newRequestID(prefix string) (string, error) { @@ -131,7 +153,12 @@ func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, er return nil, &inputError{"routeRequestId does not match stored job"} } - digest, err := requestDigest(input.Request) + digest, err := requestDigest( + input.Request, + validationOptions{ + migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + }, + ) if err != nil { return nil, err } @@ -143,11 +170,21 @@ func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, er } func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubmitInput) (StepResult, error) { - if err := validateSubmitInput(route, input); err != nil { + submitValidationOptions := validationOptions{ + migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + requireFreshMigrationPlanQuote: true, + migrationPlanQuoteVerificationNow: s.now(), + } + if err := validateSubmitInput(route, input, submitValidationOptions); err != nil { return StepResult{}, err } - normalizedRequest, err := normalizeRouteSubmitRequest(input.Request) + normalizedRequest, err := normalizeRouteSubmitRequest( + input.Request, + validationOptions{ + migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + }, + ) if err != nil { return StepResult{}, err } @@ -240,7 +277,13 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm } func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollInput) (StepResult, error) { - if err := validatePollInput(route, input); err != nil { + if err := validatePollInput( + route, + input, + validationOptions{ + migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + }, + ); err != nil { return StepResult{}, err } diff --git a/pkg/covenantsigner/types.go b/pkg/covenantsigner/types.go index 3cba41c6ba..3eff35a893 100644 --- a/pkg/covenantsigner/types.go +++ b/pkg/covenantsigner/types.go @@ -112,6 +112,40 @@ type MigrationTransactionPlan struct { LockTime uint32 `json:"lockTime"` } +type MigrationDestinationPlanQuoteSignature struct { + SignatureVersion uint32 `json:"signatureVersion"` + Algorithm string `json:"algorithm"` + KeyID string `json:"keyId"` + Signature string `json:"signature"` +} + +type MigrationDestinationPlanQuote struct { + QuoteID string `json:"quoteId"` + QuoteVersion uint32 `json:"quoteVersion"` + ReservationID string `json:"reservationId"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route ReservationRoute `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + ActiveOutpointTxID string `json:"activeOutpointTxid"` + ActiveOutpointVout uint32 `json:"activeOutpointVout"` + PlanCommitmentHash string `json:"planCommitmentHash"` + MigrationTransactionPlan *MigrationTransactionPlan `json:"migrationTransactionPlan"` + IdempotencyKey string `json:"idempotencyKey"` + ExpiresInSeconds uint64 `json:"expiresInSeconds"` + IssuedAt string `json:"issuedAt"` + ExpiresAt string `json:"expiresAt"` + Signature MigrationDestinationPlanQuoteSignature `json:"signature"` +} + +type MigrationPlanQuoteTrustRoot struct { + KeyID string `json:"keyId" mapstructure:"keyId"` + PublicKeyPEM string `json:"publicKeyPem" mapstructure:"publicKeyPem"` +} + type ArtifactApprovalRole string const ( @@ -154,6 +188,7 @@ type RouteSubmitRequest struct { ActiveOutpoint CovenantOutpoint `json:"activeOutpoint"` DestinationCommitmentHash string `json:"destinationCommitmentHash"` MigrationDestination *MigrationDestinationReservation `json:"migrationDestination,omitempty"` + MigrationPlanQuote *MigrationDestinationPlanQuote `json:"migrationPlanQuote,omitempty"` MigrationTransactionPlan *MigrationTransactionPlan `json:"migrationTransactionPlan,omitempty"` ArtifactApprovals *ArtifactApprovalEnvelope `json:"artifactApprovals,omitempty"` ArtifactSignatures []string `json:"artifactSignatures"` diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 0b154a11d6..dcbcbc6201 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -3,24 +3,37 @@ package covenantsigner import ( "bytes" "crypto/ecdsa" + "crypto/ed25519" "crypto/sha256" + "crypto/x509" "encoding/binary" "encoding/hex" "encoding/json" + "encoding/pem" "fmt" "math" "math/big" + "reflect" + "regexp" "strings" + "time" "github.com/btcsuite/btcd/btcec" "github.com/ethereum/go-ethereum/crypto" ) const ( - canonicalCovenantInputSequence uint32 = 0xFFFFFFFD - canonicalAnchorValueSats uint64 = 330 - migrationTransactionPlanVersion uint32 = 1 - artifactApprovalVersion uint32 = 1 + canonicalCovenantInputSequence uint32 = 0xFFFFFFFD + canonicalAnchorValueSats uint64 = 330 + migrationTransactionPlanVersion uint32 = 1 + artifactApprovalVersion uint32 = 1 + migrationPlanQuoteVersion uint32 = 1 + migrationPlanQuoteSignatureVersion uint32 = 1 +) + +const ( + migrationPlanQuoteSignatureAlgorithm = "ed25519" + migrationPlanQuoteSigningDomain = "migration-plan-quote-v1:" ) var artifactApprovalTypeHash = crypto.Keccak256Hash([]byte( @@ -32,6 +45,10 @@ var artifactApprovalTypeHash = crypto.Keccak256Hash([]byte( "bytes32 planCommitmentHash)", )) +var canonicalTimestampPattern = regexp.MustCompile( + `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$`, +) + type inputError struct { message string } @@ -58,11 +75,31 @@ func marshalCanonicalJSON(value any) ([]byte, error) { return bytes.TrimSuffix(buffer.Bytes(), []byte("\n")), nil } +type validationOptions struct { + migrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot + requireFreshMigrationPlanQuote bool + migrationPlanQuoteVerificationNow time.Time +} + +func resolveValidationOptions(options []validationOptions) validationOptions { + if len(options) == 0 { + return validationOptions{} + } + + return options[0] +} + // requestDigest accepts raw requests because Poll validates equivalence against // whatever the caller resubmits. Submit should use requestDigestFromNormalized // after it has already normalized the request once for storage. -func requestDigest(request RouteSubmitRequest) (string, error) { - normalizedRequest, err := normalizeRouteSubmitRequest(request) +func requestDigest( + request RouteSubmitRequest, + options ...validationOptions, +) (string, error) { + normalizedRequest, err := normalizeRouteSubmitRequest( + request, + resolveValidationOptions(options), + ) if err != nil { return "", err } @@ -264,6 +301,345 @@ func verifySecp256k1Signature( return &inputError{fmt.Sprintf("%s does not verify against the required public key", name)} } +type migrationPlanQuoteSigningPayload struct { + QuoteVersion uint32 `json:"quoteVersion"` + QuoteID string `json:"quoteId"` + ReservationID string `json:"reservationId"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route string `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + ActiveOutpointTxID string `json:"activeOutpointTxid"` + ActiveOutpointVout uint32 `json:"activeOutpointVout"` + PlanCommitmentHash string `json:"planCommitmentHash"` + IssuedAt string `json:"issuedAt"` + ExpiresAt string `json:"expiresAt"` + ExpiresInSeconds uint64 `json:"expiresInSeconds"` +} + +func normalizeCanonicalTimestamp(name string, value string) (string, error) { + if !canonicalTimestampPattern.MatchString(value) { + return "", &inputError{ + fmt.Sprintf( + "%s must be a UTC ISO-8601 timestamp from Date.toISOString()", + name, + ), + } + } + + return value, nil +} + +func normalizeMigrationPlanQuotePublicKeyPEM(value string) string { + return strings.TrimSpace(strings.ReplaceAll(value, "\\n", "\n")) +} + +func parseMigrationPlanQuoteTrustRoot( + name string, + trustRoot MigrationPlanQuoteTrustRoot, +) (ed25519.PublicKey, error) { + block, _ := pem.Decode([]byte(normalizeMigrationPlanQuotePublicKeyPEM(trustRoot.PublicKeyPEM))) + if block == nil { + return nil, &inputError{fmt.Sprintf("%s.publicKeyPem must be a PEM-encoded public key", name)} + } + + publicKeyValue, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, &inputError{fmt.Sprintf("%s.publicKeyPem must be a PEM-encoded Ed25519 public key", name)} + } + + publicKey, ok := publicKeyValue.(ed25519.PublicKey) + if !ok { + return nil, &inputError{fmt.Sprintf("%s.publicKeyPem must be a PEM-encoded Ed25519 public key", name)} + } + + return publicKey, nil +} + +func migrationPlanQuoteSigningPayloadBytes( + quote *MigrationDestinationPlanQuote, +) ([]byte, error) { + return marshalCanonicalJSON(migrationPlanQuoteSigningPayload{ + QuoteVersion: quote.QuoteVersion, + QuoteID: quote.QuoteID, + ReservationID: quote.ReservationID, + Reserve: normalizeLowerHex(quote.Reserve), + Epoch: quote.Epoch, + Route: string(quote.Route), + Revealer: normalizeLowerHex(quote.Revealer), + Vault: normalizeLowerHex(quote.Vault), + Network: quote.Network, + DestinationCommitmentHash: normalizeLowerHex(quote.DestinationCommitmentHash), + ActiveOutpointTxID: normalizeLowerHex(quote.ActiveOutpointTxID), + ActiveOutpointVout: quote.ActiveOutpointVout, + PlanCommitmentHash: normalizeLowerHex(quote.PlanCommitmentHash), + IssuedAt: quote.IssuedAt, + ExpiresAt: quote.ExpiresAt, + ExpiresInSeconds: quote.ExpiresInSeconds, + }) +} + +func migrationPlanQuoteSigningPreimage( + quote *MigrationDestinationPlanQuote, +) ([]byte, error) { + payload, err := migrationPlanQuoteSigningPayloadBytes(quote) + if err != nil { + return nil, err + } + + return []byte(migrationPlanQuoteSigningDomain + string(payload)), nil +} + +func migrationPlanQuoteSigningHash( + quote *MigrationDestinationPlanQuote, +) ([]byte, error) { + preimage, err := migrationPlanQuoteSigningPreimage(quote) + if err != nil { + return nil, err + } + + sum := sha256.Sum256(preimage) + return sum[:], nil +} + +func normalizeMigrationPlanQuote( + request RouteSubmitRequest, + options validationOptions, +) (*MigrationDestinationPlanQuote, error) { + quote := request.MigrationPlanQuote + if quote == nil { + if len(options.migrationPlanQuoteTrustRoots) > 0 { + return nil, &inputError{ + "request.migrationPlanQuote is required when migrationPlanQuoteTrustRoots are configured", + } + } + + return nil, nil + } + if len(options.migrationPlanQuoteTrustRoots) == 0 { + return nil, &inputError{"request.migrationPlanQuote verification requires configured trust roots"} + } + if request.MigrationDestination == nil { + return nil, &inputError{"request.migrationDestination is required when request.migrationPlanQuote is present"} + } + if request.MigrationTransactionPlan == nil { + return nil, &inputError{"request.migrationTransactionPlan is required when request.migrationPlanQuote is present"} + } + if quote.QuoteVersion != migrationPlanQuoteVersion { + return nil, &inputError{"request.migrationPlanQuote.quoteVersion must equal 1"} + } + if strings.TrimSpace(quote.QuoteID) == "" { + return nil, &inputError{"request.migrationPlanQuote.quoteId is required"} + } + if strings.TrimSpace(quote.ReservationID) == "" { + return nil, &inputError{"request.migrationPlanQuote.reservationId is required"} + } + if strings.TrimSpace(quote.IdempotencyKey) == "" { + return nil, &inputError{"request.migrationPlanQuote.idempotencyKey is required"} + } + if quote.Route != ReservationRouteMigration { + return nil, &inputError{"request.migrationPlanQuote.route must be MIGRATION"} + } + if err := validateAddressString("request.migrationPlanQuote.reserve", quote.Reserve); err != nil { + return nil, err + } + if err := validateAddressString("request.migrationPlanQuote.revealer", quote.Revealer); err != nil { + return nil, err + } + if err := validateAddressString("request.migrationPlanQuote.vault", quote.Vault); err != nil { + return nil, err + } + if strings.TrimSpace(quote.Network) == "" { + return nil, &inputError{"request.migrationPlanQuote.network is required"} + } + if err := validateBytes32HexString( + "request.migrationPlanQuote.destinationCommitmentHash", + quote.DestinationCommitmentHash, + ); err != nil { + return nil, err + } + if err := validateBytes32HexString( + "request.migrationPlanQuote.activeOutpointTxid", + quote.ActiveOutpointTxID, + ); err != nil { + return nil, err + } + if err := validateBytes32HexString( + "request.migrationPlanQuote.planCommitmentHash", + quote.PlanCommitmentHash, + ); err != nil { + return nil, err + } + if quote.ExpiresInSeconds == 0 { + return nil, &inputError{"request.migrationPlanQuote.expiresInSeconds must be greater than zero"} + } + if quote.Signature.SignatureVersion != migrationPlanQuoteSignatureVersion { + return nil, &inputError{"request.migrationPlanQuote.signature.signatureVersion must equal 1"} + } + if quote.Signature.Algorithm != migrationPlanQuoteSignatureAlgorithm { + return nil, &inputError{"request.migrationPlanQuote.signature.algorithm must equal ed25519"} + } + if strings.TrimSpace(quote.Signature.KeyID) == "" { + return nil, &inputError{"request.migrationPlanQuote.signature.keyId is required"} + } + if err := validateHexString("request.migrationPlanQuote.signature.signature", quote.Signature.Signature); err != nil { + return nil, err + } + + normalizedIssuedAt, err := normalizeCanonicalTimestamp( + "request.migrationPlanQuote.issuedAt", + quote.IssuedAt, + ) + if err != nil { + return nil, err + } + issuedAt, err := time.Parse(time.RFC3339Nano, normalizedIssuedAt) + if err != nil { + return nil, &inputError{ + "request.migrationPlanQuote.issuedAt must be a parseable UTC ISO-8601 timestamp", + } + } + normalizedExpiresAt, err := normalizeCanonicalTimestamp( + "request.migrationPlanQuote.expiresAt", + quote.ExpiresAt, + ) + if err != nil { + return nil, err + } + expiresAt, err := time.Parse(time.RFC3339Nano, normalizedExpiresAt) + if err != nil { + return nil, &inputError{ + "request.migrationPlanQuote.expiresAt must be a parseable UTC ISO-8601 timestamp", + } + } + if !expiresAt.After(issuedAt) { + return nil, &inputError{"request.migrationPlanQuote.expiresAt must be after request.migrationPlanQuote.issuedAt"} + } + if expiresAt.Sub(issuedAt) != time.Duration(quote.ExpiresInSeconds)*time.Second { + return nil, &inputError{"request.migrationPlanQuote.expiresAt must equal request.migrationPlanQuote.issuedAt + expiresInSeconds"} + } + if quote.Epoch != request.Epoch { + return nil, &inputError{"request.migrationPlanQuote.epoch must match request.epoch"} + } + if normalizeLowerHex(quote.Reserve) != normalizeLowerHex(request.Reserve) { + return nil, &inputError{"request.migrationPlanQuote.reserve must match request.reserve"} + } + if quote.ReservationID != request.MigrationDestination.ReservationID { + return nil, &inputError{"request.migrationPlanQuote.reservationId must match request.migrationDestination.reservationId"} + } + if normalizeLowerHex(quote.Revealer) != normalizeLowerHex(request.MigrationDestination.Revealer) { + return nil, &inputError{"request.migrationPlanQuote.revealer must match request.migrationDestination.revealer"} + } + if normalizeLowerHex(quote.Vault) != normalizeLowerHex(request.MigrationDestination.Vault) { + return nil, &inputError{"request.migrationPlanQuote.vault must match request.migrationDestination.vault"} + } + if strings.TrimSpace(quote.Network) != strings.TrimSpace(request.MigrationDestination.Network) { + return nil, &inputError{"request.migrationPlanQuote.network must match request.migrationDestination.network"} + } + if normalizeLowerHex(quote.DestinationCommitmentHash) != normalizeLowerHex(request.DestinationCommitmentHash) { + return nil, &inputError{"request.migrationPlanQuote.destinationCommitmentHash must match request.destinationCommitmentHash"} + } + if normalizeLowerHex(quote.DestinationCommitmentHash) != normalizeLowerHex(request.MigrationDestination.DestinationCommitmentHash) { + return nil, &inputError{"request.migrationPlanQuote.destinationCommitmentHash must match request.migrationDestination.destinationCommitmentHash"} + } + if normalizeLowerHex(quote.ActiveOutpointTxID) != normalizeLowerHex(request.ActiveOutpoint.TxID) { + return nil, &inputError{"request.migrationPlanQuote.activeOutpointTxid must match request.activeOutpoint.txid"} + } + if quote.ActiveOutpointVout != request.ActiveOutpoint.Vout { + return nil, &inputError{"request.migrationPlanQuote.activeOutpointVout must match request.activeOutpoint.vout"} + } + if normalizeLowerHex(quote.PlanCommitmentHash) != normalizeLowerHex(request.MigrationTransactionPlan.PlanCommitmentHash) { + return nil, &inputError{"request.migrationPlanQuote.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash"} + } + + normalizedQuotePlan := normalizeMigrationTransactionPlan(quote.MigrationTransactionPlan) + if normalizedQuotePlan == nil { + return nil, &inputError{"request.migrationPlanQuote.migrationTransactionPlan is required"} + } + if err := validateMigrationTransactionPlan(request, quote.MigrationTransactionPlan); err != nil { + return nil, err + } + if !reflect.DeepEqual(normalizedQuotePlan, normalizeMigrationTransactionPlan(request.MigrationTransactionPlan)) { + return nil, &inputError{"request.migrationPlanQuote.migrationTransactionPlan must match request.migrationTransactionPlan"} + } + + var publicKey ed25519.PublicKey + foundTrustRoot := false + for i, trustRoot := range options.migrationPlanQuoteTrustRoots { + if trustRoot.KeyID != quote.Signature.KeyID { + continue + } + + publicKey, err = parseMigrationPlanQuoteTrustRoot( + fmt.Sprintf("migrationPlanQuoteTrustRoots[%d]", i), + trustRoot, + ) + if err != nil { + return nil, err + } + foundTrustRoot = true + break + } + if !foundTrustRoot { + return nil, &inputError{"request.migrationPlanQuote.signature.keyId does not match a configured trust root"} + } + + normalizedQuote := &MigrationDestinationPlanQuote{ + QuoteID: strings.TrimSpace(quote.QuoteID), + QuoteVersion: migrationPlanQuoteVersion, + ReservationID: strings.TrimSpace(quote.ReservationID), + Reserve: normalizeLowerHex(quote.Reserve), + Epoch: quote.Epoch, + Route: ReservationRouteMigration, + Revealer: normalizeLowerHex(quote.Revealer), + Vault: normalizeLowerHex(quote.Vault), + Network: strings.TrimSpace(quote.Network), + DestinationCommitmentHash: normalizeLowerHex(quote.DestinationCommitmentHash), + ActiveOutpointTxID: normalizeLowerHex(quote.ActiveOutpointTxID), + ActiveOutpointVout: quote.ActiveOutpointVout, + PlanCommitmentHash: normalizeLowerHex(quote.PlanCommitmentHash), + MigrationTransactionPlan: normalizedQuotePlan, + IdempotencyKey: strings.TrimSpace(quote.IdempotencyKey), + ExpiresInSeconds: quote.ExpiresInSeconds, + IssuedAt: normalizedIssuedAt, + ExpiresAt: normalizedExpiresAt, + Signature: MigrationDestinationPlanQuoteSignature{ + SignatureVersion: migrationPlanQuoteSignatureVersion, + Algorithm: migrationPlanQuoteSignatureAlgorithm, + KeyID: strings.TrimSpace(quote.Signature.KeyID), + Signature: normalizeLowerHex(quote.Signature.Signature), + }, + } + + signingHash, err := migrationPlanQuoteSigningHash(normalizedQuote) + if err != nil { + return nil, err + } + + rawSignature, err := hex.DecodeString(strings.TrimPrefix(normalizedQuote.Signature.Signature, "0x")) + if err != nil { + return nil, &inputError{"request.migrationPlanQuote.signature.signature must be valid hex"} + } + if !ed25519.Verify(publicKey, signingHash, rawSignature) { + return nil, &inputError{"request.migrationPlanQuote.signature does not verify against the configured trust root"} + } + + if options.requireFreshMigrationPlanQuote { + verificationNow := options.migrationPlanQuoteVerificationNow + if verificationNow.IsZero() { + verificationNow = time.Now().UTC() + } + if expiresAt.Before(verificationNow) { + return nil, &inputError{"request.migrationPlanQuote is expired"} + } + } + + return normalizedQuote, nil +} + func computeMigrationExtraData(revealer string) string { return "0x" + hex.EncodeToString([]byte("AC_MIGRATEV1")) + strings.TrimPrefix(normalizeLowerHex(revealer), "0x") } @@ -824,7 +1200,11 @@ func normalizeScriptTemplate(route TemplateID, rawTemplate json.RawMessage) (jso } } -func normalizeRouteSubmitRequest(request RouteSubmitRequest) (RouteSubmitRequest, error) { +func normalizeRouteSubmitRequest( + request RouteSubmitRequest, + options ...validationOptions, +) (RouteSubmitRequest, error) { + resolvedOptions := resolveValidationOptions(options) normalizedArtifactApprovals, normalizedArtifactSignatures, err := normalizeArtifactApprovals( request.Route, request, @@ -838,6 +1218,14 @@ func normalizeRouteSubmitRequest(request RouteSubmitRequest) (RouteSubmitRequest return RouteSubmitRequest{}, err } + normalizedMigrationPlanQuote, err := normalizeMigrationPlanQuote( + request, + resolvedOptions, + ) + if err != nil { + return RouteSubmitRequest{}, err + } + return RouteSubmitRequest{ FacadeRequestID: request.FacadeRequestID, IdempotencyKey: request.IdempotencyKey, @@ -858,6 +1246,7 @@ func normalizeRouteSubmitRequest(request RouteSubmitRequest) (RouteSubmitRequest }, DestinationCommitmentHash: normalizeLowerHex(request.DestinationCommitmentHash), MigrationDestination: normalizeMigrationDestination(request.MigrationDestination), + MigrationPlanQuote: normalizedMigrationPlanQuote, MigrationTransactionPlan: normalizeMigrationTransactionPlan(request.MigrationTransactionPlan), ArtifactApprovals: normalizedArtifactApprovals, ArtifactSignatures: normalizedArtifactSignatures, @@ -867,7 +1256,12 @@ func normalizeRouteSubmitRequest(request RouteSubmitRequest) (RouteSubmitRequest }, nil } -func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { +func validateCommonRequest( + route TemplateID, + request RouteSubmitRequest, + options ...validationOptions, +) error { + resolvedOptions := resolveValidationOptions(options) if request.FacadeRequestID == "" { return &inputError{"request.facadeRequestId is required"} } @@ -906,6 +1300,9 @@ func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { if err := validateMigrationTransactionPlan(request, request.MigrationTransactionPlan); err != nil { return err } + if _, err := normalizeMigrationPlanQuote(request, resolvedOptions); err != nil { + return err + } if request.ArtifactApprovals == nil { return &inputError{"request.artifactApprovals is required"} } @@ -972,17 +1369,25 @@ func validateCommonRequest(route TemplateID, request RouteSubmitRequest) error { return nil } -func validateSubmitInput(route TemplateID, input SignerSubmitInput) error { +func validateSubmitInput( + route TemplateID, + input SignerSubmitInput, + options ...validationOptions, +) error { if input.RouteRequestID == "" { return &inputError{"routeRequestId is required"} } if input.Stage != StageSignerCoordination { return &inputError{"stage must be SIGNER_COORDINATION"} } - return validateCommonRequest(route, input.Request) + return validateCommonRequest(route, input.Request, resolveValidationOptions(options)) } -func validatePollInput(route TemplateID, input SignerPollInput) error { +func validatePollInput( + route TemplateID, + input SignerPollInput, + options ...validationOptions, +) error { if input.RequestID == "" { return &inputError{"requestId is required"} } @@ -990,7 +1395,7 @@ func validatePollInput(route TemplateID, input SignerPollInput) error { RouteRequestID: input.RouteRequestID, Request: input.Request, Stage: input.Stage, - }); err != nil { + }, resolveValidationOptions(options)); err != nil { return err } return nil From 48bb4f4e7c9bc0b5ca0944a826d3d9a67068db31 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 09:19:09 -0500 Subject: [PATCH 026/143] Add migration plan quote verification vectors --- pkg/covenantsigner/covenantsigner_test.go | 198 +++++++++++++----- ...gration_plan_quote_signing_vectors_v1.json | 80 +++++++ pkg/covenantsigner/validation.go | 4 + 3 files changed, 226 insertions(+), 56 deletions(-) create mode 100644 pkg/covenantsigner/testdata/migration_plan_quote_signing_vectors_v1.json diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 085fe79d9c..e51995d74b 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -111,6 +111,21 @@ type approvalContractVectorsFile struct { Vectors map[string]approvalContractVector `json:"vectors"` } +type migrationPlanQuoteSigningVector struct { + UnsignedQuote MigrationDestinationPlanQuote `json:"unsignedQuote"` + ExpectedPayload string `json:"expectedPayload"` + ExpectedPreimage string `json:"expectedPreimage"` + ExpectedHash string `json:"expectedHash"` + ExpectedSignature string `json:"expectedSignature"` +} + +type migrationPlanQuoteSigningVectorsFile struct { + Version int `json:"version"` + Scope string `json:"scope"` + TrustRoot MigrationPlanQuoteTrustRoot `json:"trustRoot"` + Vectors map[string]migrationPlanQuoteSigningVector `json:"vectors"` +} + func loadApprovalContractVector( t *testing.T, route TemplateID, @@ -146,6 +161,24 @@ func loadApprovalContractVector( return request, vector.ExpectedRequestDigest } +func loadMigrationPlanQuoteSigningVectors( + t *testing.T, +) migrationPlanQuoteSigningVectorsFile { + t.Helper() + + data, err := os.ReadFile("testdata/migration_plan_quote_signing_vectors_v1.json") + if err != nil { + t.Fatal(err) + } + + vectors := migrationPlanQuoteSigningVectorsFile{} + if err := strictUnmarshal(data, &vectors); err != nil { + t.Fatal(err) + } + + return vectors +} + const ( testDepositorPrivateKeyHex = "0x1111111111111111111111111111111111111111111111111111111111111111" testSignerPrivateKeyHex = "0x2222222222222222222222222222222222222222222222222222222222222222" @@ -1947,71 +1980,62 @@ func TestDestinationCommitmentHashDoesNotEscapeHTMLSensitiveCharacters(t *testin } } -func TestMigrationPlanQuoteSigningHashMatchesTbtcVectors(t *testing.T) { - baseRequest := canonicalArtifactApprovalRequest(TemplateSelfV1) - baseRequest.MigrationPlanQuote = &MigrationDestinationPlanQuote{ - QuoteID: "cmdq_testvector", - QuoteVersion: migrationPlanQuoteVersion, - ReservationID: "cmdr_testvector", - Reserve: "0x1111111111111111111111111111111111111111", - Epoch: 7, - Route: ReservationRouteMigration, - Revealer: "0x2222222222222222222222222222222222222222", - Vault: "0x3333333333333333333333333333333333333333", - Network: "regtest", - DestinationCommitmentHash: "0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7", - ActiveOutpointTxID: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ActiveOutpointVout: 1, - PlanCommitmentHash: "0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09", - MigrationTransactionPlan: &MigrationTransactionPlan{ - PlanVersion: migrationTransactionPlanVersion, - PlanCommitmentHash: "0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09", - InputValueSats: 100000, - DestinationValueSats: 99250, - AnchorValueSats: canonicalAnchorValueSats, - FeeSats: 420, - InputSequence: canonicalCovenantInputSequence, - LockTime: 950000, - }, - IdempotencyKey: "0x75a998ac6951c2776f3a85f6430fb41321c28c1113a71a52c754806c7a3de9c9", - ExpiresInSeconds: 900, - IssuedAt: "2026-03-09T00:00:00.000Z", - ExpiresAt: "2026-03-09T00:15:00.000Z", - Signature: MigrationDestinationPlanQuoteSignature{ - SignatureVersion: migrationPlanQuoteSignatureVersion, - Algorithm: migrationPlanQuoteSignatureAlgorithm, - KeyID: testMigrationPlanQuoteTrustRoot.KeyID, - Signature: "0x00", - }, - } - - payload, err := migrationPlanQuoteSigningPayloadBytes(baseRequest.MigrationPlanQuote) - if err != nil { - t.Fatal(err) +func TestMigrationPlanQuoteSigningVectorsMatchFixture(t *testing.T) { + vectors := loadMigrationPlanQuoteSigningVectors(t) + if vectors.Version != 1 { + t.Fatalf("unexpected vector version: %d", vectors.Version) } - if string(payload) != "{\"quoteVersion\":1,\"quoteId\":\"cmdq_testvector\",\"reservationId\":\"cmdr_testvector\",\"reserve\":\"0x1111111111111111111111111111111111111111\",\"epoch\":7,\"route\":\"MIGRATION\",\"revealer\":\"0x2222222222222222222222222222222222222222\",\"vault\":\"0x3333333333333333333333333333333333333333\",\"network\":\"regtest\",\"destinationCommitmentHash\":\"0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7\",\"activeOutpointTxid\":\"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"activeOutpointVout\":1,\"planCommitmentHash\":\"0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09\",\"issuedAt\":\"2026-03-09T00:00:00.000Z\",\"expiresAt\":\"2026-03-09T00:15:00.000Z\",\"expiresInSeconds\":900}" { - t.Fatalf("unexpected signing payload: %s", payload) + if vectors.Scope != "migration_plan_quote_signing_contract_v1" { + t.Fatalf("unexpected vector scope: %s", vectors.Scope) } - signingHash, err := migrationPlanQuoteSigningHash(baseRequest.MigrationPlanQuote) + block, _ := pem.Decode([]byte(vectors.TrustRoot.PublicKeyPEM)) + if block == nil { + t.Fatal("expected migration plan quote fixture to contain a PEM public key") + } + parsedPublicKey, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { t.Fatal(err) } - if "0x"+hex.EncodeToString(signingHash) != "0x4707935286fa15edf3f95485297307734b122f7dc1761e6fc023e9d5cc7a935a" { - t.Fatalf("unexpected signing hash: 0x%s", hex.EncodeToString(signingHash)) + publicKey, ok := parsedPublicKey.(ed25519.PublicKey) + if !ok { + t.Fatalf("expected Ed25519 public key, got %T", parsedPublicKey) } - mixedCaseQuote := *baseRequest.MigrationPlanQuote - mixedCaseQuote.Reserve = "0xAaBbCcDdEeFf00112233445566778899AaBbCcDd" - mixedCaseQuote.Revealer = "0xAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCd" - mixedCaseQuote.Vault = "0x0011AaBbCcDdEeFf0011AaBbCcDdEeFf0011AaBb" + for name, vector := range vectors.Vectors { + t.Run(name, func(t *testing.T) { + payload, err := migrationPlanQuoteSigningPayloadBytes(&vector.UnsignedQuote) + if err != nil { + t.Fatal(err) + } + if string(payload) != vector.ExpectedPayload { + t.Fatalf("unexpected signing payload: %s", payload) + } + + preimage, err := migrationPlanQuoteSigningPreimage(&vector.UnsignedQuote) + if err != nil { + t.Fatal(err) + } + if string(preimage) != vector.ExpectedPreimage { + t.Fatalf("unexpected signing preimage: %s", preimage) + } - mixedCaseHash, err := migrationPlanQuoteSigningHash(&mixedCaseQuote) - if err != nil { - t.Fatal(err) - } - if "0x"+hex.EncodeToString(mixedCaseHash) != "0x13a05f7e9caa244c446b65c2812095210cb321451d9eb9b735e60ffdd76e693d" { - t.Fatalf("unexpected mixed-case signing hash: 0x%s", hex.EncodeToString(mixedCaseHash)) + signingHash, err := migrationPlanQuoteSigningHash(&vector.UnsignedQuote) + if err != nil { + t.Fatal(err) + } + if "0x"+hex.EncodeToString(signingHash) != vector.ExpectedHash { + t.Fatalf("unexpected signing hash: 0x%s", hex.EncodeToString(signingHash)) + } + + rawSignature, err := hex.DecodeString(strings.TrimPrefix(vector.ExpectedSignature, "0x")) + if err != nil { + t.Fatal(err) + } + if !ed25519.Verify(publicKey, signingHash, rawSignature) { + t.Fatal("expected fixture signature to verify against the fixture trust root") + } + }) } } @@ -2066,6 +2090,34 @@ func TestServiceAcceptsValidMigrationPlanQuoteWhenTrustRootsConfigured(t *testin } } +func TestServiceRejectsExpiredMigrationPlanQuoteOnSubmit(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithMigrationPlanQuoteTrustRoots([]MigrationPlanQuoteTrustRoot{ + testMigrationPlanQuoteTrustRoot, + }), + ) + if err != nil { + t.Fatal(err) + } + service.now = func() time.Time { + return time.Date(2099, time.March, 9, 0, 16, 0, 0, time.UTC) + } + + request := requestWithValidMigrationPlanQuote(TemplateSelfV1) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_quote_expired", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains(err.Error(), "request.migrationPlanQuote is expired") { + t.Fatalf("expected expired quote error, got %v", err) + } +} + func TestServicePollAcceptsStoredMigrationPlanQuoteAfterQuoteExpiry(t *testing.T) { handle := newMemoryHandle() service, err := NewService( @@ -2115,6 +2167,40 @@ func TestServicePollAcceptsStoredMigrationPlanQuoteAfterQuoteExpiry(t *testing.T } } +func TestServiceAcceptsKnownBadSignerApprovalSignatureInPhase1(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithMigrationPlanQuoteTrustRoots([]MigrationPlanQuoteTrustRoot{ + testMigrationPlanQuoteTrustRoot, + }), + ) + if err != nil { + t.Fatal(err) + } + + request := requestWithValidMigrationPlanQuote(TemplateSelfV1) + for i := range request.ArtifactApprovals.Approvals { + if request.ArtifactApprovals.Approvals[i].Role == ArtifactApprovalRoleSigner { + request.ArtifactApprovals.Approvals[i].Signature = "0xdeadbeef" + } + } + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_signer_gap", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatalf("expected phase-1 signer approval gap to remain non-fatal, got %v", err) + } +} + func TestServicePollAcceptsEquivalentArtifactApprovalRequestVariants(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ diff --git a/pkg/covenantsigner/testdata/migration_plan_quote_signing_vectors_v1.json b/pkg/covenantsigner/testdata/migration_plan_quote_signing_vectors_v1.json new file mode 100644 index 0000000000..3a917dae5f --- /dev/null +++ b/pkg/covenantsigner/testdata/migration_plan_quote_signing_vectors_v1.json @@ -0,0 +1,80 @@ +{ + "version": 1, + "scope": "migration_plan_quote_signing_contract_v1", + "trustRoot": { + "keyId": "test-plan-quote-key", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAbp6B4Eys+80lvGkOsR8p2QQPadm+ocqA4/V7bhQBHBc=\n-----END PUBLIC KEY-----\n" + }, + "vectors": { + "base": { + "unsignedQuote": { + "quoteId": "cmdq_testvector", + "quoteVersion": 1, + "reservationId": "cmdr_testvector", + "reserve": "0x1111111111111111111111111111111111111111", + "epoch": 7, + "route": "MIGRATION", + "revealer": "0x2222222222222222222222222222222222222222", + "vault": "0x3333333333333333333333333333333333333333", + "network": "regtest", + "destinationCommitmentHash": "0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7", + "activeOutpointTxid": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "activeOutpointVout": 1, + "planCommitmentHash": "0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09", + "migrationTransactionPlan": { + "planVersion": 1, + "planCommitmentHash": "0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09", + "inputValueSats": 100000, + "destinationValueSats": 99250, + "anchorValueSats": 330, + "feeSats": 420, + "inputSequence": 4294967293, + "lockTime": 950000 + }, + "idempotencyKey": "0x75a998ac6951c2776f3a85f6430fb41321c28c1113a71a52c754806c7a3de9c9", + "expiresInSeconds": 900, + "issuedAt": "2026-03-09T00:00:00.000Z", + "expiresAt": "2026-03-09T00:15:00.000Z" + }, + "expectedPayload": "{\"quoteVersion\":1,\"quoteId\":\"cmdq_testvector\",\"reservationId\":\"cmdr_testvector\",\"reserve\":\"0x1111111111111111111111111111111111111111\",\"epoch\":7,\"route\":\"MIGRATION\",\"revealer\":\"0x2222222222222222222222222222222222222222\",\"vault\":\"0x3333333333333333333333333333333333333333\",\"network\":\"regtest\",\"destinationCommitmentHash\":\"0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7\",\"activeOutpointTxid\":\"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"activeOutpointVout\":1,\"planCommitmentHash\":\"0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09\",\"issuedAt\":\"2026-03-09T00:00:00.000Z\",\"expiresAt\":\"2026-03-09T00:15:00.000Z\",\"expiresInSeconds\":900}", + "expectedPreimage": "migration-plan-quote-v1:{\"quoteVersion\":1,\"quoteId\":\"cmdq_testvector\",\"reservationId\":\"cmdr_testvector\",\"reserve\":\"0x1111111111111111111111111111111111111111\",\"epoch\":7,\"route\":\"MIGRATION\",\"revealer\":\"0x2222222222222222222222222222222222222222\",\"vault\":\"0x3333333333333333333333333333333333333333\",\"network\":\"regtest\",\"destinationCommitmentHash\":\"0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7\",\"activeOutpointTxid\":\"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"activeOutpointVout\":1,\"planCommitmentHash\":\"0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09\",\"issuedAt\":\"2026-03-09T00:00:00.000Z\",\"expiresAt\":\"2026-03-09T00:15:00.000Z\",\"expiresInSeconds\":900}", + "expectedHash": "0x4707935286fa15edf3f95485297307734b122f7dc1761e6fc023e9d5cc7a935a", + "expectedSignature": "0xaae307e9daa2f42f718e8a247c59002a3af7c63f7dd3c67aaa7643d470e787315a5e8f7e330d41c311def8dbf9892bee7d4b86992b81d62a3c194b68c3f0cd03" + }, + "mixed_case": { + "unsignedQuote": { + "quoteId": "cmdq_testvector", + "quoteVersion": 1, + "reservationId": "cmdr_testvector", + "reserve": "0xAaBbCcDdEeFf00112233445566778899AaBbCcDd", + "epoch": 7, + "route": "MIGRATION", + "revealer": "0xAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCd", + "vault": "0x0011AaBbCcDdEeFf0011AaBbCcDdEeFf0011AaBb", + "network": "regtest", + "destinationCommitmentHash": "0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7", + "activeOutpointTxid": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "activeOutpointVout": 1, + "planCommitmentHash": "0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09", + "migrationTransactionPlan": { + "planVersion": 1, + "planCommitmentHash": "0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09", + "inputValueSats": 100000, + "destinationValueSats": 99250, + "anchorValueSats": 330, + "feeSats": 420, + "inputSequence": 4294967293, + "lockTime": 950000 + }, + "idempotencyKey": "0x75a998ac6951c2776f3a85f6430fb41321c28c1113a71a52c754806c7a3de9c9", + "expiresInSeconds": 900, + "issuedAt": "2026-03-09T00:00:00.000Z", + "expiresAt": "2026-03-09T00:15:00.000Z" + }, + "expectedPayload": "{\"quoteVersion\":1,\"quoteId\":\"cmdq_testvector\",\"reservationId\":\"cmdr_testvector\",\"reserve\":\"0xaabbccddeeff00112233445566778899aabbccdd\",\"epoch\":7,\"route\":\"MIGRATION\",\"revealer\":\"0xabcdefabcdefabcdefabcdefabcdefabcdefabcd\",\"vault\":\"0x0011aabbccddeeff0011aabbccddeeff0011aabb\",\"network\":\"regtest\",\"destinationCommitmentHash\":\"0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7\",\"activeOutpointTxid\":\"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"activeOutpointVout\":1,\"planCommitmentHash\":\"0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09\",\"issuedAt\":\"2026-03-09T00:00:00.000Z\",\"expiresAt\":\"2026-03-09T00:15:00.000Z\",\"expiresInSeconds\":900}", + "expectedPreimage": "migration-plan-quote-v1:{\"quoteVersion\":1,\"quoteId\":\"cmdq_testvector\",\"reservationId\":\"cmdr_testvector\",\"reserve\":\"0xaabbccddeeff00112233445566778899aabbccdd\",\"epoch\":7,\"route\":\"MIGRATION\",\"revealer\":\"0xabcdefabcdefabcdefabcdefabcdefabcdefabcd\",\"vault\":\"0x0011aabbccddeeff0011aabbccddeeff0011aabb\",\"network\":\"regtest\",\"destinationCommitmentHash\":\"0x10f6ea91bee183fc004591fb6a93495d166ff646df25eb6a3c6324b40d51ebc7\",\"activeOutpointTxid\":\"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"activeOutpointVout\":1,\"planCommitmentHash\":\"0x9ef0621c394dea9e399b4d7a44e41814a2f1bcc163abb18ec897d3f77f144b09\",\"issuedAt\":\"2026-03-09T00:00:00.000Z\",\"expiresAt\":\"2026-03-09T00:15:00.000Z\",\"expiresInSeconds\":900}", + "expectedHash": "0x13a05f7e9caa244c446b65c2812095210cb321451d9eb9b735e60ffdd76e693d", + "expectedSignature": "0x99ece768accfd8ae222ae2ecba80585fc3664cd84277e53248a4d5d37c48961e20547d66d8acc511ada8b5c0a76d2e22477402f6649d6e2193165f078f6cfe02" + } + } +} diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index dcbcbc6201..d6d0cd3eab 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -632,6 +632,10 @@ func normalizeMigrationPlanQuote( if verificationNow.IsZero() { verificationNow = time.Now().UTC() } + // Submit freshness is intentionally strict. Poll omits this check so + // already-accepted jobs remain addressable after quote expiry; operators + // must keep the destination service and keep-core on synchronized UTC + // time when enforcing quote freshness. if expiresAt.Before(verificationNow) { return nil, &inputError{"request.migrationPlanQuote is expired"} } From b820dafe9ca42dbe515739aa3e1a62ee88c34790 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 09:58:30 -0500 Subject: [PATCH 027/143] Spike signer approval certificates --- pkg/tbtc/signer_approval_certificate.go | 264 +++++++++++++++++++ pkg/tbtc/signer_approval_certificate_test.go | 180 +++++++++++++ 2 files changed, 444 insertions(+) create mode 100644 pkg/tbtc/signer_approval_certificate.go create mode 100644 pkg/tbtc/signer_approval_certificate_test.go diff --git a/pkg/tbtc/signer_approval_certificate.go b/pkg/tbtc/signer_approval_certificate.go new file mode 100644 index 0000000000..05a84df36b --- /dev/null +++ b/pkg/tbtc/signer_approval_certificate.go @@ -0,0 +1,264 @@ +package tbtc + +import ( + "context" + "crypto/ecdsa" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "sort" + "strings" + + "github.com/btcsuite/btcd/btcec" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +const ( + signerApprovalCertificateVersion uint32 = 1 + signerApprovalCertificateSignatureAlgorithm = "tecdsa-secp256k1" + signerApprovalCertificateSignerSetDomain = "covenant-signer-set-v1:" +) + +// signerApprovalCertificate is a spike artifact for evaluating whether the +// current tECDSA signer stack can emit a single offline-verifiable `S` +// approval over an arbitrary approval digest. +type signerApprovalCertificate struct { + CertificateVersion uint32 `json:"certificateVersion"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + WalletPublicKey string `json:"walletPublicKey"` + SignerSetHash string `json:"signerSetHash"` + ApprovalDigest string `json:"approvalDigest"` + Signature string `json:"signature"` + ActiveMembers []uint32 `json:"activeMembers,omitempty"` + InactiveMembers []uint32 `json:"inactiveMembers,omitempty"` + EndBlock uint64 `json:"endBlock"` +} + +type signerApprovalCertificateSignerSetPayload struct { + WalletPublicKey string `json:"walletPublicKey"` + SigningGroupOperators []string `json:"signingGroupOperators"` + HonestThreshold int `json:"honestThreshold"` +} + +func (se *signingExecutor) issueSignerApprovalCertificate( + ctx context.Context, + approvalDigest []byte, + startBlock uint64, +) (*signerApprovalCertificate, error) { + if len(approvalDigest) != sha256.Size { + return nil, fmt.Errorf( + "approval digest must be exactly %d bytes", + sha256.Size, + ) + } + + signature, activityReport, endBlock, err := se.sign( + ctx, + new(big.Int).SetBytes(approvalDigest), + startBlock, + ) + if err != nil { + return nil, err + } + + return buildSignerApprovalCertificate( + se.wallet(), + se.groupParameters, + approvalDigest, + signature, + activityReport, + endBlock, + ) +} + +func buildSignerApprovalCertificate( + wallet wallet, + groupParameters *GroupParameters, + approvalDigest []byte, + signature *tecdsa.Signature, + activityReport *signingActivityReport, + endBlock uint64, +) (*signerApprovalCertificate, error) { + if len(approvalDigest) != sha256.Size { + return nil, fmt.Errorf( + "approval digest must be exactly %d bytes", + sha256.Size, + ) + } + if groupParameters == nil { + return nil, fmt.Errorf("group parameters are required") + } + if signature == nil || signature.R == nil || signature.S == nil { + return nil, fmt.Errorf("threshold signature is required") + } + + walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) + if err != nil { + return nil, err + } + + signerSetHash, err := computeSignerApprovalCertificateSignerSetHash( + wallet, + groupParameters, + ) + if err != nil { + return nil, err + } + + signatureBytes := (&btcec.Signature{ + R: signature.R, + S: signature.S, + }).Serialize() + + certificate := &signerApprovalCertificate{ + CertificateVersion: signerApprovalCertificateVersion, + SignatureAlgorithm: signerApprovalCertificateSignatureAlgorithm, + WalletPublicKey: "0x" + hex.EncodeToString(walletPublicKeyBytes), + SignerSetHash: signerSetHash, + ApprovalDigest: "0x" + hex.EncodeToString(approvalDigest), + Signature: "0x" + hex.EncodeToString(signatureBytes), + EndBlock: endBlock, + } + + if activityReport != nil { + certificate.ActiveMembers = normalizeSignerApprovalMemberIndexes( + activityReport.activeMembers, + ) + certificate.InactiveMembers = normalizeSignerApprovalMemberIndexes( + activityReport.inactiveMembers, + ) + } + + return certificate, nil +} + +func computeSignerApprovalCertificateSignerSetHash( + wallet wallet, + groupParameters *GroupParameters, +) (string, error) { + if groupParameters == nil { + return "", fmt.Errorf("group parameters are required") + } + + walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) + if err != nil { + return "", err + } + + signingGroupOperators := make([]string, len(wallet.signingGroupOperators)) + for i, operator := range wallet.signingGroupOperators { + signingGroupOperators[i] = operator.String() + } + + payload, err := json.Marshal(signerApprovalCertificateSignerSetPayload{ + WalletPublicKey: "0x" + hex.EncodeToString(walletPublicKeyBytes), + SigningGroupOperators: signingGroupOperators, + HonestThreshold: groupParameters.HonestThreshold, + }) + if err != nil { + return "", err + } + + sum := sha256.Sum256( + append([]byte(signerApprovalCertificateSignerSetDomain), payload...), + ) + + return "0x" + hex.EncodeToString(sum[:]), nil +} + +func verifySignerApprovalCertificate( + certificate *signerApprovalCertificate, + expectedSignerSetHash string, +) error { + if certificate == nil { + return fmt.Errorf("certificate is required") + } + if certificate.CertificateVersion != signerApprovalCertificateVersion { + return fmt.Errorf("unsupported certificate version: %d", certificate.CertificateVersion) + } + if certificate.SignatureAlgorithm != signerApprovalCertificateSignatureAlgorithm { + return fmt.Errorf("unsupported signature algorithm: %s", certificate.SignatureAlgorithm) + } + if expectedSignerSetHash != "" && + strings.ToLower(expectedSignerSetHash) != strings.ToLower(certificate.SignerSetHash) { + return fmt.Errorf("signer set hash does not match the expected signer set") + } + + approvalDigest, err := decodeSignerApprovalCertificateHex( + certificate.ApprovalDigest, + sha256.Size, + ) + if err != nil { + return fmt.Errorf("invalid approval digest: %w", err) + } + signatureBytes, err := decodeSignerApprovalCertificateHex( + certificate.Signature, + 0, + ) + if err != nil { + return fmt.Errorf("invalid threshold signature: %w", err) + } + walletPublicKeyBytes, err := decodeSignerApprovalCertificateHex( + certificate.WalletPublicKey, + 0, + ) + if err != nil { + return fmt.Errorf("invalid wallet public key: %w", err) + } + + walletPublicKey := unmarshalPublicKey(walletPublicKeyBytes) + if walletPublicKey == nil || walletPublicKey.X == nil || walletPublicKey.Y == nil { + return fmt.Errorf("wallet public key is not a valid uncompressed secp256k1 key") + } + + parsedSignature, err := btcec.ParseDERSignature(signatureBytes, btcec.S256()) + if err != nil { + return fmt.Errorf("cannot parse threshold signature: %w", err) + } + + if !ecdsa.Verify(walletPublicKey, approvalDigest, parsedSignature.R, parsedSignature.S) { + return fmt.Errorf("threshold signature does not verify against wallet public key") + } + + return nil +} + +func decodeSignerApprovalCertificateHex( + value string, + expectedBytes int, +) ([]byte, error) { + normalized := strings.TrimSpace(value) + if !strings.HasPrefix(normalized, "0x") { + return nil, fmt.Errorf("value must be 0x-prefixed") + } + + decoded, err := hex.DecodeString(strings.TrimPrefix(normalized, "0x")) + if err != nil { + return nil, err + } + if expectedBytes > 0 && len(decoded) != expectedBytes { + return nil, fmt.Errorf( + "value must be exactly %d bytes, got %d", + expectedBytes, + len(decoded), + ) + } + + return decoded, nil +} + +func normalizeSignerApprovalMemberIndexes( + memberIndexes []group.MemberIndex, +) []uint32 { + normalized := make([]uint32, len(memberIndexes)) + for i, memberIndex := range memberIndexes { + normalized[i] = uint32(memberIndex) + } + sort.Slice(normalized, func(i, j int) bool { + return normalized[i] < normalized[j] + }) + return normalized +} diff --git a/pkg/tbtc/signer_approval_certificate_test.go b/pkg/tbtc/signer_approval_certificate_test.go new file mode 100644 index 0000000000..0239c2c6a4 --- /dev/null +++ b/pkg/tbtc/signer_approval_certificate_test.go @@ -0,0 +1,180 @@ +package tbtc + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/keep-network/keep-core/pkg/chain" +) + +func TestSigningExecutorCanIssueSignerApprovalCertificateForArbitraryDigest(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + + executor, ok, err := node.getSigningExecutor(walletPublicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + + startBlock, err := executor.getCurrentBlockFn() + if err != nil { + t.Fatal(err) + } + + approvalDigest := sha256.Sum256( + []byte("psbt-covenant-signer-approval-certificate-spike"), + ) + + certificate, err := executor.issueSignerApprovalCertificate( + context.Background(), + approvalDigest[:], + startBlock, + ) + if err != nil { + t.Fatal(err) + } + + expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( + executor.wallet(), + executor.groupParameters, + ) + if err != nil { + t.Fatal(err) + } + if err := verifySignerApprovalCertificate(certificate, expectedSignerSetHash); err != nil { + t.Fatalf("expected certificate verification to succeed: %v", err) + } + + expectedDigest := "0x" + hex.EncodeToString(approvalDigest[:]) + if certificate.ApprovalDigest != expectedDigest { + t.Fatalf( + "unexpected approval digest\nexpected: %s\nactual: %s", + expectedDigest, + certificate.ApprovalDigest, + ) + } + if certificate.SignerSetHash != expectedSignerSetHash { + t.Fatalf( + "unexpected signer set hash\nexpected: %s\nactual: %s", + expectedSignerSetHash, + certificate.SignerSetHash, + ) + } + if len(certificate.ActiveMembers) < executor.groupParameters.HonestThreshold { + t.Fatalf( + "expected at least honest threshold active members, got %v", + certificate.ActiveMembers, + ) + } + if certificate.EndBlock < startBlock { + t.Fatalf( + "expected end block [%v] to be >= start block [%v]", + certificate.EndBlock, + startBlock, + ) + } +} + +func TestSignerApprovalCertificateVerificationRejectsTamperedDigest(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + + executor, ok, err := node.getSigningExecutor(walletPublicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + + startBlock, err := executor.getCurrentBlockFn() + if err != nil { + t.Fatal(err) + } + + approvalDigest := sha256.Sum256([]byte("psbt-covenant-signer-approval-certificate")) + certificate, err := executor.issueSignerApprovalCertificate( + context.Background(), + approvalDigest[:], + startBlock, + ) + if err != nil { + t.Fatal(err) + } + + expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( + executor.wallet(), + executor.groupParameters, + ) + if err != nil { + t.Fatal(err) + } + + tampered := *certificate + tamperedDigest := sha256.Sum256([]byte("tampered")) + tampered.ApprovalDigest = "0x" + hex.EncodeToString(tamperedDigest[:]) + if err := verifySignerApprovalCertificate(&tampered, expectedSignerSetHash); err == nil { + t.Fatal("expected tampered approval digest to fail verification") + } +} + +func TestSignerApprovalCertificateSignerSetHashBindsRosterAndThreshold(t *testing.T) { + _, _, walletPublicKey := setupCovenantSignerTestNode(t) + + baseWallet := wallet{ + publicKey: walletPublicKey, + signingGroupOperators: []chain.Address{ + "operator-1", + "operator-2", + "operator-3", + }, + } + baseGroupParameters := &GroupParameters{ + GroupSize: 3, + GroupQuorum: 2, + HonestThreshold: 2, + } + + baseHash, err := computeSignerApprovalCertificateSignerSetHash( + baseWallet, + baseGroupParameters, + ) + if err != nil { + t.Fatal(err) + } + + reorderedWallet := baseWallet + reorderedWallet.signingGroupOperators = []chain.Address{ + "operator-2", + "operator-1", + "operator-3", + } + reorderedHash, err := computeSignerApprovalCertificateSignerSetHash( + reorderedWallet, + baseGroupParameters, + ) + if err != nil { + t.Fatal(err) + } + if reorderedHash == baseHash { + t.Fatal("expected signer set hash to change when operator seat order changes") + } + + thresholdChangedHash, err := computeSignerApprovalCertificateSignerSetHash( + baseWallet, + &GroupParameters{ + GroupSize: 3, + GroupQuorum: 2, + HonestThreshold: 3, + }, + ) + if err != nil { + t.Fatal(err) + } + if thresholdChangedHash == baseHash { + t.Fatal("expected signer set hash to change when honest threshold changes") + } +} From 9f1f3a7698edf7dbdbfb09d3ad19ba7313a9d109 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 10:11:10 -0500 Subject: [PATCH 028/143] Bind signer approval cert to on-chain wallet identity --- pkg/chain/ethereum/tbtc.go | 12 +++- pkg/tbtc/chain.go | 1 + pkg/tbtc/covenant_signer_test.go | 6 +- pkg/tbtc/node.go | 1 + pkg/tbtc/signer_approval_certificate.go | 47 +++++++++++---- pkg/tbtc/signer_approval_certificate_test.go | 62 +++++++++++++++----- pkg/tbtc/signing.go | 3 + 7 files changed, 101 insertions(+), 31 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index ec5c29d40f..97eb4ecc85 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1464,7 +1464,16 @@ func (tc *TbtcChain) GetWallet( if wallet.CreatedAt == 0 { return nil, fmt.Errorf( "no wallet for public key hash [0x%x]", - wallet, + walletPublicKeyHash, + ) + } + + walletRegistryWallet, err := tc.walletRegistry.GetWallet(wallet.EcdsaWalletID) + if err != nil { + return nil, fmt.Errorf( + "cannot get wallet registry data for wallet [0x%x]: [%v]", + wallet.EcdsaWalletID, + err, ) } @@ -1475,6 +1484,7 @@ func (tc *TbtcChain) GetWallet( return &tbtc.WalletChainData{ EcdsaWalletID: wallet.EcdsaWalletID, + MembersIDsHash: walletRegistryWallet.MembersIdsHash, MainUtxoHash: wallet.MainUtxoHash, PendingRedemptionsValue: wallet.PendingRedemptionsValue, CreatedAt: time.Unix(int64(wallet.CreatedAt), 0), diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 55206f86fb..8dc745c7cf 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -414,6 +414,7 @@ type DepositChainRequest struct { // WalletChainData represents wallet data stored on-chain. type WalletChainData struct { EcdsaWalletID [32]byte + MembersIDsHash [32]byte MainUtxoHash [32]byte PendingRedemptionsValue uint64 CreatedAt time.Time diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index c65381c7a1..ab244528d9 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -999,12 +999,14 @@ func setupCovenantSignerTestNode( if err != nil { t.Fatal(err) } + membersIDsHash := sha256.Sum256([]byte("covenant-signer-test-members")) localChain.setWallet( walletPublicKeyHash, &WalletChainData{ - EcdsaWalletID: walletID, - State: StateLive, + EcdsaWalletID: walletID, + MembersIDsHash: membersIDsHash, + State: StateLive, }, ) diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index 8ce03ed130..b6b0dc15bf 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -394,6 +394,7 @@ func (n *node) getSigningExecutor( } executor := newSigningExecutor( + n.chain, signers, broadcastChannel, membershipValidator, diff --git a/pkg/tbtc/signer_approval_certificate.go b/pkg/tbtc/signer_approval_certificate.go index 05a84df36b..0bc42689ba 100644 --- a/pkg/tbtc/signer_approval_certificate.go +++ b/pkg/tbtc/signer_approval_certificate.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/btcsuite/btcd/btcec" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" ) @@ -38,9 +39,10 @@ type signerApprovalCertificate struct { } type signerApprovalCertificateSignerSetPayload struct { - WalletPublicKey string `json:"walletPublicKey"` - SigningGroupOperators []string `json:"signingGroupOperators"` - HonestThreshold int `json:"honestThreshold"` + WalletID string `json:"walletId"` + WalletPublicKey string `json:"walletPublicKey"` + MembersIDsHash string `json:"membersIdsHash"` + HonestThreshold int `json:"honestThreshold"` } func (se *signingExecutor) issueSignerApprovalCertificate( @@ -55,6 +57,15 @@ func (se *signingExecutor) issueSignerApprovalCertificate( ) } + wallet := se.wallet() + walletChainData, err := se.chain.GetWallet(bitcoin.PublicKeyHash(wallet.publicKey)) + if err != nil { + return nil, fmt.Errorf( + "cannot get on-chain wallet data for signer approval certificate: %w", + err, + ) + } + signature, activityReport, endBlock, err := se.sign( ctx, new(big.Int).SetBytes(approvalDigest), @@ -65,7 +76,8 @@ func (se *signingExecutor) issueSignerApprovalCertificate( } return buildSignerApprovalCertificate( - se.wallet(), + wallet, + walletChainData, se.groupParameters, approvalDigest, signature, @@ -76,6 +88,7 @@ func (se *signingExecutor) issueSignerApprovalCertificate( func buildSignerApprovalCertificate( wallet wallet, + walletChainData *WalletChainData, groupParameters *GroupParameters, approvalDigest []byte, signature *tecdsa.Signature, @@ -91,6 +104,9 @@ func buildSignerApprovalCertificate( if groupParameters == nil { return nil, fmt.Errorf("group parameters are required") } + if walletChainData == nil { + return nil, fmt.Errorf("wallet chain data is required") + } if signature == nil || signature.R == nil || signature.S == nil { return nil, fmt.Errorf("threshold signature is required") } @@ -102,6 +118,7 @@ func buildSignerApprovalCertificate( signerSetHash, err := computeSignerApprovalCertificateSignerSetHash( wallet, + walletChainData, groupParameters, ) if err != nil { @@ -137,26 +154,32 @@ func buildSignerApprovalCertificate( func computeSignerApprovalCertificateSignerSetHash( wallet wallet, + walletChainData *WalletChainData, groupParameters *GroupParameters, ) (string, error) { if groupParameters == nil { return "", fmt.Errorf("group parameters are required") } + if walletChainData == nil { + return "", fmt.Errorf("wallet chain data is required") + } + if walletChainData.EcdsaWalletID == ([32]byte{}) { + return "", fmt.Errorf("wallet chain data must include wallet ID") + } + if walletChainData.MembersIDsHash == ([32]byte{}) { + return "", fmt.Errorf("wallet chain data must include members IDs hash") + } walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) if err != nil { return "", err } - signingGroupOperators := make([]string, len(wallet.signingGroupOperators)) - for i, operator := range wallet.signingGroupOperators { - signingGroupOperators[i] = operator.String() - } - payload, err := json.Marshal(signerApprovalCertificateSignerSetPayload{ - WalletPublicKey: "0x" + hex.EncodeToString(walletPublicKeyBytes), - SigningGroupOperators: signingGroupOperators, - HonestThreshold: groupParameters.HonestThreshold, + WalletID: "0x" + hex.EncodeToString(walletChainData.EcdsaWalletID[:]), + WalletPublicKey: "0x" + hex.EncodeToString(walletPublicKeyBytes), + MembersIDsHash: "0x" + hex.EncodeToString(walletChainData.MembersIDsHash[:]), + HonestThreshold: groupParameters.HonestThreshold, }) if err != nil { return "", err diff --git a/pkg/tbtc/signer_approval_certificate_test.go b/pkg/tbtc/signer_approval_certificate_test.go index 0239c2c6a4..95ceb6c0d5 100644 --- a/pkg/tbtc/signer_approval_certificate_test.go +++ b/pkg/tbtc/signer_approval_certificate_test.go @@ -6,7 +6,7 @@ import ( "encoding/hex" "testing" - "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/bitcoin" ) func TestSigningExecutorCanIssueSignerApprovalCertificateForArbitraryDigest(t *testing.T) { @@ -38,8 +38,16 @@ func TestSigningExecutorCanIssueSignerApprovalCertificateForArbitraryDigest(t *t t.Fatal(err) } + walletChainData, err := executor.chain.GetWallet( + bitcoin.PublicKeyHash(executor.wallet().publicKey), + ) + if err != nil { + t.Fatal(err) + } + expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( executor.wallet(), + walletChainData, executor.groupParameters, ) if err != nil { @@ -105,8 +113,16 @@ func TestSignerApprovalCertificateVerificationRejectsTamperedDigest(t *testing.T t.Fatal(err) } + walletChainData, err := executor.chain.GetWallet( + bitcoin.PublicKeyHash(executor.wallet().publicKey), + ) + if err != nil { + t.Fatal(err) + } + expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( executor.wallet(), + walletChainData, executor.groupParameters, ) if err != nil { @@ -121,16 +137,15 @@ func TestSignerApprovalCertificateVerificationRejectsTamperedDigest(t *testing.T } } -func TestSignerApprovalCertificateSignerSetHashBindsRosterAndThreshold(t *testing.T) { +func TestSignerApprovalCertificateSignerSetHashBindsOnChainWalletIdentityAndThreshold(t *testing.T) { _, _, walletPublicKey := setupCovenantSignerTestNode(t) baseWallet := wallet{ publicKey: walletPublicKey, - signingGroupOperators: []chain.Address{ - "operator-1", - "operator-2", - "operator-3", - }, + } + baseWalletChainData := &WalletChainData{ + EcdsaWalletID: sha256.Sum256([]byte("wallet-id-base")), + MembersIDsHash: sha256.Sum256([]byte("members-hash-base")), } baseGroupParameters := &GroupParameters{ GroupSize: 3, @@ -140,31 +155,46 @@ func TestSignerApprovalCertificateSignerSetHashBindsRosterAndThreshold(t *testin baseHash, err := computeSignerApprovalCertificateSignerSetHash( baseWallet, + baseWalletChainData, baseGroupParameters, ) if err != nil { t.Fatal(err) } - reorderedWallet := baseWallet - reorderedWallet.signingGroupOperators = []chain.Address{ - "operator-2", - "operator-1", - "operator-3", + changedMembersHash, err := computeSignerApprovalCertificateSignerSetHash( + baseWallet, + &WalletChainData{ + EcdsaWalletID: baseWalletChainData.EcdsaWalletID, + MembersIDsHash: sha256.Sum256([]byte("members-hash-changed")), + }, + baseGroupParameters, + ) + if err != nil { + t.Fatal(err) + } + if changedMembersHash == baseHash { + t.Fatal("expected signer set hash to change when members IDs hash changes") } - reorderedHash, err := computeSignerApprovalCertificateSignerSetHash( - reorderedWallet, + + changedWalletIDHash, err := computeSignerApprovalCertificateSignerSetHash( + baseWallet, + &WalletChainData{ + EcdsaWalletID: sha256.Sum256([]byte("wallet-id-changed")), + MembersIDsHash: baseWalletChainData.MembersIDsHash, + }, baseGroupParameters, ) if err != nil { t.Fatal(err) } - if reorderedHash == baseHash { - t.Fatal("expected signer set hash to change when operator seat order changes") + if changedWalletIDHash == baseHash { + t.Fatal("expected signer set hash to change when wallet ID changes") } thresholdChangedHash, err := computeSignerApprovalCertificateSignerSetHash( baseWallet, + baseWalletChainData, &GroupParameters{ GroupSize: 3, GroupQuorum: 2, diff --git a/pkg/tbtc/signing.go b/pkg/tbtc/signing.go index 346b6b0446..40bf947d3b 100644 --- a/pkg/tbtc/signing.go +++ b/pkg/tbtc/signing.go @@ -45,6 +45,7 @@ var errSigningExecutorBusy = fmt.Errorf("signing executor is busy") type signingExecutor struct { lock *semaphore.Weighted + chain Chain signers []*signer broadcastChannel net.BroadcastChannel membershipValidator *group.MembershipValidator @@ -70,6 +71,7 @@ type signingExecutor struct { } func newSigningExecutor( + chain Chain, signers []*signer, broadcastChannel net.BroadcastChannel, membershipValidator *group.MembershipValidator, @@ -81,6 +83,7 @@ func newSigningExecutor( ) *signingExecutor { return &signingExecutor{ lock: semaphore.NewWeighted(1), + chain: chain, signers: signers, broadcastChannel: broadcastChannel, membershipValidator: membershipValidator, From f34053740e7c905b81ef8404ae881fd94e992a8f Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 11:04:10 -0500 Subject: [PATCH 029/143] Verify structured covenant signer approvals --- pkg/covenantsigner/covenantsigner_test.go | 277 +++++++++++++++++++ pkg/covenantsigner/engine.go | 12 + pkg/covenantsigner/service.go | 16 ++ pkg/covenantsigner/types.go | 13 + pkg/covenantsigner/validation.go | 254 ++++++++++++++++- pkg/tbtc/covenant_signer.go | 91 ++++++ pkg/tbtc/signer_approval_certificate.go | 32 +-- pkg/tbtc/signer_approval_certificate_test.go | 169 ++++++++++- 8 files changed, 827 insertions(+), 37 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index e51995d74b..bd4e86f1a6 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -191,6 +191,7 @@ var ( testCustodianPrivateKey = mustDeterministicTestPrivateKey(testCustodianPrivateKeyHex) testDepositorPublicKey = mustCompressedPublicKeyHex(testDepositorPrivateKey) testSignerPublicKey = mustCompressedPublicKeyHex(testSignerPrivateKey) + testSignerUncompressedPublicKey = mustUncompressedPublicKeyHex(testSignerPrivateKey) testCustodianPublicKey = mustCompressedPublicKeyHex(testCustodianPrivateKey) testMigrationPlanQuoteSeed = bytes.Repeat([]byte{0x44}, ed25519.SeedSize) testMigrationPlanQuotePrivateKey = ed25519.NewKeyFromSeed(testMigrationPlanQuoteSeed) @@ -214,6 +215,10 @@ func mustCompressedPublicKeyHex(privateKey *btcec.PrivateKey) string { return "0x" + hex.EncodeToString(privateKey.PubKey().SerializeCompressed()) } +func mustUncompressedPublicKeyHex(privateKey *btcec.PrivateKey) string { + return "0x" + hex.EncodeToString(privateKey.PubKey().SerializeUncompressed()) +} + func mustMigrationPlanQuoteTrustRootPEM(publicKey ed25519.PublicKey) string { encodedPublicKey, err := x509.MarshalPKIXPublicKey(publicKey) if err != nil { @@ -292,6 +297,30 @@ func canonicalArtifactSignatures( return signatures } +func canonicalArtifactSignaturesWithSignerApproval( + route TemplateID, + artifactApprovals *ArtifactApprovalEnvelope, + signerApproval *SignerApprovalCertificate, +) []string { + requiredRoles, err := requiredStructuredArtifactApprovalRoles(route) + if err != nil { + panic(err) + } + + signatures := make([]string, 0, len(requiredRoles)+1) + for _, role := range requiredRoles { + signatures = append( + signatures, + artifactApprovalSignatureByRole(artifactApprovals, role), + ) + } + if signerApproval == nil { + return signatures + } + + return append(signatures, signerApproval.Signature) +} + func validSelfTemplate() json.RawMessage { return mustTemplate(SelfV1Template{ Template: TemplateSelfV1, @@ -408,6 +437,76 @@ func validArtifactApprovals(request RouteSubmitRequest) *ArtifactApprovalEnvelop } } +func validStructuredArtifactApprovals( + request RouteSubmitRequest, +) *ArtifactApprovalEnvelope { + payload := ArtifactApprovalPayload{ + ApprovalVersion: artifactApprovalVersion, + Route: request.Route, + ScriptTemplateID: request.Route, + DestinationCommitmentHash: request.DestinationCommitmentHash, + PlanCommitmentHash: request.MigrationTransactionPlan.PlanCommitmentHash, + } + + approvals := []ArtifactRoleApproval{ + { + Role: ArtifactApprovalRoleDepositor, + Signature: mustArtifactApprovalSignature(testDepositorPrivateKey, payload), + }, + } + + if request.Route == TemplateQcV1 { + approvals = append(approvals, ArtifactRoleApproval{ + Role: ArtifactApprovalRoleCustodian, + Signature: mustArtifactApprovalSignature(testCustodianPrivateKey, payload), + }) + } + + return &ArtifactApprovalEnvelope{ + Payload: payload, + Approvals: approvals, + } +} + +func validSignerApproval( + artifactApprovals *ArtifactApprovalEnvelope, +) *SignerApprovalCertificate { + if artifactApprovals == nil { + panic("artifact approvals are required") + } + + digest, err := artifactApprovalDigest(artifactApprovals.Payload) + if err != nil { + panic(err) + } + + endBlock := uint64(123456) + return &SignerApprovalCertificate{ + CertificateVersion: signerApprovalCertificateVersion, + SignatureAlgorithm: signerApprovalSignatureAlgorithm, + ApprovalDigest: "0x" + hex.EncodeToString(digest), + WalletPublicKey: testSignerUncompressedPublicKey, + SignerSetHash: "0x" + strings.Repeat("ab", 32), + Signature: "0x304402200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f2002202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + ActiveMembers: []uint32{2, 1}, + InactiveMembers: []uint32{4, 3}, + EndBlock: &endBlock, + } +} + +func structuredSignerApprovalRequest(route TemplateID) RouteSubmitRequest { + request := baseRequest(route) + request.ArtifactApprovals = validStructuredArtifactApprovals(request) + request.SignerApproval = validSignerApproval(request.ArtifactApprovals) + request.ArtifactSignatures = canonicalArtifactSignaturesWithSignerApproval( + request.Route, + request.ArtifactApprovals, + request.SignerApproval, + ) + + return request +} + func canonicalArtifactApprovalRequest(route TemplateID) RouteSubmitRequest { return baseRequest(route) } @@ -510,6 +609,37 @@ func artifactApprovalVariantFromRequest( variant.ArtifactSignatures[i] = transformHex(variant.ArtifactSignatures[i]) } + if variant.SignerApproval != nil { + variant.SignerApproval.ApprovalDigest = transformHex( + variant.SignerApproval.ApprovalDigest, + ) + variant.SignerApproval.WalletPublicKey = transformHex( + variant.SignerApproval.WalletPublicKey, + ) + variant.SignerApproval.SignerSetHash = transformHex( + variant.SignerApproval.SignerSetHash, + ) + variant.SignerApproval.Signature = transformHex( + variant.SignerApproval.Signature, + ) + if len(variant.SignerApproval.ActiveMembers) > 1 { + variant.SignerApproval.ActiveMembers = append( + []uint32{ + variant.SignerApproval.ActiveMembers[len(variant.SignerApproval.ActiveMembers)-1], + }, + variant.SignerApproval.ActiveMembers[:len(variant.SignerApproval.ActiveMembers)-1]..., + ) + } + if len(variant.SignerApproval.InactiveMembers) > 1 { + variant.SignerApproval.InactiveMembers = append( + []uint32{ + variant.SignerApproval.InactiveMembers[len(variant.SignerApproval.InactiveMembers)-1], + }, + variant.SignerApproval.InactiveMembers[:len(variant.SignerApproval.InactiveMembers)-1]..., + ) + } + } + for pathID, artifact := range variant.Artifacts { artifact.PSBTHash = transformHex(artifact.PSBTHash) artifact.DestinationCommitmentHash = transformHex(artifact.DestinationCommitmentHash) @@ -1748,6 +1878,128 @@ func TestServiceAcceptsArtifactApprovalsWithCanonicalLegacySignatures(t *testing } } +func TestServiceAcceptsStructuredSignerApprovalWhenVerifierConfigured(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { + if request.SignerApproval == nil { + t.Fatal("expected signer approval") + } + return nil + })), + ) + if err != nil { + t.Fatal(err) + } + + request := structuredSignerApprovalRequest(TemplateQcV1) + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_structured_signer_approval", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + job, ok, err := service.store.GetByRouteRequest( + TemplateQcV1, + "orq_structured_signer_approval", + ) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected stored job") + } + if job.Request.SignerApproval == nil { + t.Fatal("expected stored signer approval") + } + if !reflect.DeepEqual( + job.Request.SignerApproval.ActiveMembers, + []uint32{1, 2}, + ) { + t.Fatalf( + "unexpected active members: %#v", + job.Request.SignerApproval.ActiveMembers, + ) + } + if !reflect.DeepEqual( + job.Request.SignerApproval.InactiveMembers, + []uint32{3, 4}, + ) { + t.Fatalf( + "unexpected inactive members: %#v", + job.Request.SignerApproval.InactiveMembers, + ) + } +} + +func TestServiceRejectsStructuredSignerApprovalWithoutVerifier(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + request := structuredSignerApprovalRequest(TemplateSelfV1) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_structured_signer_approval_unsupported", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval cannot be verified by this signer deployment", + ) { + t.Fatalf("expected unsupported signer approval error, got %v", err) + } +} + +func TestServiceRejectsStructuredSignerApprovalWithLegacySignerRole(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(RouteSubmitRequest) error { + return nil + })), + ) + if err != nil { + t.Fatal(err) + } + + request := structuredSignerApprovalRequest(TemplateSelfV1) + request.ArtifactApprovals.Approvals = append( + request.ArtifactApprovals.Approvals, + ArtifactRoleApproval{ + Role: ArtifactApprovalRoleSigner, + Signature: "0x5151", + }, + ) + request.ArtifactSignatures = append( + request.ArtifactSignatures[:len(request.ArtifactSignatures)-1], + "0x5151", + request.ArtifactSignatures[len(request.ArtifactSignatures)-1], + ) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_structured_signer_approval_legacy_role", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.artifactApprovals.approvals[1].role is not allowed for self_v1", + ) { + t.Fatalf("expected structured signer-role rejection, got %v", err) + } +} + func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{}) @@ -1903,6 +2155,31 @@ func TestRequestDigestNormalizesEquivalentArtifactApprovalVariants(t *testing.T) } } +func TestRequestDigestNormalizesEquivalentStructuredSignerApprovalVariants(t *testing.T) { + canonicalDigest, err := requestDigest(structuredSignerApprovalRequest(TemplateQcV1)) + if err != nil { + t.Fatal(err) + } + + variantDigest, err := requestDigest( + equivalentArtifactApprovalVariantFromRequest( + t, + structuredSignerApprovalRequest(TemplateQcV1), + ), + ) + if err != nil { + t.Fatal(err) + } + + if canonicalDigest != variantDigest { + t.Fatalf( + "expected matching structured request digest, got %s vs %s", + canonicalDigest, + variantDigest, + ) + } +} + func TestRequestDigestDoesNotEscapeHTMLSensitiveCharacters(t *testing.T) { request := canonicalArtifactApprovalRequest(TemplateSelfV1) request.FacadeRequestID = "rf_&sink" diff --git a/pkg/covenantsigner/engine.go b/pkg/covenantsigner/engine.go index c1eab76cf0..b36ea06ee2 100644 --- a/pkg/covenantsigner/engine.go +++ b/pkg/covenantsigner/engine.go @@ -21,6 +21,18 @@ type Engine interface { OnPoll(ctx context.Context, job *Job) (*Transition, error) } +type SignerApprovalVerifier interface { + VerifySignerApproval(request RouteSubmitRequest) error +} + +type SignerApprovalVerifierFunc func(request RouteSubmitRequest) error + +func (savf SignerApprovalVerifierFunc) VerifySignerApproval( + request RouteSubmitRequest, +) error { + return savf(request) +} + type passiveEngine struct{} func NewPassiveEngine() Engine { diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index a50ca0c838..20e5e1248a 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -15,6 +15,7 @@ import ( type Service struct { store *Store engine Engine + signerApprovalVerifier SignerApprovalVerifier now func() time.Time mutex sync.Mutex migrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot @@ -32,6 +33,14 @@ func WithMigrationPlanQuoteTrustRoots( } } +func WithSignerApprovalVerifier( + verifier SignerApprovalVerifier, +) ServiceOption { + return func(service *Service) { + service.signerApprovalVerifier = verifier + } +} + func NewService( handle persistence.BasicHandle, engine Engine, @@ -51,6 +60,9 @@ func NewService( engine: engine, now: func() time.Time { return time.Now().UTC() }, } + if verifier, ok := engine.(SignerApprovalVerifier); ok { + service.signerApprovalVerifier = verifier + } for _, option := range options { option(service) } @@ -157,6 +169,7 @@ func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, er input.Request, validationOptions{ migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + signerApprovalVerifier: s.signerApprovalVerifier, }, ) if err != nil { @@ -174,6 +187,7 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, requireFreshMigrationPlanQuote: true, migrationPlanQuoteVerificationNow: s.now(), + signerApprovalVerifier: s.signerApprovalVerifier, } if err := validateSubmitInput(route, input, submitValidationOptions); err != nil { return StepResult{}, err @@ -183,6 +197,7 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm input.Request, validationOptions{ migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + signerApprovalVerifier: s.signerApprovalVerifier, }, ) if err != nil { @@ -282,6 +297,7 @@ func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollIn input, validationOptions{ migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + signerApprovalVerifier: s.signerApprovalVerifier, }, ); err != nil { return StepResult{}, err diff --git a/pkg/covenantsigner/types.go b/pkg/covenantsigner/types.go index 3eff35a893..50fb6efd02 100644 --- a/pkg/covenantsigner/types.go +++ b/pkg/covenantsigner/types.go @@ -172,6 +172,18 @@ type ArtifactApprovalEnvelope struct { Approvals []ArtifactRoleApproval `json:"approvals"` } +type SignerApprovalCertificate struct { + CertificateVersion uint32 `json:"certificateVersion"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + ApprovalDigest string `json:"approvalDigest"` + WalletPublicKey string `json:"walletPublicKey"` + SignerSetHash string `json:"signerSetHash"` + Signature string `json:"signature"` + ActiveMembers []uint32 `json:"activeMembers,omitempty"` + InactiveMembers []uint32 `json:"inactiveMembers,omitempty"` + EndBlock *uint64 `json:"endBlock,omitempty"` +} + type SigningRequirements struct { SignerRequired bool `json:"signerRequired"` CustodianRequired bool `json:"custodianRequired"` @@ -191,6 +203,7 @@ type RouteSubmitRequest struct { MigrationPlanQuote *MigrationDestinationPlanQuote `json:"migrationPlanQuote,omitempty"` MigrationTransactionPlan *MigrationTransactionPlan `json:"migrationTransactionPlan,omitempty"` ArtifactApprovals *ArtifactApprovalEnvelope `json:"artifactApprovals,omitempty"` + SignerApproval *SignerApprovalCertificate `json:"signerApproval,omitempty"` ArtifactSignatures []string `json:"artifactSignatures"` Artifacts map[RecoveryPathID]ArtifactRecord `json:"artifacts"` ScriptTemplate json.RawMessage `json:"scriptTemplate"` diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index d6d0cd3eab..7243a5ff83 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -15,6 +15,7 @@ import ( "math/big" "reflect" "regexp" + "sort" "strings" "time" @@ -27,6 +28,7 @@ const ( canonicalAnchorValueSats uint64 = 330 migrationTransactionPlanVersion uint32 = 1 artifactApprovalVersion uint32 = 1 + signerApprovalCertificateVersion uint32 = 1 migrationPlanQuoteVersion uint32 = 1 migrationPlanQuoteSignatureVersion uint32 = 1 ) @@ -34,6 +36,7 @@ const ( const ( migrationPlanQuoteSignatureAlgorithm = "ed25519" migrationPlanQuoteSigningDomain = "migration-plan-quote-v1:" + signerApprovalSignatureAlgorithm = "tecdsa-secp256k1" ) var artifactApprovalTypeHash = crypto.Keccak256Hash([]byte( @@ -57,6 +60,10 @@ func (ie *inputError) Error() string { return ie.message } +func NewInputError(message string) error { + return &inputError{message: message} +} + func strictUnmarshal(data []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() @@ -79,6 +86,7 @@ type validationOptions struct { migrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot requireFreshMigrationPlanQuote bool migrationPlanQuoteVerificationNow time.Time + signerApprovalVerifier SignerApprovalVerifier } func resolveValidationOptions(options []validationOptions) validationOptions { @@ -153,6 +161,14 @@ func validateBytes32HexString(name string, value string) error { return nil } +func validateUint32Range(name string, value uint64) error { + if value > math.MaxUint32 { + return &inputError{fmt.Sprintf("%s must fit in uint32", name)} + } + + return nil +} + func decodeBytes32HexString(name string, value string) ([32]byte, error) { var decoded [32]byte @@ -169,6 +185,176 @@ func decodeBytes32HexString(name string, value string) ([32]byte, error) { return decoded, nil } +func normalizeSignerApprovalMemberIndexes( + name string, + values []uint32, +) ([]uint32, error) { + if len(values) == 0 { + return nil, nil + } + + normalized := append([]uint32{}, values...) + seen := make(map[uint32]struct{}, len(normalized)) + for i, value := range normalized { + if value == 0 { + return nil, &inputError{ + fmt.Sprintf("%s[%d] must be greater than zero", name, i), + } + } + if err := validateUint32Range(name, uint64(value)); err != nil { + return nil, err + } + if _, ok := seen[value]; ok { + return nil, &inputError{ + fmt.Sprintf("%s[%d] duplicates member %d", name, i, value), + } + } + seen[value] = struct{}{} + } + + sort.Slice(normalized, func(i, j int) bool { + return normalized[i] < normalized[j] + }) + + return normalized, nil +} + +func normalizeSignerApprovalCertificate( + request RouteSubmitRequest, +) (*SignerApprovalCertificate, error) { + if request.SignerApproval == nil { + return nil, nil + } + if request.ArtifactApprovals == nil { + return nil, &inputError{ + "request.artifactApprovals is required when request.signerApproval is present", + } + } + + signerApproval := request.SignerApproval + if signerApproval.CertificateVersion != signerApprovalCertificateVersion { + return nil, &inputError{ + fmt.Sprintf( + "request.signerApproval.certificateVersion must equal %d", + signerApprovalCertificateVersion, + ), + } + } + if signerApproval.SignatureAlgorithm != signerApprovalSignatureAlgorithm { + return nil, &inputError{ + fmt.Sprintf( + "request.signerApproval.signatureAlgorithm must equal %s", + signerApprovalSignatureAlgorithm, + ), + } + } + if err := validateBytes32HexString( + "request.signerApproval.approvalDigest", + signerApproval.ApprovalDigest, + ); err != nil { + return nil, err + } + if err := validateHexString( + "request.signerApproval.walletPublicKey", + signerApproval.WalletPublicKey, + ); err != nil { + return nil, err + } + if len(signerApproval.WalletPublicKey) != 132 { + return nil, &inputError{ + "request.signerApproval.walletPublicKey must be a 65-byte uncompressed secp256k1 public key", + } + } + normalizedWalletPublicKey := normalizeLowerHex(signerApproval.WalletPublicKey) + if !strings.HasPrefix(normalizedWalletPublicKey, "0x04") { + return nil, &inputError{ + "request.signerApproval.walletPublicKey must be a 65-byte uncompressed secp256k1 public key", + } + } + if err := validateBytes32HexString( + "request.signerApproval.signerSetHash", + signerApproval.SignerSetHash, + ); err != nil { + return nil, err + } + if err := validateHexString( + "request.signerApproval.signature", + signerApproval.Signature, + ); err != nil { + return nil, err + } + + expectedApprovalDigest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + if err != nil { + return nil, err + } + + normalizedApprovalDigest := normalizeLowerHex(signerApproval.ApprovalDigest) + if normalizedApprovalDigest != "0x"+hex.EncodeToString(expectedApprovalDigest) { + return nil, &inputError{ + "request.signerApproval.approvalDigest must match the canonical artifactApprovals payload digest", + } + } + + normalizedSignerApproval := &SignerApprovalCertificate{ + CertificateVersion: signerApprovalCertificateVersion, + SignatureAlgorithm: signerApprovalSignatureAlgorithm, + ApprovalDigest: normalizedApprovalDigest, + WalletPublicKey: normalizedWalletPublicKey, + SignerSetHash: normalizeLowerHex(signerApproval.SignerSetHash), + Signature: normalizeLowerHex(signerApproval.Signature), + } + + activeMembers, err := normalizeSignerApprovalMemberIndexes( + "request.signerApproval.activeMembers", + signerApproval.ActiveMembers, + ) + if err != nil { + return nil, err + } + if len(activeMembers) > 0 { + normalizedSignerApproval.ActiveMembers = activeMembers + } + + inactiveMembers, err := normalizeSignerApprovalMemberIndexes( + "request.signerApproval.inactiveMembers", + signerApproval.InactiveMembers, + ) + if err != nil { + return nil, err + } + if len(inactiveMembers) > 0 { + normalizedSignerApproval.InactiveMembers = inactiveMembers + } + + if len(activeMembers) > 0 && len(inactiveMembers) > 0 { + activeSet := make(map[uint32]struct{}, len(activeMembers)) + for _, value := range activeMembers { + activeSet[value] = struct{}{} + } + for _, value := range inactiveMembers { + if _, ok := activeSet[value]; ok { + return nil, &inputError{ + "request.signerApproval.activeMembers and request.signerApproval.inactiveMembers must not overlap", + } + } + } + } + + if signerApproval.EndBlock != nil { + if err := validateUint32Range( + "request.signerApproval.endBlock", + *signerApproval.EndBlock, + ); err != nil { + return nil, err + } + endBlock := *signerApproval.EndBlock + normalizedSignerApproval.EndBlock = &endBlock + } + + return normalizedSignerApproval, nil +} + func normalizeLowerHex(value string) string { return strings.ToLower(value) } @@ -885,6 +1071,22 @@ func validateArtifactSignatures(signatures []string) ([]string, error) { return normalizedSignatures, nil } +func requiredStructuredArtifactApprovalRoles(route TemplateID) ([]ArtifactApprovalRole, error) { + switch route { + case TemplateQcV1: + return []ArtifactApprovalRole{ + ArtifactApprovalRoleDepositor, + ArtifactApprovalRoleCustodian, + }, nil + case TemplateSelfV1: + return []ArtifactApprovalRole{ + ArtifactApprovalRoleDepositor, + }, nil + default: + return nil, &inputError{"unsupported request.route"} + } +} + func requiredArtifactApprovalRoles(route TemplateID) ([]ArtifactApprovalRole, error) { switch route { case TemplateQcV1: @@ -912,6 +1114,11 @@ func normalizeArtifactApprovals( route TemplateID, request RouteSubmitRequest, ) (*ArtifactApprovalEnvelope, []string, error) { + normalizedSignerApproval, err := normalizeSignerApprovalCertificate(request) + if err != nil { + return nil, nil, err + } + normalizedLegacySignatures, err := validateArtifactSignatures(request.ArtifactSignatures) if err != nil { return nil, nil, err @@ -964,6 +1171,9 @@ func normalizeArtifactApprovals( } requiredRoles, err := requiredArtifactApprovalRoles(route) + if normalizedSignerApproval != nil { + requiredRoles, err = requiredStructuredArtifactApprovalRoles(route) + } if err != nil { return nil, nil, err } @@ -999,7 +1209,7 @@ func normalizeArtifactApprovals( approvalsByRole[approval.Role] = normalizeLowerHex(approval.Signature) } - derivedLegacySignatures := make([]string, len(requiredRoles)) + derivedLegacySignatures := make([]string, 0, len(requiredRoles)+1) normalizedApprovals := &ArtifactApprovalEnvelope{ Payload: ArtifactApprovalPayload{ ApprovalVersion: artifactApprovalVersion, @@ -1020,19 +1230,31 @@ func normalizeArtifactApprovals( )} } - derivedLegacySignatures[i] = signature + derivedLegacySignatures = append(derivedLegacySignatures, signature) normalizedApprovals.Approvals[i] = ArtifactRoleApproval{ Role: role, Signature: signature, } } + if normalizedSignerApproval != nil { + derivedLegacySignatures = append( + derivedLegacySignatures, + normalizedSignerApproval.Signature, + ) + } + + canonicalSignatureError := "request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals" + if normalizedSignerApproval != nil { + canonicalSignatureError = "request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals and request.signerApproval" + } + if len(normalizedLegacySignatures) != len(derivedLegacySignatures) { - return nil, nil, &inputError{"request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals"} + return nil, nil, &inputError{canonicalSignatureError} } for i := range derivedLegacySignatures { if normalizedLegacySignatures[i] != derivedLegacySignatures[i] { - return nil, nil, &inputError{"request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals"} + return nil, nil, &inputError{canonicalSignatureError} } } @@ -1216,6 +1438,10 @@ func normalizeRouteSubmitRequest( if err != nil { return RouteSubmitRequest{}, err } + normalizedSignerApproval, err := normalizeSignerApprovalCertificate(request) + if err != nil { + return RouteSubmitRequest{}, err + } normalizedScriptTemplate, err := normalizeScriptTemplate(request.Route, request.ScriptTemplate) if err != nil { @@ -1253,6 +1479,7 @@ func normalizeRouteSubmitRequest( MigrationPlanQuote: normalizedMigrationPlanQuote, MigrationTransactionPlan: normalizeMigrationTransactionPlan(request.MigrationTransactionPlan), ArtifactApprovals: normalizedArtifactApprovals, + SignerApproval: normalizedSignerApproval, ArtifactSignatures: normalizedArtifactSignatures, Artifacts: normalizeArtifacts(request.Artifacts), ScriptTemplate: normalizedScriptTemplate, @@ -1370,6 +1597,25 @@ func validateCommonRequest( return &inputError{"unsupported request.route"} } + if request.SignerApproval != nil { + if resolvedOptions.signerApprovalVerifier == nil { + return &inputError{ + "request.signerApproval cannot be verified by this signer deployment", + } + } + + normalizedRequest, err := normalizeRouteSubmitRequest(request, resolvedOptions) + if err != nil { + return err + } + + if err := resolvedOptions.signerApprovalVerifier.VerifySignerApproval( + normalizedRequest, + ); err != nil { + return err + } + } + return nil } diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index 0195d79ae3..f4b699c329 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -42,6 +42,97 @@ func newCovenantSignerEngine(node *node) covenantsigner.Engine { return &covenantSignerEngine{node: node} } +func (cse *covenantSignerEngine) VerifySignerApproval( + request covenantsigner.RouteSubmitRequest, +) error { + if request.SignerApproval == nil { + return nil + } + + signerPublicKey, err := cse.resolveSignerApprovalTemplatePublicKey(request) + if err != nil { + return covenantsigner.NewInputError(err.Error()) + } + + expectedWalletPublicKeyBytes, err := marshalPublicKey(signerPublicKey) + if err != nil { + return fmt.Errorf( + "cannot marshal signer public key for signer approval verification: %w", + err, + ) + } + + expectedWalletPublicKey := "0x" + hex.EncodeToString(expectedWalletPublicKeyBytes) + if !strings.EqualFold( + request.SignerApproval.WalletPublicKey, + expectedWalletPublicKey, + ) { + return covenantsigner.NewInputError( + "request.signerApproval.walletPublicKey must match request.scriptTemplate.signerPublicKey", + ) + } + + walletChainData, err := cse.node.chain.GetWallet( + bitcoin.PublicKeyHash(signerPublicKey), + ) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "no wallet") { + return covenantsigner.NewInputError( + "request.signerApproval.walletPublicKey must resolve to a registered on-chain wallet", + ) + } + + return fmt.Errorf( + "cannot resolve on-chain wallet for signer approval verification: %w", + err, + ) + } + + expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( + signerPublicKey, + walletChainData, + cse.node.groupParameters, + ) + if err != nil { + return fmt.Errorf( + "cannot compute signer approval signer set hash: %w", + err, + ) + } + + if err := verifySignerApprovalCertificate( + request.SignerApproval, + expectedSignerSetHash, + ); err != nil { + return covenantsigner.NewInputError( + fmt.Sprintf("request.signerApproval is invalid: %v", err), + ) + } + + return nil +} + +func (cse *covenantSignerEngine) resolveSignerApprovalTemplatePublicKey( + request covenantsigner.RouteSubmitRequest, +) (*ecdsa.PublicKey, error) { + switch request.Route { + case covenantsigner.TemplateSelfV1: + template, err := decodeSelfV1Template(request.ScriptTemplate) + if err != nil { + return nil, err + } + return parseCompressedPublicKey(template.SignerPublicKey) + case covenantsigner.TemplateQcV1: + template, err := decodeQcV1Template(request.ScriptTemplate) + if err != nil { + return nil, err + } + return parseCompressedPublicKey(template.SignerPublicKey) + default: + return nil, fmt.Errorf("unsupported covenant route") + } +} + func (cse *covenantSignerEngine) OnSubmit( ctx context.Context, job *covenantsigner.Job, diff --git a/pkg/tbtc/signer_approval_certificate.go b/pkg/tbtc/signer_approval_certificate.go index 0bc42689ba..6b2ceb1725 100644 --- a/pkg/tbtc/signer_approval_certificate.go +++ b/pkg/tbtc/signer_approval_certificate.go @@ -13,6 +13,7 @@ import ( "github.com/btcsuite/btcd/btcec" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/covenantsigner" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" ) @@ -23,21 +24,6 @@ const ( signerApprovalCertificateSignerSetDomain = "covenant-signer-set-v1:" ) -// signerApprovalCertificate is a spike artifact for evaluating whether the -// current tECDSA signer stack can emit a single offline-verifiable `S` -// approval over an arbitrary approval digest. -type signerApprovalCertificate struct { - CertificateVersion uint32 `json:"certificateVersion"` - SignatureAlgorithm string `json:"signatureAlgorithm"` - WalletPublicKey string `json:"walletPublicKey"` - SignerSetHash string `json:"signerSetHash"` - ApprovalDigest string `json:"approvalDigest"` - Signature string `json:"signature"` - ActiveMembers []uint32 `json:"activeMembers,omitempty"` - InactiveMembers []uint32 `json:"inactiveMembers,omitempty"` - EndBlock uint64 `json:"endBlock"` -} - type signerApprovalCertificateSignerSetPayload struct { WalletID string `json:"walletId"` WalletPublicKey string `json:"walletPublicKey"` @@ -49,7 +35,7 @@ func (se *signingExecutor) issueSignerApprovalCertificate( ctx context.Context, approvalDigest []byte, startBlock uint64, -) (*signerApprovalCertificate, error) { +) (*covenantsigner.SignerApprovalCertificate, error) { if len(approvalDigest) != sha256.Size { return nil, fmt.Errorf( "approval digest must be exactly %d bytes", @@ -94,7 +80,7 @@ func buildSignerApprovalCertificate( signature *tecdsa.Signature, activityReport *signingActivityReport, endBlock uint64, -) (*signerApprovalCertificate, error) { +) (*covenantsigner.SignerApprovalCertificate, error) { if len(approvalDigest) != sha256.Size { return nil, fmt.Errorf( "approval digest must be exactly %d bytes", @@ -117,7 +103,7 @@ func buildSignerApprovalCertificate( } signerSetHash, err := computeSignerApprovalCertificateSignerSetHash( - wallet, + wallet.publicKey, walletChainData, groupParameters, ) @@ -130,15 +116,15 @@ func buildSignerApprovalCertificate( S: signature.S, }).Serialize() - certificate := &signerApprovalCertificate{ + certificate := &covenantsigner.SignerApprovalCertificate{ CertificateVersion: signerApprovalCertificateVersion, SignatureAlgorithm: signerApprovalCertificateSignatureAlgorithm, WalletPublicKey: "0x" + hex.EncodeToString(walletPublicKeyBytes), SignerSetHash: signerSetHash, ApprovalDigest: "0x" + hex.EncodeToString(approvalDigest), Signature: "0x" + hex.EncodeToString(signatureBytes), - EndBlock: endBlock, } + certificate.EndBlock = &endBlock if activityReport != nil { certificate.ActiveMembers = normalizeSignerApprovalMemberIndexes( @@ -153,7 +139,7 @@ func buildSignerApprovalCertificate( } func computeSignerApprovalCertificateSignerSetHash( - wallet wallet, + walletPublicKey *ecdsa.PublicKey, walletChainData *WalletChainData, groupParameters *GroupParameters, ) (string, error) { @@ -170,7 +156,7 @@ func computeSignerApprovalCertificateSignerSetHash( return "", fmt.Errorf("wallet chain data must include members IDs hash") } - walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) + walletPublicKeyBytes, err := marshalPublicKey(walletPublicKey) if err != nil { return "", err } @@ -193,7 +179,7 @@ func computeSignerApprovalCertificateSignerSetHash( } func verifySignerApprovalCertificate( - certificate *signerApprovalCertificate, + certificate *covenantsigner.SignerApprovalCertificate, expectedSignerSetHash string, ) error { if certificate == nil { diff --git a/pkg/tbtc/signer_approval_certificate_test.go b/pkg/tbtc/signer_approval_certificate_test.go index 95ceb6c0d5..2fac70ed59 100644 --- a/pkg/tbtc/signer_approval_certificate_test.go +++ b/pkg/tbtc/signer_approval_certificate_test.go @@ -1,14 +1,133 @@ package tbtc import ( + "bytes" "context" + "crypto/ecdsa" "crypto/sha256" "encoding/hex" + "encoding/json" + "strings" "testing" + "github.com/btcsuite/btcd/btcec" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/covenantsigner" ) +func validStructuredSignerApprovalVerificationRequest( + t *testing.T, + node *node, + walletPublicKey *ecdsa.PublicKey, + route covenantsigner.TemplateID, +) covenantsigner.RouteSubmitRequest { + t.Helper() + + executor, ok, err := node.getSigningExecutor(walletPublicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + + startBlock, err := executor.getCurrentBlockFn() + if err != nil { + t.Fatal(err) + } + + depositorPrivateKey, _ := btcec.PrivKeyFromBytes( + btcec.S256(), + bytes.Repeat([]byte{0xaa}, 32), + ) + + request := covenantsigner.RouteSubmitRequest{ + Route: route, + ArtifactApprovals: &covenantsigner.ArtifactApprovalEnvelope{ + Payload: covenantsigner.ArtifactApprovalPayload{ + ApprovalVersion: 1, + Route: route, + ScriptTemplateID: route, + DestinationCommitmentHash: "0x" + strings.Repeat("11", 32), + PlanCommitmentHash: "0x" + strings.Repeat("22", 32), + }, + }, + } + + switch route { + case covenantsigner.TemplateSelfV1: + templateJSON, err := json.Marshal(&covenantsigner.SelfV1Template{ + Template: covenantsigner.TemplateSelfV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPrivateKey.PubKey().SerializeCompressed()), + SignerPublicKey: "0x" + hex.EncodeToString((*btcec.PublicKey)(walletPublicKey).SerializeCompressed()), + Delta2: 4320, + }) + if err != nil { + t.Fatal(err) + } + request.ScriptTemplate = templateJSON + request.ArtifactApprovals.Approvals = []covenantsigner.ArtifactRoleApproval{ + { + Role: covenantsigner.ArtifactApprovalRoleDepositor, + Signature: testSignArtifactApproval( + t, + depositorPrivateKey, + request.ArtifactApprovals.Payload, + ), + }, + } + case covenantsigner.TemplateQcV1: + custodianPrivateKey, _ := btcec.PrivKeyFromBytes( + btcec.S256(), + bytes.Repeat([]byte{0xbb}, 32), + ) + templateJSON, err := json.Marshal(&covenantsigner.QcV1Template{ + Template: covenantsigner.TemplateQcV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPrivateKey.PubKey().SerializeCompressed()), + CustodianPublicKey: "0x" + hex.EncodeToString(custodianPrivateKey.PubKey().SerializeCompressed()), + SignerPublicKey: "0x" + hex.EncodeToString((*btcec.PublicKey)(walletPublicKey).SerializeCompressed()), + Beta: 144, + Delta2: 4320, + }) + if err != nil { + t.Fatal(err) + } + request.ScriptTemplate = templateJSON + request.ArtifactApprovals.Approvals = []covenantsigner.ArtifactRoleApproval{ + { + Role: covenantsigner.ArtifactApprovalRoleDepositor, + Signature: testSignArtifactApproval( + t, + depositorPrivateKey, + request.ArtifactApprovals.Payload, + ), + }, + { + Role: covenantsigner.ArtifactApprovalRoleCustodian, + Signature: testSignArtifactApproval( + t, + custodianPrivateKey, + request.ArtifactApprovals.Payload, + ), + }, + } + default: + t.Fatalf("unsupported route %s", route) + } + + certificate, err := executor.issueSignerApprovalCertificate( + context.Background(), + testArtifactApprovalDigest(t, request.ArtifactApprovals.Payload), + startBlock, + ) + if err != nil { + t.Fatal(err) + } + request.SignerApproval = certificate + + return request +} + func TestSigningExecutorCanIssueSignerApprovalCertificateForArbitraryDigest(t *testing.T) { node, _, walletPublicKey := setupCovenantSignerTestNode(t) @@ -46,7 +165,7 @@ func TestSigningExecutorCanIssueSignerApprovalCertificateForArbitraryDigest(t *t } expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( - executor.wallet(), + executor.wallet().publicKey, walletChainData, executor.groupParameters, ) @@ -78,7 +197,7 @@ func TestSigningExecutorCanIssueSignerApprovalCertificateForArbitraryDigest(t *t certificate.ActiveMembers, ) } - if certificate.EndBlock < startBlock { + if certificate.EndBlock == nil || *certificate.EndBlock < startBlock { t.Fatalf( "expected end block [%v] to be >= start block [%v]", certificate.EndBlock, @@ -121,7 +240,7 @@ func TestSignerApprovalCertificateVerificationRejectsTamperedDigest(t *testing.T } expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( - executor.wallet(), + executor.wallet().publicKey, walletChainData, executor.groupParameters, ) @@ -140,9 +259,6 @@ func TestSignerApprovalCertificateVerificationRejectsTamperedDigest(t *testing.T func TestSignerApprovalCertificateSignerSetHashBindsOnChainWalletIdentityAndThreshold(t *testing.T) { _, _, walletPublicKey := setupCovenantSignerTestNode(t) - baseWallet := wallet{ - publicKey: walletPublicKey, - } baseWalletChainData := &WalletChainData{ EcdsaWalletID: sha256.Sum256([]byte("wallet-id-base")), MembersIDsHash: sha256.Sum256([]byte("members-hash-base")), @@ -154,7 +270,7 @@ func TestSignerApprovalCertificateSignerSetHashBindsOnChainWalletIdentityAndThre } baseHash, err := computeSignerApprovalCertificateSignerSetHash( - baseWallet, + walletPublicKey, baseWalletChainData, baseGroupParameters, ) @@ -163,7 +279,7 @@ func TestSignerApprovalCertificateSignerSetHashBindsOnChainWalletIdentityAndThre } changedMembersHash, err := computeSignerApprovalCertificateSignerSetHash( - baseWallet, + walletPublicKey, &WalletChainData{ EcdsaWalletID: baseWalletChainData.EcdsaWalletID, MembersIDsHash: sha256.Sum256([]byte("members-hash-changed")), @@ -178,7 +294,7 @@ func TestSignerApprovalCertificateSignerSetHashBindsOnChainWalletIdentityAndThre } changedWalletIDHash, err := computeSignerApprovalCertificateSignerSetHash( - baseWallet, + walletPublicKey, &WalletChainData{ EcdsaWalletID: sha256.Sum256([]byte("wallet-id-changed")), MembersIDsHash: baseWalletChainData.MembersIDsHash, @@ -193,7 +309,7 @@ func TestSignerApprovalCertificateSignerSetHashBindsOnChainWalletIdentityAndThre } thresholdChangedHash, err := computeSignerApprovalCertificateSignerSetHash( - baseWallet, + walletPublicKey, baseWalletChainData, &GroupParameters{ GroupSize: 3, @@ -208,3 +324,36 @@ func TestSignerApprovalCertificateSignerSetHashBindsOnChainWalletIdentityAndThre t.Fatal("expected signer set hash to change when honest threshold changes") } } + +func TestCovenantSignerEngineVerifySignerApprovalAcceptsValidCertificate(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + + if err := (&covenantSignerEngine{node: node}).VerifySignerApproval(request); err != nil { + t.Fatalf("expected signer approval verification to succeed: %v", err) + } +} + +func TestCovenantSignerEngineVerifySignerApprovalRejectsWalletPublicKeyMismatch(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + request.SignerApproval.WalletPublicKey = "0x04" + strings.Repeat("55", 64) + + err := (&covenantSignerEngine{node: node}).VerifySignerApproval(request) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval.walletPublicKey must match request.scriptTemplate.signerPublicKey", + ) { + t.Fatalf("expected wallet public key mismatch error, got %v", err) + } +} From 9542560176c4568a7ca0245f20f5341120c35e7a Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 11:18:46 -0500 Subject: [PATCH 030/143] Require structured signer approval on engine path --- pkg/covenantsigner/covenantsigner_test.go | 101 ++++++++++-------- ...covenant_recovery_approval_vectors_v1.json | 36 +++++-- pkg/covenantsigner/validation.go | 5 + pkg/tbtc/covenant_signer_test.go | 88 ++++++++++++--- 4 files changed, 162 insertions(+), 68 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index bd4e86f1a6..5def84d048 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -102,6 +102,7 @@ func mustJSON(t *testing.T, value any) []byte { type approvalContractVector struct { CanonicalSubmitRequest json.RawMessage `json:"canonicalSubmitRequest"` + ExpectedApprovalDigest string `json:"expectedApprovalDigest"` ExpectedRequestDigest string `json:"expectedRequestDigest"` } @@ -129,7 +130,7 @@ type migrationPlanQuoteSigningVectorsFile struct { func loadApprovalContractVector( t *testing.T, route TemplateID, -) (RouteSubmitRequest, string) { +) (RouteSubmitRequest, string, string) { t.Helper() data, err := os.ReadFile("testdata/covenant_recovery_approval_vectors_v1.json") @@ -158,7 +159,7 @@ func loadApprovalContractVector( t.Fatal(err) } - return request, vector.ExpectedRequestDigest + return request, vector.ExpectedApprovalDigest, vector.ExpectedRequestDigest } func loadMigrationPlanQuoteSigningVectors( @@ -1839,19 +1840,29 @@ func TestServiceRejectsMigrationTransactionPlanBoundToDifferentDestinationCommit } } -func TestServiceAcceptsArtifactApprovalsWithCanonicalLegacySignatures(t *testing.T) { +func TestServiceAcceptsStructuredSignerApprovalWithCanonicalLegacySignatures(t *testing.T) { handle := newMemoryHandle() - service, err := NewService(handle, &scriptedEngine{}) + service, err := NewService( + handle, + &scriptedEngine{}, + WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(RouteSubmitRequest) error { + return nil + })), + ) if err != nil { t.Fatal(err) } - request := baseRequest(TemplateQcV1) + request := structuredSignerApprovalRequest(TemplateQcV1) request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ - request.ArtifactApprovals.Approvals[2], request.ArtifactApprovals.Approvals[0], request.ArtifactApprovals.Approvals[1], } + request.ArtifactSignatures = canonicalArtifactSignaturesWithSignerApproval( + request.Route, + request.ArtifactApprovals, + request.SignerApproval, + ) result, err := service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ RouteRequestID: "orq_artifact_approvals", @@ -1960,6 +1971,34 @@ func TestServiceRejectsStructuredSignerApprovalWithoutVerifier(t *testing.T) { } } +func TestServiceRejectsLegacySignerApprovalPathWhenVerifierConfigured(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(RouteSubmitRequest) error { + return nil + })), + ) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateSelfV1) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_legacy_signer_approval_path", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval is required when request.artifactApprovals is present", + ) { + t.Fatalf("expected missing signer approval error, got %v", err) + } +} + func TestServiceRejectsStructuredSignerApprovalWithLegacySignerRole(t *testing.T) { handle := newMemoryHandle() service, err := NewService( @@ -2444,40 +2483,6 @@ func TestServicePollAcceptsStoredMigrationPlanQuoteAfterQuoteExpiry(t *testing.T } } -func TestServiceAcceptsKnownBadSignerApprovalSignatureInPhase1(t *testing.T) { - handle := newMemoryHandle() - service, err := NewService( - handle, - &scriptedEngine{}, - WithMigrationPlanQuoteTrustRoots([]MigrationPlanQuoteTrustRoot{ - testMigrationPlanQuoteTrustRoot, - }), - ) - if err != nil { - t.Fatal(err) - } - - request := requestWithValidMigrationPlanQuote(TemplateSelfV1) - for i := range request.ArtifactApprovals.Approvals { - if request.ArtifactApprovals.Approvals[i].Role == ArtifactApprovalRoleSigner { - request.ArtifactApprovals.Approvals[i].Signature = "0xdeadbeef" - } - } - request.ArtifactSignatures = canonicalArtifactSignatures( - request.Route, - request.ArtifactApprovals, - ) - - _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ - RouteRequestID: "ors_signer_gap", - Stage: StageSignerCoordination, - Request: request, - }) - if err != nil { - t.Fatalf("expected phase-1 signer approval gap to remain non-fatal, got %v", err) - } -} - func TestServicePollAcceptsEquivalentArtifactApprovalRequestVariants(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ @@ -2584,7 +2589,19 @@ func TestArtifactApprovalDigestMatchesPhase1Contract(t *testing.T) { func TestApprovalContractVectorsMatchExpectedRequestDigests(t *testing.T) { for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} { t.Run(string(route), func(t *testing.T) { - request, expectedDigest := loadApprovalContractVector(t, route) + request, expectedApprovalDigest, expectedDigest := loadApprovalContractVector(t, route) + + digestBytes, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + if err != nil { + t.Fatal(err) + } + if actualApprovalDigest := "0x" + hex.EncodeToString(digestBytes); actualApprovalDigest != expectedApprovalDigest { + t.Fatalf( + "expected approval digest %s, got %s", + expectedApprovalDigest, + actualApprovalDigest, + ) + } digest, err := requestDigest(request) if err != nil { @@ -2601,7 +2618,7 @@ func TestApprovalContractVectorsMatchExpectedRequestDigests(t *testing.T) { func TestApprovalContractVectorsNormalizeEquivalentVariants(t *testing.T) { for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} { t.Run(string(route), func(t *testing.T) { - canonicalRequest, expectedDigest := loadApprovalContractVector(t, route) + canonicalRequest, _, expectedDigest := loadApprovalContractVector(t, route) normalizedCanonical, err := normalizeRouteSubmitRequest(canonicalRequest) if err != nil { diff --git a/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json index 1ee2333ec3..7416c451de 100644 --- a/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json +++ b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json @@ -3,6 +3,7 @@ "scope": "covenant_recovery_approval_contract_v1", "vectors": { "qc_v1": { + "expectedApprovalDigest": "0xa6ffb42318a8e8b3b9669324ee5ad393133afcc9cc81044739cbaa77d5fa34c9", "canonicalSubmitRequest": { "facadeRequestId": "rf_vector_qc_v1", "idempotencyKey": "idem-qc-vector-v1", @@ -57,13 +58,20 @@ { "role": "C", "signature": "0xc0c0" - }, - { - "role": "S", - "signature": "0x5050" } ] }, + "signerApproval": { + "certificateVersion": 1, + "signatureAlgorithm": "tecdsa-secp256k1", + "approvalDigest": "0xa6ffb42318a8e8b3b9669324ee5ad393133afcc9cc81044739cbaa77d5fa34c9", + "walletPublicKey": "0x04d140d1eedb94f53ce43e0f4d68e8e0de6d6f2a444ef98f2a0e6c0f7fca02ef7dc4cb14e7b0f7c23787c93ca4d978f312c64379f38d9f52f86d1a89f0f8572f9f", + "signerSetHash": "0xabababababababababababababababababababababababababababababababab", + "signature": "0x5050", + "activeMembers": [1, 2, 3], + "inactiveMembers": [4, 5], + "endBlock": 123 + }, "artifactSignatures": [ "0xd0d0", "0xc0c0", @@ -83,9 +91,10 @@ "custodianRequired": true } }, - "expectedRequestDigest": "0x4bb14155042065021708e80e35470a27640d68fc3e2a642c3cb2823595ea66b1" + "expectedRequestDigest": "0x1a435b7fda4d048b8b4b9fb1448c14f581d4b3e38ddfe526b17b3c5ef719498c" }, "self_v1": { + "expectedApprovalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", "canonicalSubmitRequest": { "facadeRequestId": "rf_vector_self_v1", "idempotencyKey": "idem-self-vector-v1", @@ -136,13 +145,20 @@ { "role": "D", "signature": "0xd0d0" - }, - { - "role": "S", - "signature": "0x5050" } ] }, + "signerApproval": { + "certificateVersion": 1, + "signatureAlgorithm": "tecdsa-secp256k1", + "approvalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", + "walletPublicKey": "0x04d140d1eedb94f53ce43e0f4d68e8e0de6d6f2a444ef98f2a0e6c0f7fca02ef7dc4cb14e7b0f7c23787c93ca4d978f312c64379f38d9f52f86d1a89f0f8572f9f", + "signerSetHash": "0xabababababababababababababababababababababababababababababababab", + "signature": "0x5050", + "activeMembers": [1, 2, 3], + "inactiveMembers": [4, 5], + "endBlock": 123 + }, "artifactSignatures": [ "0xd0d0", "0x5050" @@ -159,7 +175,7 @@ "custodianRequired": false } }, - "expectedRequestDigest": "0x38c86be37817a1d4ec87bf5ec41a9022f44a03e08d7195c4280b7b91eae5bce2" + "expectedRequestDigest": "0xb5cd6a894a8e6bb5e68310e9c43560e91de1fc62bf0acf73b63e882332b2138a" } } } diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 7243a5ff83..649e64889a 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -1537,6 +1537,11 @@ func validateCommonRequest( if request.ArtifactApprovals == nil { return &inputError{"request.artifactApprovals is required"} } + if resolvedOptions.signerApprovalVerifier != nil && request.SignerApproval == nil { + return &inputError{ + "request.signerApproval is required when request.artifactApprovals is present", + } + } if err := validateArtifactApprovals(route, request); err != nil { return err } diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index ab244528d9..6f77413638 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -198,7 +198,14 @@ func TestCovenantSignerEngine_SubmitSelfV1Ready(t *testing.T) { }, } applyTestMigrationTransactionPlanCommitment(t, &request) - applyTestArtifactApprovals(t, &request, depositorPrivateKey, nil) + applyTestArtifactApprovals( + t, + node, + walletPublicKey, + &request, + depositorPrivateKey, + nil, + ) result, err := service.Submit(context.Background(), covenantsigner.TemplateSelfV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_self_ready", @@ -428,7 +435,14 @@ func TestCovenantSignerEngine_SubmitQcV1HandoffReady(t *testing.T) { }, } applyTestMigrationTransactionPlanCommitment(t, &request) - applyTestArtifactApprovals(t, &request, depositorPrivateKey, custodianPrivateKey) + applyTestArtifactApprovals( + t, + node, + walletPublicKey, + &request, + depositorPrivateKey, + custodianPrivateKey, + ) result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_qc_ready", @@ -668,7 +682,14 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsInvalidBeta(t *testing.T) { request.MigrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, request.MigrationDestination) request.DestinationCommitmentHash = request.MigrationDestination.DestinationCommitmentHash applyTestMigrationTransactionPlanCommitment(t, &request) - applyTestArtifactApprovals(t, &request, depositorPrivateKey, custodianPrivateKey) + applyTestArtifactApprovals( + t, + node, + walletPublicKey, + &request, + depositorPrivateKey, + custodianPrivateKey, + ) result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_qc_bad_beta", @@ -805,7 +826,14 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsScriptHashMismatch(t *testing.T) }, } applyTestMigrationTransactionPlanCommitment(t, &request) - applyTestArtifactApprovals(t, &request, depositorPrivateKey, custodianPrivateKey) + applyTestArtifactApprovals( + t, + node, + walletPublicKey, + &request, + depositorPrivateKey, + custodianPrivateKey, + ) result, err := service.Submit(context.Background(), covenantsigner.TemplateQcV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_qc_bad_script_hash", @@ -909,7 +937,14 @@ func TestCovenantSignerEngine_SubmitSelfV1RejectsZeroMaturityHeight(t *testing.T request.MigrationDestination.DestinationCommitmentHash = testDestinationCommitmentHash(t, request.MigrationDestination) request.DestinationCommitmentHash = request.MigrationDestination.DestinationCommitmentHash applyTestMigrationTransactionPlanCommitment(t, &request) - applyTestArtifactApprovals(t, &request, depositorPrivateKey, nil) + applyTestArtifactApprovals( + t, + node, + walletPublicKey, + &request, + depositorPrivateKey, + nil, + ) result, err := service.Submit(context.Background(), covenantsigner.TemplateSelfV1, covenantsigner.SignerSubmitInput{ RouteRequestID: "ors_self_zero", @@ -1212,6 +1247,8 @@ func testSignArtifactApproval( func applyTestArtifactApprovals( t *testing.T, + node *node, + walletPublicKey *ecdsa.PublicKey, request *covenantsigner.RouteSubmitRequest, depositorPrivateKey *btcec.PrivateKey, custodianPrivateKey *btcec.PrivateKey, @@ -1231,10 +1268,6 @@ func applyTestArtifactApprovals( Role: covenantsigner.ArtifactApprovalRoleDepositor, Signature: testSignArtifactApproval(t, depositorPrivateKey, payload), }, - { - Role: covenantsigner.ArtifactApprovalRoleSigner, - Signature: "0x5151", - }, } if request.Route == covenantsigner.TemplateQcV1 { @@ -1247,10 +1280,6 @@ func applyTestArtifactApprovals( Role: covenantsigner.ArtifactApprovalRoleCustodian, Signature: testSignArtifactApproval(t, custodianPrivateKey, payload), }, - { - Role: covenantsigner.ArtifactApprovalRoleSigner, - Signature: "0x5151", - }, } } @@ -1258,10 +1287,37 @@ func applyTestArtifactApprovals( Payload: payload, Approvals: approvals, } - request.ArtifactSignatures = make([]string, len(approvals)) - for i, approval := range approvals { - request.ArtifactSignatures[i] = approval.Signature + + executor, ok, err := node.getSigningExecutor(walletPublicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected node to control wallet signers") + } + + startBlock, err := executor.getCurrentBlockFn() + if err != nil { + t.Fatal(err) } + + signerApproval, err := executor.issueSignerApprovalCertificate( + context.Background(), + testArtifactApprovalDigest(t, payload), + startBlock, + ) + if err != nil { + t.Fatal(err) + } + request.SignerApproval = signerApproval + request.ArtifactSignatures = make([]string, 0, len(approvals)+1) + for _, approval := range approvals { + request.ArtifactSignatures = append(request.ArtifactSignatures, approval.Signature) + } + request.ArtifactSignatures = append( + request.ArtifactSignatures, + signerApproval.Signature, + ) } func applyTestMigrationTransactionPlanCommitment( From e617128aad28a67791226d555ac5703361428de9 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 11:32:51 -0500 Subject: [PATCH 031/143] Tighten signer approval cutover validation --- pkg/covenantsigner/covenantsigner_test.go | 30 ++++++++++++- pkg/covenantsigner/validation.go | 55 +++++++++++------------ 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 5def84d048..fee9dba640 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -1993,12 +1993,40 @@ func TestServiceRejectsLegacySignerApprovalPathWhenVerifierConfigured(t *testing }) if err == nil || !strings.Contains( err.Error(), - "request.signerApproval is required when request.artifactApprovals is present", + "request.signerApproval is required when the signer approval verifier is configured", ) { t.Fatalf("expected missing signer approval error, got %v", err) } } +func TestServiceRejectsStructuredSignerApprovalWithMismatchedApprovalDigest(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(RouteSubmitRequest) error { + return nil + })), + ) + if err != nil { + t.Fatal(err) + } + + request := structuredSignerApprovalRequest(TemplateSelfV1) + request.SignerApproval.ApprovalDigest = "0x" + strings.Repeat("11", 32) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_structured_signer_approval_bad_digest", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval.approvalDigest must match the canonical artifactApprovals payload digest", + ) { + t.Fatalf("expected signer approval digest mismatch error, got %v", err) + } +} func TestServiceRejectsStructuredSignerApprovalWithLegacySignerRole(t *testing.T) { handle := newMemoryHandle() service, err := NewService( diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 649e64889a..8bc2404bf5 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -1106,76 +1106,79 @@ func requiredArtifactApprovalRoles(route TemplateID) ([]ArtifactApprovalRole, er } func validateArtifactApprovals(route TemplateID, request RouteSubmitRequest) error { - _, _, err := normalizeArtifactApprovals(route, request) + _, _, _, err := normalizeArtifactApprovals(route, request) return err } func normalizeArtifactApprovals( route TemplateID, request RouteSubmitRequest, -) (*ArtifactApprovalEnvelope, []string, error) { +) (*ArtifactApprovalEnvelope, *SignerApprovalCertificate, []string, error) { normalizedSignerApproval, err := normalizeSignerApprovalCertificate(request) if err != nil { - return nil, nil, err + return nil, nil, nil, err } normalizedLegacySignatures, err := validateArtifactSignatures(request.ArtifactSignatures) if err != nil { - return nil, nil, err + return nil, nil, nil, err } if request.ArtifactApprovals == nil { - return nil, normalizedLegacySignatures, nil + return nil, normalizedSignerApproval, normalizedLegacySignatures, nil } if request.MigrationTransactionPlan == nil { - return nil, nil, &inputError{"request.migrationTransactionPlan is required when request.artifactApprovals is present"} + return nil, nil, nil, &inputError{"request.migrationTransactionPlan is required when request.artifactApprovals is present"} } if request.ArtifactApprovals.Payload.ApprovalVersion != artifactApprovalVersion { - return nil, nil, &inputError{"request.artifactApprovals.payload.approvalVersion must equal 1"} + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.approvalVersion must equal 1"} } if request.ArtifactApprovals.Payload.Route != route { - return nil, nil, &inputError{"request.artifactApprovals.payload.route must match request.route"} + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.route must match request.route"} } if request.ArtifactApprovals.Payload.ScriptTemplateID != route { - return nil, nil, &inputError{"request.artifactApprovals.payload.scriptTemplateId must match request.route"} + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.scriptTemplateId must match request.route"} } if err := validateBytes32HexString( "request.artifactApprovals.payload.destinationCommitmentHash", request.ArtifactApprovals.Payload.DestinationCommitmentHash, ); err != nil { - return nil, nil, err + return nil, nil, nil, err } if err := validateBytes32HexString( "request.artifactApprovals.payload.planCommitmentHash", request.ArtifactApprovals.Payload.PlanCommitmentHash, ); err != nil { - return nil, nil, err + return nil, nil, nil, err } normalizedDestinationCommitmentHash := normalizeLowerHex( request.ArtifactApprovals.Payload.DestinationCommitmentHash, ) if normalizedDestinationCommitmentHash != normalizeLowerHex(request.DestinationCommitmentHash) { - return nil, nil, &inputError{"request.artifactApprovals.payload.destinationCommitmentHash must match request.destinationCommitmentHash"} + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.destinationCommitmentHash must match request.destinationCommitmentHash"} } normalizedPlanCommitmentHash := normalizeLowerHex( request.ArtifactApprovals.Payload.PlanCommitmentHash, ) if normalizedPlanCommitmentHash != normalizeLowerHex(request.MigrationTransactionPlan.PlanCommitmentHash) { - return nil, nil, &inputError{"request.artifactApprovals.payload.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash"} + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash"} } if len(request.ArtifactApprovals.Approvals) == 0 { - return nil, nil, &inputError{"request.artifactApprovals.approvals must not be empty"} + return nil, nil, nil, &inputError{"request.artifactApprovals.approvals must not be empty"} } requiredRoles, err := requiredArtifactApprovalRoles(route) + if err != nil { + return nil, nil, nil, err + } if normalizedSignerApproval != nil { requiredRoles, err = requiredStructuredArtifactApprovalRoles(route) } if err != nil { - return nil, nil, err + return nil, nil, nil, err } allowedRoles := make(map[ArtifactApprovalRole]struct{}, len(requiredRoles)) @@ -1186,14 +1189,14 @@ func normalizeArtifactApprovals( approvalsByRole := make(map[ArtifactApprovalRole]string, len(requiredRoles)) for i, approval := range request.ArtifactApprovals.Approvals { if _, ok := allowedRoles[approval.Role]; !ok { - return nil, nil, &inputError{fmt.Sprintf( + return nil, nil, nil, &inputError{fmt.Sprintf( "request.artifactApprovals.approvals[%d].role is not allowed for %s", i, route, )} } if _, ok := approvalsByRole[approval.Role]; ok { - return nil, nil, &inputError{fmt.Sprintf( + return nil, nil, nil, &inputError{fmt.Sprintf( "request.artifactApprovals.approvals[%d].role duplicates role %s", i, approval.Role, @@ -1203,7 +1206,7 @@ func normalizeArtifactApprovals( fmt.Sprintf("request.artifactApprovals.approvals[%d].signature", i), approval.Signature, ); err != nil { - return nil, nil, err + return nil, nil, nil, err } approvalsByRole[approval.Role] = normalizeLowerHex(approval.Signature) @@ -1223,7 +1226,7 @@ func normalizeArtifactApprovals( for i, role := range requiredRoles { signature, ok := approvalsByRole[role] if !ok { - return nil, nil, &inputError{fmt.Sprintf( + return nil, nil, nil, &inputError{fmt.Sprintf( "request.artifactApprovals.approvals must include role %s for %s", role, route, @@ -1250,15 +1253,15 @@ func normalizeArtifactApprovals( } if len(normalizedLegacySignatures) != len(derivedLegacySignatures) { - return nil, nil, &inputError{canonicalSignatureError} + return nil, nil, nil, &inputError{canonicalSignatureError} } for i := range derivedLegacySignatures { if normalizedLegacySignatures[i] != derivedLegacySignatures[i] { - return nil, nil, &inputError{canonicalSignatureError} + return nil, nil, nil, &inputError{canonicalSignatureError} } } - return normalizedApprovals, derivedLegacySignatures, nil + return normalizedApprovals, normalizedSignerApproval, derivedLegacySignatures, nil } func validateArtifactApprovalAuthenticity( @@ -1431,17 +1434,13 @@ func normalizeRouteSubmitRequest( options ...validationOptions, ) (RouteSubmitRequest, error) { resolvedOptions := resolveValidationOptions(options) - normalizedArtifactApprovals, normalizedArtifactSignatures, err := normalizeArtifactApprovals( + normalizedArtifactApprovals, normalizedSignerApproval, normalizedArtifactSignatures, err := normalizeArtifactApprovals( request.Route, request, ) if err != nil { return RouteSubmitRequest{}, err } - normalizedSignerApproval, err := normalizeSignerApprovalCertificate(request) - if err != nil { - return RouteSubmitRequest{}, err - } normalizedScriptTemplate, err := normalizeScriptTemplate(request.Route, request.ScriptTemplate) if err != nil { @@ -1539,7 +1538,7 @@ func validateCommonRequest( } if resolvedOptions.signerApprovalVerifier != nil && request.SignerApproval == nil { return &inputError{ - "request.signerApproval is required when request.artifactApprovals is present", + "request.signerApproval is required when the signer approval verifier is configured", } } if err := validateArtifactApprovals(route, request); err != nil { From 16f05dd327610056ffa0959ace7b9f76a5d52f0e Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 11:36:55 -0500 Subject: [PATCH 032/143] Warn on signer verifier-less startup --- pkg/covenantsigner/server.go | 7 +++++++ pkg/covenantsigner/validation.go | 8 ++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index bd1bf8034a..b12dce7c6d 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -56,6 +56,13 @@ func Initialize( if err != nil { return nil, false, err } + if service.signerApprovalVerifier == nil { + logger.Warn( + "covenant signer started without a signer approval verifier; " + + "structured signerApproval certificates cannot be verified and " + + "legacy signer role S may still be accepted on passive/non-production paths", + ) + } server := &Server{ service: service, diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 8bc2404bf5..091bc54fcc 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -1324,10 +1324,10 @@ func validateArtifactApprovalAuthenticity( return err } case ArtifactApprovalRoleSigner: - // Phase 1 keeps S structurally required but not cryptographically - // verified. Signer approval must eventually bind to quorum or - // signer-service trust roots rather than the single signer key in the - // script template. + // Temporary cutover debt for passive/non-verifier deployments only. + // Production engine-backed deployments require request.signerApproval + // and do not reach this legacy S branch. Remove this fallback once + // non-verifier paths are deleted. continue } } From 36e9b443ec0b2e1de2a81a6086559da8a34a9e7f Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 11:51:20 -0500 Subject: [PATCH 033/143] Remove legacy signer approval role path --- pkg/covenantsigner/covenantsigner_test.go | 184 +++------------------- pkg/covenantsigner/types.go | 1 - pkg/covenantsigner/validation.go | 32 +--- 3 files changed, 24 insertions(+), 193 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index fee9dba640..2f6ae0ba50 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -285,7 +285,7 @@ func canonicalArtifactSignatures( return nil } - requiredRoles, err := requiredArtifactApprovalRoles(route) + requiredRoles, err := requiredStructuredArtifactApprovalRoles(route) if err != nil { panic(err) } @@ -409,10 +409,6 @@ func validArtifactApprovals(request RouteSubmitRequest) *ArtifactApprovalEnvelop Role: ArtifactApprovalRoleDepositor, Signature: mustArtifactApprovalSignature(testDepositorPrivateKey, payload), }, - { - Role: ArtifactApprovalRoleSigner, - Signature: mustArtifactApprovalSignature(testSignerPrivateKey, payload), - }, } if request.Route == TemplateQcV1 { @@ -425,10 +421,6 @@ func validArtifactApprovals(request RouteSubmitRequest) *ArtifactApprovalEnvelop Role: ArtifactApprovalRoleCustodian, Signature: mustArtifactApprovalSignature(testCustodianPrivateKey, payload), }, - { - Role: ArtifactApprovalRoleSigner, - Signature: mustArtifactApprovalSignature(testSignerPrivateKey, payload), - }, } } @@ -438,37 +430,6 @@ func validArtifactApprovals(request RouteSubmitRequest) *ArtifactApprovalEnvelop } } -func validStructuredArtifactApprovals( - request RouteSubmitRequest, -) *ArtifactApprovalEnvelope { - payload := ArtifactApprovalPayload{ - ApprovalVersion: artifactApprovalVersion, - Route: request.Route, - ScriptTemplateID: request.Route, - DestinationCommitmentHash: request.DestinationCommitmentHash, - PlanCommitmentHash: request.MigrationTransactionPlan.PlanCommitmentHash, - } - - approvals := []ArtifactRoleApproval{ - { - Role: ArtifactApprovalRoleDepositor, - Signature: mustArtifactApprovalSignature(testDepositorPrivateKey, payload), - }, - } - - if request.Route == TemplateQcV1 { - approvals = append(approvals, ArtifactRoleApproval{ - Role: ArtifactApprovalRoleCustodian, - Signature: mustArtifactApprovalSignature(testCustodianPrivateKey, payload), - }) - } - - return &ArtifactApprovalEnvelope{ - Payload: payload, - Approvals: approvals, - } -} - func validSignerApproval( artifactApprovals *ArtifactApprovalEnvelope, ) *SignerApprovalCertificate { @@ -497,7 +458,6 @@ func validSignerApproval( func structuredSignerApprovalRequest(route TemplateID) RouteSubmitRequest { request := baseRequest(route) - request.ArtifactApprovals = validStructuredArtifactApprovals(request) request.SignerApproval = validSignerApproval(request.ArtifactApprovals) request.ArtifactSignatures = canonicalArtifactSignaturesWithSignerApproval( request.Route, @@ -747,108 +707,6 @@ func mixedCaseArtifactApprovalVariantFromRequest( return artifactApprovalVariantFromRequest(t, request, mixedCaseHexBody) } -func equivalentArtifactApprovalVariant(route TemplateID) RouteSubmitRequest { - request := canonicalArtifactApprovalRequest(route) - - request.Strategy = upperHexBody(request.Strategy) - request.Reserve = upperHexBody(request.Reserve) - request.ActiveOutpoint.TxID = upperHexBody(request.ActiveOutpoint.TxID) - request.ActiveOutpoint.ScriptHash = upperHexBody(request.ActiveOutpoint.ScriptHash) - request.DestinationCommitmentHash = upperHexBody(request.DestinationCommitmentHash) - request.MigrationDestination.Reserve = upperHexBody(request.MigrationDestination.Reserve) - request.MigrationDestination.Revealer = upperHexBody(request.MigrationDestination.Revealer) - request.MigrationDestination.Vault = upperHexBody(request.MigrationDestination.Vault) - request.MigrationDestination.DepositScript = upperHexBody(request.MigrationDestination.DepositScript) - request.MigrationDestination.DepositScriptHash = upperHexBody(request.MigrationDestination.DepositScriptHash) - request.MigrationDestination.MigrationExtraData = upperHexBody(request.MigrationDestination.MigrationExtraData) - request.MigrationDestination.DestinationCommitmentHash = upperHexBody(request.MigrationDestination.DestinationCommitmentHash) - request.MigrationTransactionPlan.PlanCommitmentHash = upperHexBody(request.MigrationTransactionPlan.PlanCommitmentHash) - for i := range request.ArtifactSignatures { - request.ArtifactSignatures[i] = upperHexBody(request.ArtifactSignatures[i]) - } - - if route == TemplateQcV1 { - request.ScriptTemplate = mustTemplate(QcV1Template{ - Template: TemplateQcV1, - DepositorPublicKey: upperHexBody(testDepositorPublicKey), - CustodianPublicKey: upperHexBody(testCustodianPublicKey), - SignerPublicKey: upperHexBody(testSignerPublicKey), - Beta: 144, - Delta2: 4320, - }) - request.ArtifactApprovals.Payload.DestinationCommitmentHash = upperHexBody( - request.ArtifactApprovals.Payload.DestinationCommitmentHash, - ) - request.ArtifactApprovals.Payload.PlanCommitmentHash = upperHexBody( - request.ArtifactApprovals.Payload.PlanCommitmentHash, - ) - request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ - { - Role: ArtifactApprovalRoleSigner, - Signature: upperHexBody( - artifactApprovalSignatureByRole( - request.ArtifactApprovals, - ArtifactApprovalRoleSigner, - ), - ), - }, - { - Role: ArtifactApprovalRoleDepositor, - Signature: upperHexBody( - artifactApprovalSignatureByRole( - request.ArtifactApprovals, - ArtifactApprovalRoleDepositor, - ), - ), - }, - { - Role: ArtifactApprovalRoleCustodian, - Signature: upperHexBody( - artifactApprovalSignatureByRole( - request.ArtifactApprovals, - ArtifactApprovalRoleCustodian, - ), - ), - }, - } - } else { - request.ScriptTemplate = mustTemplate(SelfV1Template{ - Template: TemplateSelfV1, - DepositorPublicKey: upperHexBody(testDepositorPublicKey), - SignerPublicKey: upperHexBody(testSignerPublicKey), - Delta2: 4320, - }) - request.ArtifactApprovals.Payload.DestinationCommitmentHash = upperHexBody( - request.ArtifactApprovals.Payload.DestinationCommitmentHash, - ) - request.ArtifactApprovals.Payload.PlanCommitmentHash = upperHexBody( - request.ArtifactApprovals.Payload.PlanCommitmentHash, - ) - request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ - { - Role: ArtifactApprovalRoleSigner, - Signature: upperHexBody( - artifactApprovalSignatureByRole( - request.ArtifactApprovals, - ArtifactApprovalRoleSigner, - ), - ), - }, - { - Role: ArtifactApprovalRoleDepositor, - Signature: upperHexBody( - artifactApprovalSignatureByRole( - request.ArtifactApprovals, - ArtifactApprovalRoleDepositor, - ), - ), - }, - } - } - - return request -} - func validMigrationDestination() *MigrationDestinationReservation { reservation := &MigrationDestinationReservation{ ReservationID: "cmdr_12345678", @@ -1971,7 +1829,7 @@ func TestServiceRejectsStructuredSignerApprovalWithoutVerifier(t *testing.T) { } } -func TestServiceRejectsLegacySignerApprovalPathWhenVerifierConfigured(t *testing.T) { +func TestServiceRejectsMissingSignerApprovalWhenVerifierConfigured(t *testing.T) { handle := newMemoryHandle() service, err := NewService( handle, @@ -2044,7 +1902,7 @@ func TestServiceRejectsStructuredSignerApprovalWithLegacySignerRole(t *testing.T request.ArtifactApprovals.Approvals = append( request.ArtifactApprovals.Approvals, ArtifactRoleApproval{ - Role: ArtifactApprovalRoleSigner, + Role: ArtifactApprovalRole("S"), Signature: "0x5151", }, ) @@ -2086,11 +1944,9 @@ func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { mutate: func(request *RouteSubmitRequest) { request.ArtifactApprovals.Approvals = []ArtifactRoleApproval{ request.ArtifactApprovals.Approvals[0], - request.ArtifactApprovals.Approvals[2], } request.ArtifactSignatures = []string{ request.ArtifactSignatures[0], - request.ArtifactSignatures[2], } }, expectErr: "request.artifactApprovals.approvals must include role C for qc_v1", @@ -2108,7 +1964,6 @@ func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { request.ArtifactApprovals.Payload, ), }, - request.ArtifactApprovals.Approvals[1], } }, expectErr: "request.artifactApprovals.approvals[1].role is not allowed for self_v1", @@ -2134,9 +1989,8 @@ func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { route: TemplateQcV1, mutate: func(request *RouteSubmitRequest) { request.ArtifactSignatures = []string{ - request.ArtifactSignatures[2], - request.ArtifactSignatures[0], request.ArtifactSignatures[1], + request.ArtifactSignatures[0], } }, expectErr: "request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals", @@ -2156,9 +2010,9 @@ func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { setArtifactApprovalSignature( request.ArtifactApprovals, ArtifactApprovalRoleDepositor, - artifactApprovalSignatureByRole( - request.ArtifactApprovals, - ArtifactApprovalRoleSigner, + mustArtifactApprovalSignature( + testCustodianPrivateKey, + request.ArtifactApprovals.Payload, ), ) request.ArtifactSignatures = canonicalArtifactSignatures( @@ -2212,7 +2066,12 @@ func TestRequestDigestNormalizesEquivalentArtifactApprovalVariants(t *testing.T) t.Fatal(err) } - variantDigest, err := requestDigest(equivalentArtifactApprovalVariant(TemplateQcV1)) + variantDigest, err := requestDigest( + equivalentArtifactApprovalVariantFromRequest( + t, + canonicalArtifactApprovalRequest(TemplateQcV1), + ), + ) if err != nil { t.Fatal(err) } @@ -2522,7 +2381,10 @@ func TestServicePollAcceptsEquivalentArtifactApprovalRequestVariants(t *testing. t.Fatal(err) } - submitRequest := equivalentArtifactApprovalVariant(TemplateQcV1) + submitRequest := equivalentArtifactApprovalVariantFromRequest( + t, + canonicalArtifactApprovalRequest(TemplateQcV1), + ) submitResult, err := service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ RouteRequestID: "orq_equivalent_digest", Stage: StageSignerCoordination, @@ -2554,7 +2416,10 @@ func TestServiceStoresNormalizedArtifactApprovalRequest(t *testing.T) { t.Fatal(err) } - request := equivalentArtifactApprovalVariant(TemplateQcV1) + request := equivalentArtifactApprovalVariantFromRequest( + t, + canonicalArtifactApprovalRequest(TemplateQcV1), + ) _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ RouteRequestID: "orq_normalized_store", Stage: StageSignerCoordination, @@ -2978,11 +2843,10 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { "planCommitmentHash":"%s" }, "approvals":[ - {"role":"D","signature":"%s"}, - {"role":"S","signature":"%s"} + {"role":"D","signature":"%s"} ] }, - "artifactSignatures":["%s","%s"], + "artifactSignatures":["%s"], "artifacts":{}, "scriptTemplate":{"template":"self_v1","depositorPublicKey":"%s","signerPublicKey":"%s","delta2":4320}, "signing":{"signerRequired":true,"custodianRequired":false}, @@ -2996,9 +2860,7 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { base.ArtifactApprovals.Payload.DestinationCommitmentHash, base.ArtifactApprovals.Payload.PlanCommitmentHash, base.ArtifactApprovals.Approvals[0].Signature, - base.ArtifactApprovals.Approvals[1].Signature, base.ArtifactSignatures[0], - base.ArtifactSignatures[1], template.DepositorPublicKey, template.SignerPublicKey, )) diff --git a/pkg/covenantsigner/types.go b/pkg/covenantsigner/types.go index 50fb6efd02..5eae01b1d3 100644 --- a/pkg/covenantsigner/types.go +++ b/pkg/covenantsigner/types.go @@ -151,7 +151,6 @@ type ArtifactApprovalRole string const ( ArtifactApprovalRoleDepositor ArtifactApprovalRole = "D" ArtifactApprovalRoleCustodian ArtifactApprovalRole = "C" - ArtifactApprovalRoleSigner ArtifactApprovalRole = "S" ) type ArtifactApprovalPayload struct { diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 091bc54fcc..04d969ea09 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -1087,24 +1087,6 @@ func requiredStructuredArtifactApprovalRoles(route TemplateID) ([]ArtifactApprov } } -func requiredArtifactApprovalRoles(route TemplateID) ([]ArtifactApprovalRole, error) { - switch route { - case TemplateQcV1: - return []ArtifactApprovalRole{ - ArtifactApprovalRoleDepositor, - ArtifactApprovalRoleCustodian, - ArtifactApprovalRoleSigner, - }, nil - case TemplateSelfV1: - return []ArtifactApprovalRole{ - ArtifactApprovalRoleDepositor, - ArtifactApprovalRoleSigner, - }, nil - default: - return nil, &inputError{"unsupported request.route"} - } -} - func validateArtifactApprovals(route TemplateID, request RouteSubmitRequest) error { _, _, _, err := normalizeArtifactApprovals(route, request) return err @@ -1170,13 +1152,7 @@ func normalizeArtifactApprovals( return nil, nil, nil, &inputError{"request.artifactApprovals.approvals must not be empty"} } - requiredRoles, err := requiredArtifactApprovalRoles(route) - if err != nil { - return nil, nil, nil, err - } - if normalizedSignerApproval != nil { - requiredRoles, err = requiredStructuredArtifactApprovalRoles(route) - } + requiredRoles, err := requiredStructuredArtifactApprovalRoles(route) if err != nil { return nil, nil, nil, err } @@ -1323,12 +1299,6 @@ func validateArtifactApprovalAuthenticity( ); err != nil { return err } - case ArtifactApprovalRoleSigner: - // Temporary cutover debt for passive/non-verifier deployments only. - // Production engine-backed deployments require request.signerApproval - // and do not reach this legacy S branch. Remove this fallback once - // non-verifier paths are deleted. - continue } } From 686503d8925afa91371ef9ad60529c63e548084e Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 12:00:22 -0500 Subject: [PATCH 034/143] Harden signer approval verifier boundary --- pkg/covenantsigner/server.go | 4 +-- pkg/covenantsigner/validation.go | 7 ++++ pkg/tbtc/covenant_signer.go | 29 ++++++++++++++- pkg/tbtc/signer_approval_certificate_test.go | 38 ++++++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index b12dce7c6d..cf31b169d8 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -59,8 +59,8 @@ func Initialize( if service.signerApprovalVerifier == nil { logger.Warn( "covenant signer started without a signer approval verifier; " + - "structured signerApproval certificates cannot be verified and " + - "legacy signer role S may still be accepted on passive/non-production paths", + "structured signerApproval certificates will not be verified and " + + "requests without signerApproval will be accepted", ) } diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 04d969ea09..d83bd22100 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -410,6 +410,13 @@ func artifactApprovalDigest(payload ArtifactApprovalPayload) ([]byte, error) { return digest.Bytes(), nil } +// ComputeArtifactApprovalDigest exposes the current phase-1 approval payload +// digest contract to cross-package verifiers that need to bind +// signerApproval.approvalDigest to request.artifactApprovals.payload. +func ComputeArtifactApprovalDigest(payload ArtifactApprovalPayload) ([]byte, error) { + return artifactApprovalDigest(payload) +} + func parseCompressedSecp256k1PublicKey( name string, value string, diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index f4b699c329..7fafad1ec0 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -46,7 +46,34 @@ func (cse *covenantSignerEngine) VerifySignerApproval( request covenantsigner.RouteSubmitRequest, ) error { if request.SignerApproval == nil { - return nil + return covenantsigner.NewInputError( + "request.signerApproval is required for signer approval verification", + ) + } + if request.ArtifactApprovals == nil { + return covenantsigner.NewInputError( + "request.artifactApprovals is required for signer approval verification", + ) + } + + expectedApprovalDigest, err := covenantsigner.ComputeArtifactApprovalDigest( + request.ArtifactApprovals.Payload, + ) + if err != nil { + return covenantsigner.NewInputError( + fmt.Sprintf( + "request.artifactApprovals.payload is invalid for signer approval verification: %v", + err, + ), + ) + } + if !strings.EqualFold( + request.SignerApproval.ApprovalDigest, + "0x"+hex.EncodeToString(expectedApprovalDigest), + ) { + return covenantsigner.NewInputError( + "request.signerApproval.approvalDigest must match request.artifactApprovals.payload", + ) } signerPublicKey, err := cse.resolveSignerApprovalTemplatePublicKey(request) diff --git a/pkg/tbtc/signer_approval_certificate_test.go b/pkg/tbtc/signer_approval_certificate_test.go index 2fac70ed59..21ce550998 100644 --- a/pkg/tbtc/signer_approval_certificate_test.go +++ b/pkg/tbtc/signer_approval_certificate_test.go @@ -357,3 +357,41 @@ func TestCovenantSignerEngineVerifySignerApprovalRejectsWalletPublicKeyMismatch( t.Fatalf("expected wallet public key mismatch error, got %v", err) } } + +func TestCovenantSignerEngineVerifySignerApprovalRejectsMissingCertificate(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + request.SignerApproval = nil + + err := (&covenantSignerEngine{node: node}).VerifySignerApproval(request) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval is required for signer approval verification", + ) { + t.Fatalf("expected missing signer approval error, got %v", err) + } +} + +func TestCovenantSignerEngineVerifySignerApprovalRejectsApprovalDigestMismatch(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + request.SignerApproval.ApprovalDigest = "0x" + strings.Repeat("11", 32) + + err := (&covenantSignerEngine{node: node}).VerifySignerApproval(request) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval.approvalDigest must match request.artifactApprovals.payload", + ) { + t.Fatalf("expected signer approval digest mismatch error, got %v", err) + } +} From 97fc5ef9743f931ea6402209e47b474fb8adef14 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 12:58:05 -0500 Subject: [PATCH 035/143] Pin covenant approval trust roots --- pkg/covenantsigner/config.go | 6 + pkg/covenantsigner/covenantsigner_test.go | 200 +++++++++++++++++++ pkg/covenantsigner/server.go | 47 +++++ pkg/covenantsigner/service.go | 46 +++++ pkg/covenantsigner/types.go | 14 ++ pkg/covenantsigner/validation.go | 226 +++++++++++++++++++++- 6 files changed, 537 insertions(+), 2 deletions(-) diff --git a/pkg/covenantsigner/config.go b/pkg/covenantsigner/config.go index b02e51f178..e14b855eb0 100644 --- a/pkg/covenantsigner/config.go +++ b/pkg/covenantsigner/config.go @@ -19,4 +19,10 @@ type Config struct { // trust roots used to verify migration plan quotes when the quote authority // path is enabled. MigrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot `mapstructure:"migrationPlanQuoteTrustRoots"` + // DepositorTrustRoots configures independently pinned depositor public keys + // by route/reserve/network for self_v1 approval verification. + DepositorTrustRoots []DepositorTrustRoot `mapstructure:"depositorTrustRoots"` + // CustodianTrustRoots configures independently pinned custodian public keys + // by route/reserve/network for qc_v1 approval verification. + CustodianTrustRoots []CustodianTrustRoot `mapstructure:"custodianTrustRoots"` } diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 2f6ae0ba50..2e58096c69 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -202,6 +202,28 @@ var ( } ) +func testDepositorTrustRoot(route TemplateID) DepositorTrustRoot { + migrationDestination := validMigrationDestination() + + return DepositorTrustRoot{ + Route: route, + Reserve: migrationDestination.Reserve, + Network: migrationDestination.Network, + PublicKey: testDepositorPublicKey, + } +} + +func testCustodianTrustRoot(route TemplateID) CustodianTrustRoot { + migrationDestination := validMigrationDestination() + + return CustodianTrustRoot{ + Route: route, + Reserve: migrationDestination.Reserve, + Network: migrationDestination.Network, + PublicKey: testCustodianPublicKey, + } +} + func mustDeterministicTestPrivateKey(encoded string) *btcec.PrivateKey { rawPrivateKey, err := hex.DecodeString(strings.TrimPrefix(encoded, "0x")) if err != nil { @@ -2370,6 +2392,184 @@ func TestServicePollAcceptsStoredMigrationPlanQuoteAfterQuoteExpiry(t *testing.T } } +func TestServiceAcceptsSelfV1WithMatchingDepositorTrustRoot(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + testDepositorTrustRoot(TemplateSelfV1), + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_self_trust_root_match", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err != nil { + t.Fatal(err) + } +} + +func TestServiceRejectsSelfV1WithoutMatchingDepositorTrustRoot(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + testDepositorTrustRoot(TemplateSelfV1), + }), + ) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateSelfV1) + request.ScriptTemplate = mustTemplate(SelfV1Template{ + Template: TemplateSelfV1, + DepositorPublicKey: testSignerPublicKey, + SignerPublicKey: testSignerPublicKey, + Delta2: 4320, + }) + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_self_trust_root_mismatch", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.scriptTemplate.depositorPublicKey must match the configured depositorTrustRoots publicKey for self_v1", + ) { + t.Fatalf("expected self_v1 depositor trust-root mismatch, got %v", err) + } +} + +func TestServiceRejectsSelfV1WithoutConfiguredDepositorTrustRootMatch(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + { + Route: TemplateSelfV1, + Reserve: "0x9999999999999999999999999999999999999999", + Network: "regtest", + PublicKey: testDepositorPublicKey, + }, + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_self_trust_root_missing", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err == nil || !strings.Contains( + err.Error(), + "request.scriptTemplate.depositorPublicKey requires a matching configured depositorTrustRoots entry for self_v1", + ) { + t.Fatalf("expected missing self_v1 depositor trust-root error, got %v", err) + } +} + +func TestServiceAcceptsQcV1WithMatchingCustodianTrustRoot(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithCustodianTrustRoots([]CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_qc_trust_root_match", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + }) + if err != nil { + t.Fatal(err) + } +} + +func TestServiceRejectsQcV1WithoutMatchingCustodianTrustRoot(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithCustodianTrustRoots([]CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }), + ) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateQcV1) + request.ScriptTemplate = mustTemplate(QcV1Template{ + Template: TemplateQcV1, + DepositorPublicKey: testDepositorPublicKey, + CustodianPublicKey: testSignerPublicKey, + SignerPublicKey: testSignerPublicKey, + Beta: 144, + Delta2: 4320, + }) + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_qc_trust_root_mismatch", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.scriptTemplate.custodianPublicKey must match the configured custodianTrustRoots publicKey for qc_v1", + ) { + t.Fatalf("expected qc_v1 custodian trust-root mismatch, got %v", err) + } +} + +func TestServiceRejectsQcV1WithoutConfiguredCustodianTrustRootMatch(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithCustodianTrustRoots([]CustodianTrustRoot{ + { + Route: TemplateQcV1, + Reserve: "0x9999999999999999999999999999999999999999", + Network: "regtest", + PublicKey: testCustodianPublicKey, + }, + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_qc_trust_root_missing", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + }) + if err == nil || !strings.Contains( + err.Error(), + "request.scriptTemplate.custodianPublicKey requires a matching configured custodianTrustRoots entry for qc_v1", + ) { + t.Fatalf("expected missing qc_v1 custodian trust-root error, got %v", err) + } +} + func TestServicePollAcceptsEquivalentArtifactApprovalRequestVariants(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index cf31b169d8..95caf3a9c7 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -52,6 +52,8 @@ func Initialize( handle, engine, WithMigrationPlanQuoteTrustRoots(config.MigrationPlanQuoteTrustRoots), + WithDepositorTrustRoots(config.DepositorTrustRoots), + WithCustodianTrustRoots(config.CustodianTrustRoots), ) if err != nil { return nil, false, err @@ -63,6 +65,25 @@ func Initialize( "requests without signerApproval will be accepted", ) } + if config.EnableSelfV1 && + !hasDepositorTrustRootForRoute( + service.depositorTrustRoots, + TemplateSelfV1, + ) { + logger.Warn( + "covenant signer self_v1 routes are enabled without depositorTrustRoots; " + + "self_v1 depositor approvals still rely on request-supplied scriptTemplate keys", + ) + } + if !hasCustodianTrustRootForRoute( + service.custodianTrustRoots, + TemplateQcV1, + ) { + logger.Warn( + "covenant signer started without custodianTrustRoots; " + + "qc_v1 custodian approvals still rely on request-supplied scriptTemplate keys", + ) + } server := &Server{ service: service, @@ -105,6 +126,32 @@ func Initialize( return server, true, nil } +func hasDepositorTrustRootForRoute( + trustRoots []DepositorTrustRoot, + route TemplateID, +) bool { + for _, trustRoot := range trustRoots { + if trustRoot.Route == route { + return true + } + } + + return false +} + +func hasCustodianTrustRootForRoute( + trustRoots []CustodianTrustRoot, + route TemplateID, +) bool { + for _, trustRoot := range trustRoots { + if trustRoot.Route == route { + return true + } + } + + return false +} + func newHandler(service *Service, authToken string, enableSelfV1 bool) http.Handler { mux := http.NewServeMux() protectedHandler := withBearerAuth(mux, authToken) diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 20e5e1248a..fa7b74c72d 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -19,6 +19,8 @@ type Service struct { now func() time.Time mutex sync.Mutex migrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot + depositorTrustRoots []DepositorTrustRoot + custodianTrustRoots []CustodianTrustRoot } type ServiceOption func(*Service) @@ -33,6 +35,26 @@ func WithMigrationPlanQuoteTrustRoots( } } +func WithDepositorTrustRoots( + trustRoots []DepositorTrustRoot, +) ServiceOption { + cloned := append([]DepositorTrustRoot{}, trustRoots...) + + return func(service *Service) { + service.depositorTrustRoots = cloned + } +} + +func WithCustodianTrustRoots( + trustRoots []CustodianTrustRoot, +) ServiceOption { + cloned := append([]CustodianTrustRoot{}, trustRoots...) + + return func(service *Service) { + service.custodianTrustRoots = cloned + } +} + func WithSignerApprovalVerifier( verifier SignerApprovalVerifier, ) ServiceOption { @@ -67,6 +89,22 @@ func NewService( option(service) } + normalizedDepositorTrustRoots, err := normalizeDepositorTrustRoots( + service.depositorTrustRoots, + ) + if err != nil { + return nil, err + } + service.depositorTrustRoots = normalizedDepositorTrustRoots + + normalizedCustodianTrustRoots, err := normalizeCustodianTrustRoots( + service.custodianTrustRoots, + ) + if err != nil { + return nil, err + } + service.custodianTrustRoots = normalizedCustodianTrustRoots + return service, nil } @@ -169,6 +207,8 @@ func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, er input.Request, validationOptions{ migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + depositorTrustRoots: s.depositorTrustRoots, + custodianTrustRoots: s.custodianTrustRoots, signerApprovalVerifier: s.signerApprovalVerifier, }, ) @@ -185,6 +225,8 @@ func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, er func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubmitInput) (StepResult, error) { submitValidationOptions := validationOptions{ migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + depositorTrustRoots: s.depositorTrustRoots, + custodianTrustRoots: s.custodianTrustRoots, requireFreshMigrationPlanQuote: true, migrationPlanQuoteVerificationNow: s.now(), signerApprovalVerifier: s.signerApprovalVerifier, @@ -197,6 +239,8 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm input.Request, validationOptions{ migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + depositorTrustRoots: s.depositorTrustRoots, + custodianTrustRoots: s.custodianTrustRoots, signerApprovalVerifier: s.signerApprovalVerifier, }, ) @@ -297,6 +341,8 @@ func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollIn input, validationOptions{ migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + depositorTrustRoots: s.depositorTrustRoots, + custodianTrustRoots: s.custodianTrustRoots, signerApprovalVerifier: s.signerApprovalVerifier, }, ); err != nil { diff --git a/pkg/covenantsigner/types.go b/pkg/covenantsigner/types.go index 5eae01b1d3..21e723a95c 100644 --- a/pkg/covenantsigner/types.go +++ b/pkg/covenantsigner/types.go @@ -146,6 +146,20 @@ type MigrationPlanQuoteTrustRoot struct { PublicKeyPEM string `json:"publicKeyPem" mapstructure:"publicKeyPem"` } +type DepositorTrustRoot struct { + Route TemplateID `json:"route" mapstructure:"route"` + Reserve string `json:"reserve" mapstructure:"reserve"` + Network string `json:"network" mapstructure:"network"` + PublicKey string `json:"publicKey" mapstructure:"publicKey"` +} + +type CustodianTrustRoot struct { + Route TemplateID `json:"route" mapstructure:"route"` + Reserve string `json:"reserve" mapstructure:"reserve"` + Network string `json:"network" mapstructure:"network"` + PublicKey string `json:"publicKey" mapstructure:"publicKey"` +} + type ArtifactApprovalRole string const ( diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index d83bd22100..373defbe2c 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -84,6 +84,8 @@ func marshalCanonicalJSON(value any) ([]byte, error) { type validationOptions struct { migrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot + depositorTrustRoots []DepositorTrustRoot + custodianTrustRoots []CustodianTrustRoot requireFreshMigrationPlanQuote bool migrationPlanQuoteVerificationNow time.Time signerApprovalVerifier SignerApprovalVerifier @@ -552,6 +554,186 @@ func parseMigrationPlanQuoteTrustRoot( return publicKey, nil } +func normalizeScopedApprovalTrustRoot( + name string, + route TemplateID, + reserve string, + network string, + publicKey string, +) (TemplateID, string, string, string, error) { + switch route { + case TemplateSelfV1, TemplateQcV1: + default: + return "", "", "", "", &inputError{ + fmt.Sprintf("%s.route must be self_v1 or qc_v1", name), + } + } + + if err := validateHexString(name+".reserve", reserve); err != nil { + return "", "", "", "", err + } + + trimmedNetwork := strings.TrimSpace(network) + if trimmedNetwork == "" { + return "", "", "", "", &inputError{ + fmt.Sprintf("%s.network is required", name), + } + } + + normalizedPublicKey := normalizeLowerHex(publicKey) + if _, err := parseCompressedSecp256k1PublicKey( + name+".publicKey", + normalizedPublicKey, + ); err != nil { + return "", "", "", "", err + } + + return route, + normalizeLowerHex(reserve), + strings.ToLower(trimmedNetwork), + normalizedPublicKey, + nil +} + +func normalizeDepositorTrustRoots( + trustRoots []DepositorTrustRoot, +) ([]DepositorTrustRoot, error) { + if len(trustRoots) == 0 { + return nil, nil + } + + normalized := make([]DepositorTrustRoot, len(trustRoots)) + seen := make(map[string]int, len(trustRoots)) + + for i, trustRoot := range trustRoots { + name := fmt.Sprintf("depositorTrustRoots[%d]", i) + route, reserve, network, publicKey, err := normalizeScopedApprovalTrustRoot( + name, + trustRoot.Route, + trustRoot.Reserve, + trustRoot.Network, + trustRoot.PublicKey, + ) + if err != nil { + return nil, err + } + + scopeKey := string(route) + "|" + reserve + "|" + network + if previousIndex, ok := seen[scopeKey]; ok { + return nil, &inputError{ + fmt.Sprintf( + "%s duplicates depositorTrustRoots[%d] for route %s reserve %s network %s", + name, + previousIndex, + route, + reserve, + network, + ), + } + } + seen[scopeKey] = i + + normalized[i] = DepositorTrustRoot{ + Route: route, + Reserve: reserve, + Network: network, + PublicKey: publicKey, + } + } + + return normalized, nil +} + +func normalizeCustodianTrustRoots( + trustRoots []CustodianTrustRoot, +) ([]CustodianTrustRoot, error) { + if len(trustRoots) == 0 { + return nil, nil + } + + normalized := make([]CustodianTrustRoot, len(trustRoots)) + seen := make(map[string]int, len(trustRoots)) + + for i, trustRoot := range trustRoots { + name := fmt.Sprintf("custodianTrustRoots[%d]", i) + route, reserve, network, publicKey, err := normalizeScopedApprovalTrustRoot( + name, + trustRoot.Route, + trustRoot.Reserve, + trustRoot.Network, + trustRoot.PublicKey, + ) + if err != nil { + return nil, err + } + + scopeKey := string(route) + "|" + reserve + "|" + network + if previousIndex, ok := seen[scopeKey]; ok { + return nil, &inputError{ + fmt.Sprintf( + "%s duplicates custodianTrustRoots[%d] for route %s reserve %s network %s", + name, + previousIndex, + route, + reserve, + network, + ), + } + } + seen[scopeKey] = i + + normalized[i] = CustodianTrustRoot{ + Route: route, + Reserve: reserve, + Network: network, + PublicKey: publicKey, + } + } + + return normalized, nil +} + +func trustRootLookupScope(request RouteSubmitRequest) (TemplateID, string, string) { + network := "" + if request.MigrationDestination != nil { + network = strings.ToLower(strings.TrimSpace(request.MigrationDestination.Network)) + } + + return request.Route, normalizeLowerHex(request.Reserve), network +} + +func resolveExpectedDepositorPublicKey( + request RouteSubmitRequest, + trustRoots []DepositorTrustRoot, +) (string, bool) { + route, reserve, network := trustRootLookupScope(request) + for _, trustRoot := range trustRoots { + if trustRoot.Route == route && + trustRoot.Reserve == reserve && + trustRoot.Network == network { + return trustRoot.PublicKey, true + } + } + + return "", false +} + +func resolveExpectedCustodianPublicKey( + request RouteSubmitRequest, + trustRoots []CustodianTrustRoot, +) (string, bool) { + route, reserve, network := trustRootLookupScope(request) + for _, trustRoot := range trustRoots { + if trustRoot.Route == route && + trustRoot.Reserve == reserve && + trustRoot.Network == network { + return trustRoot.PublicKey, true + } + } + + return "", false +} + func migrationPlanQuoteSigningPayloadBytes( quote *MigrationDestinationPlanQuote, ) ([]byte, error) { @@ -1540,9 +1722,29 @@ func validateCommonRequest( if err := validateHexString("request.scriptTemplate.signerPublicKey", template.SignerPublicKey); err != nil { return err } + + depositorPublicKey := template.DepositorPublicKey + if len(resolvedOptions.depositorTrustRoots) > 0 { + expectedDepositorPublicKey, ok := resolveExpectedDepositorPublicKey( + request, + resolvedOptions.depositorTrustRoots, + ) + if !ok { + return &inputError{ + "request.scriptTemplate.depositorPublicKey requires a matching configured depositorTrustRoots entry for self_v1", + } + } + if normalizeLowerHex(template.DepositorPublicKey) != expectedDepositorPublicKey { + return &inputError{ + "request.scriptTemplate.depositorPublicKey must match the configured depositorTrustRoots publicKey for self_v1", + } + } + depositorPublicKey = expectedDepositorPublicKey + } + if err := validateArtifactApprovalAuthenticity( request, - template.DepositorPublicKey, + depositorPublicKey, "", ); err != nil { return err @@ -1567,10 +1769,30 @@ func validateCommonRequest( if err := validateHexString("request.scriptTemplate.signerPublicKey", template.SignerPublicKey); err != nil { return err } + + custodianPublicKey := template.CustodianPublicKey + if len(resolvedOptions.custodianTrustRoots) > 0 { + expectedCustodianPublicKey, ok := resolveExpectedCustodianPublicKey( + request, + resolvedOptions.custodianTrustRoots, + ) + if !ok { + return &inputError{ + "request.scriptTemplate.custodianPublicKey requires a matching configured custodianTrustRoots entry for qc_v1", + } + } + if normalizeLowerHex(template.CustodianPublicKey) != expectedCustodianPublicKey { + return &inputError{ + "request.scriptTemplate.custodianPublicKey must match the configured custodianTrustRoots publicKey for qc_v1", + } + } + custodianPublicKey = expectedCustodianPublicKey + } + if err := validateArtifactApprovalAuthenticity( request, template.DepositorPublicKey, - template.CustodianPublicKey, + custodianPublicKey, ); err != nil { return err } From 4933af0038d4b065e524a3f32084d1eff0f742a1 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 13:12:04 -0500 Subject: [PATCH 036/143] Tighten covenant approval trust roots --- pkg/covenantsigner/covenantsigner_test.go | 165 ++++++++++++++++++++++ pkg/covenantsigner/server.go | 9 ++ pkg/covenantsigner/validation.go | 21 ++- 3 files changed, 194 insertions(+), 1 deletion(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 2e58096c69..2812d4a657 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -2503,6 +2503,32 @@ func TestServiceAcceptsQcV1WithMatchingCustodianTrustRoot(t *testing.T) { } } +func TestServiceAcceptsQcV1WithMatchingDepositorAndCustodianTrustRoots(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + }), + WithCustodianTrustRoots([]CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_qc_depositor_and_custodian_trust_root_match", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + }) + if err != nil { + t.Fatal(err) + } +} + func TestServiceRejectsQcV1WithoutMatchingCustodianTrustRoot(t *testing.T) { handle := newMemoryHandle() service, err := NewService( @@ -2539,6 +2565,73 @@ func TestServiceRejectsQcV1WithoutMatchingCustodianTrustRoot(t *testing.T) { } } +func TestServiceRejectsQcV1WithoutMatchingDepositorTrustRoot(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + }), + ) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateQcV1) + request.ScriptTemplate = mustTemplate(QcV1Template{ + Template: TemplateQcV1, + DepositorPublicKey: testSignerPublicKey, + CustodianPublicKey: testCustodianPublicKey, + SignerPublicKey: testSignerPublicKey, + Beta: 144, + Delta2: 4320, + }) + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_qc_depositor_trust_root_mismatch", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil || !strings.Contains( + err.Error(), + "request.scriptTemplate.depositorPublicKey must match the configured depositorTrustRoots publicKey for qc_v1", + ) { + t.Fatalf("expected qc_v1 depositor trust-root mismatch, got %v", err) + } +} + +func TestServiceRejectsQcV1WithoutConfiguredDepositorTrustRootMatch(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + { + Route: TemplateQcV1, + Reserve: "0x9999999999999999999999999999999999999999", + Network: "regtest", + PublicKey: testDepositorPublicKey, + }, + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "orq_qc_depositor_trust_root_missing", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + }) + if err == nil || !strings.Contains( + err.Error(), + "request.scriptTemplate.depositorPublicKey requires a matching configured depositorTrustRoots entry for qc_v1", + ) { + t.Fatalf("expected missing qc_v1 depositor trust-root error, got %v", err) + } +} + func TestServiceRejectsQcV1WithoutConfiguredCustodianTrustRootMatch(t *testing.T) { handle := newMemoryHandle() service, err := NewService( @@ -2570,6 +2663,78 @@ func TestServiceRejectsQcV1WithoutConfiguredCustodianTrustRootMatch(t *testing.T } } +func TestNewServiceRejectsDuplicateDepositorTrustRootScope(t *testing.T) { + handle := newMemoryHandle() + + _, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + testDepositorTrustRoot(TemplateSelfV1), + testDepositorTrustRoot(TemplateSelfV1), + }), + ) + if err == nil || !strings.Contains( + err.Error(), + "duplicates depositorTrustRoots[0]", + ) { + t.Fatalf("expected duplicate depositor trust-root error, got %v", err) + } +} + +func TestNewServiceRejectsInvalidCustodianTrustRootPublicKey(t *testing.T) { + handle := newMemoryHandle() + + _, err := NewService( + handle, + &scriptedEngine{}, + WithCustodianTrustRoots([]CustodianTrustRoot{ + { + Route: TemplateQcV1, + Reserve: validMigrationDestination().Reserve, + Network: validMigrationDestination().Network, + PublicKey: "0x1234", + }, + }), + ) + if err == nil || !strings.Contains( + err.Error(), + "custodianTrustRoots[0].publicKey must be a compressed secp256k1 public key", + ) { + t.Fatalf("expected invalid custodian trust-root public key error, got %v", err) + } +} + +func TestServiceAcceptsMixedCaseDepositorTrustRootConfig(t *testing.T) { + handle := newMemoryHandle() + migrationDestination := validMigrationDestination() + + service, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{ + { + Route: TemplateSelfV1, + Reserve: mixedCaseHexBody(migrationDestination.Reserve), + Network: strings.ToUpper(migrationDestination.Network), + PublicKey: mixedCaseHexBody(testDepositorPublicKey), + }, + }), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_self_trust_root_mixed_case_config", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + if err != nil { + t.Fatal(err) + } +} + func TestServicePollAcceptsEquivalentArtifactApprovalRequestVariants(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 95caf3a9c7..6667e9a76a 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -75,6 +75,15 @@ func Initialize( "self_v1 depositor approvals still rely on request-supplied scriptTemplate keys", ) } + if !hasDepositorTrustRootForRoute( + service.depositorTrustRoots, + TemplateQcV1, + ) { + logger.Warn( + "covenant signer started without qc_v1 depositorTrustRoots; " + + "qc_v1 depositor approvals still rely on request-supplied scriptTemplate keys", + ) + } if !hasCustodianTrustRootForRoute( service.custodianTrustRoots, TemplateQcV1, diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 373defbe2c..6ae0c9d8eb 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -1770,6 +1770,25 @@ func validateCommonRequest( return err } + depositorPublicKey := template.DepositorPublicKey + if len(resolvedOptions.depositorTrustRoots) > 0 { + expectedDepositorPublicKey, ok := resolveExpectedDepositorPublicKey( + request, + resolvedOptions.depositorTrustRoots, + ) + if !ok { + return &inputError{ + "request.scriptTemplate.depositorPublicKey requires a matching configured depositorTrustRoots entry for qc_v1", + } + } + if normalizeLowerHex(template.DepositorPublicKey) != expectedDepositorPublicKey { + return &inputError{ + "request.scriptTemplate.depositorPublicKey must match the configured depositorTrustRoots publicKey for qc_v1", + } + } + depositorPublicKey = expectedDepositorPublicKey + } + custodianPublicKey := template.CustodianPublicKey if len(resolvedOptions.custodianTrustRoots) > 0 { expectedCustodianPublicKey, ok := resolveExpectedCustodianPublicKey( @@ -1791,7 +1810,7 @@ func validateCommonRequest( if err := validateArtifactApprovalAuthenticity( request, - template.DepositorPublicKey, + depositorPublicKey, custodianPublicKey, ); err != nil { return err From 3a59edbae21933589281d23b12db9490a2838a00 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 15:03:31 -0500 Subject: [PATCH 037/143] Differentiate self_v1 presign and reconstruction requests --- pkg/covenantsigner/covenantsigner_test.go | 61 +++++++++++++++++++ ...covenant_recovery_approval_vectors_v1.json | 6 +- pkg/covenantsigner/types.go | 8 +++ pkg/covenantsigner/validation.go | 25 ++++++++ pkg/tbtc/covenant_signer.go | 7 ++- pkg/tbtc/covenant_signer_test.go | 5 ++ pkg/tbtc/signer_approval_certificate_test.go | 1 + 7 files changed, 110 insertions(+), 3 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 2812d4a657..411046d544 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -374,6 +374,7 @@ func baseRequest(route TemplateID) RouteSubmitRequest { request := RouteSubmitRequest{ FacadeRequestID: "rf_123", IdempotencyKey: "idem_123", + RequestType: RequestTypeReconstruct, Route: route, Strategy: "0x1234", Reserve: migrationDestination.Reserve, @@ -2911,6 +2912,65 @@ func TestApprovalContractVectorsNormalizeEquivalentVariants(t *testing.T) { } } +func TestRequestDigestDistinguishesSelfV1PresignFromReconstruct(t *testing.T) { + reconstructRequest := structuredSignerApprovalRequest(TemplateSelfV1) + reconstructRequest.RequestType = RequestTypeReconstruct + + presignRequest := cloneRouteSubmitRequest(t, reconstructRequest) + presignRequest.RequestType = RequestTypePresignSelfV1 + + reconstructDigest, err := requestDigest(reconstructRequest) + if err != nil { + t.Fatal(err) + } + presignDigest, err := requestDigest(presignRequest) + if err != nil { + t.Fatal(err) + } + + if reconstructDigest == presignDigest { + t.Fatalf("expected distinct self_v1 digests, got %s", reconstructDigest) + } + + normalizedReconstruct, err := normalizeRouteSubmitRequest(reconstructRequest) + if err != nil { + t.Fatal(err) + } + normalizedPresign, err := normalizeRouteSubmitRequest(presignRequest) + if err != nil { + t.Fatal(err) + } + + if normalizedReconstruct.RequestType != RequestTypeReconstruct { + t.Fatalf("expected reconstruct requestType, got %s", normalizedReconstruct.RequestType) + } + if normalizedPresign.RequestType != RequestTypePresignSelfV1 { + t.Fatalf("expected presign requestType, got %s", normalizedPresign.RequestType) + } +} + +func TestServiceRejectsQcV1PresignRequestType(t *testing.T) { + service, err := NewService(newMemoryHandle(), &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + + request := baseRequest(TemplateQcV1) + request.RequestType = RequestTypePresignSelfV1 + + _, err = service.Submit(context.Background(), TemplateQcV1, SignerSubmitInput{ + RouteRequestID: "route_qc_invalid_presign", + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil { + t.Fatal("expected requestType validation error") + } + if !strings.Contains(err.Error(), "request.requestType must be reconstruct for qc_v1") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestRequestDigestNormalizesMixedCaseArtifactApprovalVariants(t *testing.T) { for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} { t.Run(string(route), func(t *testing.T) { @@ -3169,6 +3229,7 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { "facadeRequestId":"rf_123", "idempotencyKey":"idem_123", "route":"self_v1", + "requestType":"reconstruct", "strategy":"0x1234", "reserve":"0x1111111111111111111111111111111111111111", "epoch":12, diff --git a/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json index 7416c451de..10ff070172 100644 --- a/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json +++ b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json @@ -8,6 +8,7 @@ "facadeRequestId": "rf_vector_qc_v1", "idempotencyKey": "idem-qc-vector-v1", "route": "qc_v1", + "requestType": "reconstruct", "strategy": "0x1111111111111111111111111111111111111111", "reserve": "0x2222222222222222222222222222222222222222", "epoch": 12, @@ -91,7 +92,7 @@ "custodianRequired": true } }, - "expectedRequestDigest": "0x1a435b7fda4d048b8b4b9fb1448c14f581d4b3e38ddfe526b17b3c5ef719498c" + "expectedRequestDigest": "0x5cdfdc1861efd8ed59b0ee9b3b2a8583fc787321900fd36f4198db311a22fbcc" }, "self_v1": { "expectedApprovalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", @@ -99,6 +100,7 @@ "facadeRequestId": "rf_vector_self_v1", "idempotencyKey": "idem-self-vector-v1", "route": "self_v1", + "requestType": "reconstruct", "strategy": "0x1111111111111111111111111111111111111111", "reserve": "0x2222222222222222222222222222222222222222", "epoch": 12, @@ -175,7 +177,7 @@ "custodianRequired": false } }, - "expectedRequestDigest": "0xb5cd6a894a8e6bb5e68310e9c43560e91de1fc62bf0acf73b63e882332b2138a" + "expectedRequestDigest": "0x238153ab33ce630fe44c59da2a42ef3a0eeb106df86c59c893c0047648589e05" } } } diff --git a/pkg/covenantsigner/types.go b/pkg/covenantsigner/types.go index 21e723a95c..b991bbb7e9 100644 --- a/pkg/covenantsigner/types.go +++ b/pkg/covenantsigner/types.go @@ -9,6 +9,13 @@ const ( TemplateSelfV1 TemplateID = "self_v1" ) +type RequestType string + +const ( + RequestTypeReconstruct RequestType = "reconstruct" + RequestTypePresignSelfV1 RequestType = "presign_self_v1" +) + type RecoveryPathID string const ( @@ -205,6 +212,7 @@ type SigningRequirements struct { type RouteSubmitRequest struct { FacadeRequestID string `json:"facadeRequestId"` IdempotencyKey string `json:"idempotencyKey"` + RequestType RequestType `json:"requestType"` Route TemplateID `json:"route"` Strategy string `json:"strategy"` Reserve string `json:"reserve"` diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 6ae0c9d8eb..b79dbb03c7 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -221,6 +221,23 @@ func normalizeSignerApprovalMemberIndexes( return normalized, nil } +func normalizeRequestType( + route TemplateID, + requestType RequestType, +) (RequestType, error) { + switch requestType { + case RequestTypeReconstruct: + return requestType, nil + case RequestTypePresignSelfV1: + if route != TemplateSelfV1 { + return "", &inputError{"request.requestType must be reconstruct for qc_v1"} + } + return requestType, nil + default: + return "", &inputError{"request.requestType must be reconstruct or presign_self_v1"} + } +} + func normalizeSignerApprovalCertificate( request RouteSubmitRequest, ) (*SignerApprovalCertificate, error) { @@ -1613,10 +1630,15 @@ func normalizeRouteSubmitRequest( if err != nil { return RouteSubmitRequest{}, err } + normalizedRequestType, err := normalizeRequestType(request.Route, request.RequestType) + if err != nil { + return RouteSubmitRequest{}, err + } return RouteSubmitRequest{ FacadeRequestID: request.FacadeRequestID, IdempotencyKey: request.IdempotencyKey, + RequestType: normalizedRequestType, Route: request.Route, Strategy: normalizeLowerHex(request.Strategy), Reserve: normalizeLowerHex(request.Reserve), @@ -1660,6 +1682,9 @@ func validateCommonRequest( if request.Route != route { return &inputError{"request.route does not match endpoint route"} } + if _, err := normalizeRequestType(route, request.RequestType); err != nil { + return err + } if err := validateHexString("request.strategy", request.Strategy); err != nil { return err } diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index 7fafad1ec0..c1cdffcc5b 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -240,7 +240,12 @@ func (cse *covenantSignerEngine) submitSelfV1( return &covenantsigner.Transition{ State: covenantsigner.JobStateArtifactReady, - Detail: "self_v1 artifact ready", + Detail: func() string { + if job.Request.RequestType == covenantsigner.RequestTypePresignSelfV1 { + return "self_v1 presign artifact ready" + } + return "self_v1 artifact ready" + }(), PSBTHash: psbtHash, TransactionHex: transactionHex, } diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index 6f77413638..d1c7c81595 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -173,6 +173,7 @@ func TestCovenantSignerEngine_SubmitSelfV1Ready(t *testing.T) { request := covenantsigner.RouteSubmitRequest{ FacadeRequestID: "rf_self_1", IdempotencyKey: "idem_self_1", + RequestType: covenantsigner.RequestTypeReconstruct, Route: covenantsigner.TemplateSelfV1, Strategy: "0x1234", Reserve: reserve, @@ -410,6 +411,7 @@ func TestCovenantSignerEngine_SubmitQcV1HandoffReady(t *testing.T) { request := covenantsigner.RouteSubmitRequest{ FacadeRequestID: "rf_qc_1", IdempotencyKey: "idem_qc_1", + RequestType: covenantsigner.RequestTypeReconstruct, Route: covenantsigner.TemplateQcV1, Strategy: "0x1234", Reserve: reserve, @@ -642,6 +644,7 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsInvalidBeta(t *testing.T) { request := covenantsigner.RouteSubmitRequest{ FacadeRequestID: "rf_qc_bad_beta", IdempotencyKey: "idem_qc_bad_beta", + RequestType: covenantsigner.RequestTypeReconstruct, Route: covenantsigner.TemplateQcV1, Strategy: "0x1234", Reserve: reserve, @@ -801,6 +804,7 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsScriptHashMismatch(t *testing.T) request := covenantsigner.RouteSubmitRequest{ FacadeRequestID: "rf_qc_bad_script_hash", IdempotencyKey: "idem_qc_bad_script_hash", + RequestType: covenantsigner.RequestTypeReconstruct, Route: covenantsigner.TemplateQcV1, Strategy: "0x1234", Reserve: reserve, @@ -897,6 +901,7 @@ func TestCovenantSignerEngine_SubmitSelfV1RejectsZeroMaturityHeight(t *testing.T request := covenantsigner.RouteSubmitRequest{ FacadeRequestID: "rf_self_zero", IdempotencyKey: "idem_self_zero", + RequestType: covenantsigner.RequestTypeReconstruct, Route: covenantsigner.TemplateSelfV1, Strategy: "0x1234", Reserve: reserve, diff --git a/pkg/tbtc/signer_approval_certificate_test.go b/pkg/tbtc/signer_approval_certificate_test.go index 21ce550998..5354a8bdf4 100644 --- a/pkg/tbtc/signer_approval_certificate_test.go +++ b/pkg/tbtc/signer_approval_certificate_test.go @@ -42,6 +42,7 @@ func validStructuredSignerApprovalVerificationRequest( ) request := covenantsigner.RouteSubmitRequest{ + RequestType: covenantsigner.RequestTypeReconstruct, Route: route, ArtifactApprovals: &covenantsigner.ArtifactApprovalEnvelope{ Payload: covenantsigner.ArtifactApprovalPayload{ From 10dd782a4de4c65e238a01ad7160ab3f398500ed Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 15:49:39 -0500 Subject: [PATCH 038/143] Add self_v1 presign request vectors --- pkg/covenantsigner/covenantsigner_test.go | 18 ++-- ...covenant_recovery_approval_vectors_v1.json | 85 +++++++++++++++++++ 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 411046d544..b6bd61cf5e 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -129,7 +129,7 @@ type migrationPlanQuoteSigningVectorsFile struct { func loadApprovalContractVector( t *testing.T, - route TemplateID, + key string, ) (RouteSubmitRequest, string, string) { t.Helper() @@ -149,9 +149,9 @@ func loadApprovalContractVector( t.Fatalf("unexpected vector scope: %s", vectors.Scope) } - vector, ok := vectors.Vectors[string(route)] + vector, ok := vectors.Vectors[key] if !ok { - t.Fatalf("missing vector for route %s", route) + t.Fatalf("missing vector %s", key) } request := RouteSubmitRequest{} @@ -2846,9 +2846,9 @@ func TestArtifactApprovalDigestMatchesPhase1Contract(t *testing.T) { } func TestApprovalContractVectorsMatchExpectedRequestDigests(t *testing.T) { - for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} { - t.Run(string(route), func(t *testing.T) { - request, expectedApprovalDigest, expectedDigest := loadApprovalContractVector(t, route) + for _, vectorKey := range []string{"qc_v1", "self_v1", "self_v1_presign"} { + t.Run(vectorKey, func(t *testing.T) { + request, expectedApprovalDigest, expectedDigest := loadApprovalContractVector(t, vectorKey) digestBytes, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) if err != nil { @@ -2875,9 +2875,9 @@ func TestApprovalContractVectorsMatchExpectedRequestDigests(t *testing.T) { } func TestApprovalContractVectorsNormalizeEquivalentVariants(t *testing.T) { - for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} { - t.Run(string(route), func(t *testing.T) { - canonicalRequest, _, expectedDigest := loadApprovalContractVector(t, route) + for _, vectorKey := range []string{"qc_v1", "self_v1", "self_v1_presign"} { + t.Run(vectorKey, func(t *testing.T) { + canonicalRequest, _, expectedDigest := loadApprovalContractVector(t, vectorKey) normalizedCanonical, err := normalizeRouteSubmitRequest(canonicalRequest) if err != nil { diff --git a/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json index 10ff070172..393d97ca52 100644 --- a/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json +++ b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json @@ -178,6 +178,91 @@ } }, "expectedRequestDigest": "0x238153ab33ce630fe44c59da2a42ef3a0eeb106df86c59c893c0047648589e05" + }, + "self_v1_presign": { + "expectedApprovalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", + "canonicalSubmitRequest": { + "facadeRequestId": "rf_vector_self_v1_presign", + "idempotencyKey": "idem-self-presign-vector-v1", + "route": "self_v1", + "requestType": "presign_self_v1", + "strategy": "0x1111111111111111111111111111111111111111", + "reserve": "0x2222222222222222222222222222222222222222", + "epoch": 12, + "maturityHeight": 950000, + "activeOutpoint": { + "txid": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "vout": 1, + "scriptHash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", + "migrationDestination": { + "reservationId": "cmdr_12345678", + "reserve": "0x2222222222222222222222222222222222222222", + "epoch": 12, + "route": "MIGRATION", + "revealer": "0x2222222222222222222222222222222222222222", + "vault": "0x3333333333333333333333333333333333333333", + "network": "regtest", + "status": "RESERVED", + "depositScript": "0x0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "depositScriptHash": "0x8532ec6785e391b2af968b5728d574e271c7f46658f5ed10845d9ad5b23ac6d3", + "migrationExtraData": "0x41435f4d49475241544556312222222222222222222222222222222222222222", + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9" + }, + "migrationTransactionPlan": { + "planVersion": 1, + "planCommitmentHash": "0xc14b6b7c58211ceaee8f57a39c07481d9835ef959dbd6a02908312db4cf3c969", + "inputValueSats": 1000000, + "destinationValueSats": 998000, + "anchorValueSats": 330, + "feeSats": 1670, + "inputSequence": 4294967293, + "lockTime": 950000 + }, + "artifactApprovals": { + "payload": { + "approvalVersion": 1, + "route": "self_v1", + "scriptTemplateId": "self_v1", + "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", + "planCommitmentHash": "0xc14b6b7c58211ceaee8f57a39c07481d9835ef959dbd6a02908312db4cf3c969" + }, + "approvals": [ + { + "role": "D", + "signature": "0xd0d0" + } + ] + }, + "signerApproval": { + "certificateVersion": 1, + "signatureAlgorithm": "tecdsa-secp256k1", + "approvalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", + "walletPublicKey": "0x04d140d1eedb94f53ce43e0f4d68e8e0de6d6f2a444ef98f2a0e6c0f7fca02ef7dc4cb14e7b0f7c23787c93ca4d978f312c64379f38d9f52f86d1a89f0f8572f9f", + "signerSetHash": "0xabababababababababababababababababababababababababababababababab", + "signature": "0x5050", + "activeMembers": [1, 2, 3], + "inactiveMembers": [4, 5], + "endBlock": 123 + }, + "artifactSignatures": [ + "0xd0d0", + "0x5050" + ], + "artifacts": {}, + "scriptTemplate": { + "template": "self_v1", + "depositorPublicKey": "0x02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "signerPublicKey": "0x02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "delta2": 4320 + }, + "signing": { + "signerRequired": true, + "custodianRequired": false + } + }, + "expectedRequestDigest": "0xb44ea2821d1734a8af7a71cb9cf70712f989ac11404222a5315d0db15b248de1" } } } From 3aa2dc8002aa197753c31b025aa786a073c2d9ae Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 15:53:14 -0500 Subject: [PATCH 039/143] Run gofmt on covenant signer files --- pkg/tbtc/covenant_signer.go | 2 +- pkg/tbtc/signer_approval_certificate_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index c1cdffcc5b..9b31cd2a33 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -239,7 +239,7 @@ func (cse *covenantSignerEngine) submitSelfV1( psbtHash := "0x" + transaction.WitnessHash().Hex(bitcoin.InternalByteOrder) return &covenantsigner.Transition{ - State: covenantsigner.JobStateArtifactReady, + State: covenantsigner.JobStateArtifactReady, Detail: func() string { if job.Request.RequestType == covenantsigner.RequestTypePresignSelfV1 { return "self_v1 presign artifact ready" diff --git a/pkg/tbtc/signer_approval_certificate_test.go b/pkg/tbtc/signer_approval_certificate_test.go index 5354a8bdf4..9caa0772ab 100644 --- a/pkg/tbtc/signer_approval_certificate_test.go +++ b/pkg/tbtc/signer_approval_certificate_test.go @@ -43,7 +43,7 @@ func validStructuredSignerApprovalVerificationRequest( request := covenantsigner.RouteSubmitRequest{ RequestType: covenantsigner.RequestTypeReconstruct, - Route: route, + Route: route, ArtifactApprovals: &covenantsigner.ArtifactApprovalEnvelope{ Payload: covenantsigner.ArtifactApprovalPayload{ ApprovalVersion: 1, From 834500b365a6353c55fc137030108e56d8cc2ce6 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 22:17:33 -0500 Subject: [PATCH 040/143] Require covenant approval trust roots in production mode --- cmd/flags.go | 6 ++ cmd/flags_test.go | 7 ++ config/config_test.go | 4 + pkg/covenantsigner/config.go | 5 + pkg/covenantsigner/covenantsigner_test.go | 124 ++++++++++++++++++++++ pkg/covenantsigner/server.go | 42 ++++++++ test/config.json | 3 +- test/config.toml | 1 + test/config.yaml | 1 + 9 files changed, 192 insertions(+), 1 deletion(-) diff --git a/cmd/flags.go b/cmd/flags.go index 9899b814d1..e2e82e3d80 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -338,6 +338,12 @@ func initCovenantSignerFlags(cmd *cobra.Command, cfg *config.Config) { false, "Expose self_v1 covenant signer HTTP routes. Keep disabled for a qc_v1-first launch unless self_v1 is explicitly approved.", ) + cmd.Flags().BoolVar( + &cfg.CovenantSigner.RequireApprovalTrustRoots, + "covenantSigner.requireApprovalTrustRoots", + false, + "Fail startup when enabled covenant routes are missing route-level approval trust roots. Request-time validation still enforces exact reserve/network trust-root matches.", + ) } // Initialize flags for Maintainer configuration. diff --git a/cmd/flags_test.go b/cmd/flags_test.go index 559640bac5..29ccddd53a 100644 --- a/cmd/flags_test.go +++ b/cmd/flags_test.go @@ -219,6 +219,13 @@ var cmdFlagsTests = map[string]struct { expectedValueFromFlag: true, defaultValue: false, }, + "covenantSigner.requireApprovalTrustRoots": { + readValueFunc: func(c *config.Config) interface{} { return c.CovenantSigner.RequireApprovalTrustRoots }, + flagName: "--covenantSigner.requireApprovalTrustRoots", + flagValue: "", + expectedValueFromFlag: true, + defaultValue: false, + }, "tbtc.preParamsPoolSize": { readValueFunc: func(c *config.Config) interface{} { return c.Tbtc.PreParamsPoolSize }, flagName: "--tbtc.preParamsPoolSize", diff --git a/config/config_test.go b/config/config_test.go index 8f63b7ea99..a29da7d9fd 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -203,6 +203,10 @@ func TestReadConfigFromFile(t *testing.T) { readValueFunc: func(c *Config) interface{} { return c.CovenantSigner.Port }, expectedValue: 9702, }, + "CovenantSigner.RequireApprovalTrustRoots": { + readValueFunc: func(c *Config) interface{} { return c.CovenantSigner.RequireApprovalTrustRoots }, + expectedValue: true, + }, "Maintainer.BitcoinDifficulty.Enabled": { readValueFunc: func(c *Config) interface{} { return c.Maintainer.BitcoinDifficulty.Enabled }, expectedValue: true, diff --git a/pkg/covenantsigner/config.go b/pkg/covenantsigner/config.go index e14b855eb0..d9e100261f 100644 --- a/pkg/covenantsigner/config.go +++ b/pkg/covenantsigner/config.go @@ -15,6 +15,11 @@ type Config struct { // EnableSelfV1 exposes the self_v1 signer HTTP routes. Keep this disabled // for a qc_v1-first launch unless self_v1 has cleared its own go-live gate. EnableSelfV1 bool + // RequireApprovalTrustRoots turns missing route-level approval trust roots + // from startup warnings into startup errors. This does not prove every + // reserve/network launch scope is provisioned; request-time validation still + // enforces exact route/reserve/network matches for configured entries. + RequireApprovalTrustRoots bool `mapstructure:"requireApprovalTrustRoots"` // MigrationPlanQuoteTrustRoots configures the destination-service plan-quote // trust roots used to verify migration plan quotes when the quote authority // path is enabled. diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index b6bd61cf5e..85eb497cba 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -3337,6 +3337,130 @@ func TestInitializeRejectsInvalidOrUnavailablePort(t *testing.T) { } } +func availableLoopbackPort(t *testing.T) int { + t.Helper() + + listener, err := net.Listen("tcp", net.JoinHostPort(DefaultListenAddress, "0")) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + return listener.Addr().(*net.TCPAddr).Port +} + +func TestInitializeRequiresQcV1DepositorTrustRootsWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + RequireApprovalTrustRoots: true, + }, + handle, + &scriptedEngine{}, + ) + if err == nil || enabled { + t.Fatalf("expected missing qc_v1 depositor trust roots to fail, got enabled=%v err=%v", enabled, err) + } + if !strings.Contains( + err.Error(), + "covenant signer qc_v1 routes require depositorTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInitializeRequiresQcV1CustodianTrustRootsWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + RequireApprovalTrustRoots: true, + DepositorTrustRoots: []DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + }, + }, + handle, + &scriptedEngine{}, + ) + if err == nil || enabled { + t.Fatalf("expected missing qc_v1 custodian trust roots to fail, got enabled=%v err=%v", enabled, err) + } + if !strings.Contains( + err.Error(), + "covenant signer qc_v1 routes require custodianTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInitializeRequiresSelfV1DepositorTrustRootsWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + EnableSelfV1: true, + RequireApprovalTrustRoots: true, + DepositorTrustRoots: []DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + }, + CustodianTrustRoots: []CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }, + }, + handle, + &scriptedEngine{}, + ) + if err == nil || enabled { + t.Fatalf("expected missing self_v1 depositor trust roots to fail, got enabled=%v err=%v", enabled, err) + } + if !strings.Contains( + err.Error(), + "covenant signer self_v1 routes require depositorTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInitializeAcceptsRequiredApprovalTrustRootsWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + server, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + EnableSelfV1: true, + RequireApprovalTrustRoots: true, + DepositorTrustRoots: []DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + testDepositorTrustRoot(TemplateSelfV1), + }, + CustodianTrustRoots: []CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }, + }, + handle, + &scriptedEngine{}, + ) + if err != nil || !enabled || server == nil { + t.Fatalf("expected startup to succeed with required trust roots, got enabled=%v server=%v err=%v", enabled, server != nil, err) + } +} + func TestIsLoopbackListenAddressAcceptsBracketedIPv6Loopback(t *testing.T) { if !isLoopbackListenAddress("[::1]") { t.Fatal("expected bracketed IPv6 loopback address to be recognized") diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 6667e9a76a..8283c29ee5 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -58,6 +58,9 @@ func Initialize( if err != nil { return nil, false, err } + if err := validateRequiredApprovalTrustRoots(config, service); err != nil { + return nil, false, err + } if service.signerApprovalVerifier == nil { logger.Warn( "covenant signer started without a signer approval verifier; " + @@ -135,6 +138,45 @@ func Initialize( return server, true, nil } +func validateRequiredApprovalTrustRoots( + config Config, + service *Service, +) error { + if !config.RequireApprovalTrustRoots { + return nil + } + + if config.EnableSelfV1 && + !hasDepositorTrustRootForRoute( + service.depositorTrustRoots, + TemplateSelfV1, + ) { + return fmt.Errorf( + "covenant signer self_v1 routes require depositorTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) + } + + if !hasDepositorTrustRootForRoute( + service.depositorTrustRoots, + TemplateQcV1, + ) { + return fmt.Errorf( + "covenant signer qc_v1 routes require depositorTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) + } + + if !hasCustodianTrustRootForRoute( + service.custodianTrustRoots, + TemplateQcV1, + ) { + return fmt.Errorf( + "covenant signer qc_v1 routes require custodianTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) + } + + return nil +} + func hasDepositorTrustRootForRoute( trustRoots []DepositorTrustRoot, route TemplateID, diff --git a/test/config.json b/test/config.json index 96b5771908..8e3662cd9a 100644 --- a/test/config.json +++ b/test/config.json @@ -40,7 +40,8 @@ "EthereumMetricsTick": "1m27s" }, "CovenantSigner": { - "Port": 9702 + "Port": 9702, + "RequireApprovalTrustRoots": true }, "Maintainer": { "BitcoinDifficulty": { diff --git a/test/config.toml b/test/config.toml index 220c2dd6fa..44836f6e9a 100644 --- a/test/config.toml +++ b/test/config.toml @@ -37,6 +37,7 @@ EthereumMetricsTick = "1m27s" [covenantsigner] Port = 9702 +RequireApprovalTrustRoots = true [maintainer.BitcoinDifficulty] Enabled = true diff --git a/test/config.yaml b/test/config.yaml index 29b78b814d..dce648d86c 100644 --- a/test/config.yaml +++ b/test/config.yaml @@ -32,6 +32,7 @@ ClientInfo: EthereumMetricsTick: "1m27s" CovenantSigner: Port: 9702 + RequireApprovalTrustRoots: true Maintainer: BitcoinDifficulty: Enabled: true From 81c3d28cc1a084e04f064f339f60ce00f491fb69 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:47:54 +0000 Subject: [PATCH 041/143] fix(covenantsigner): add HTTP timeouts and body size cap --- pkg/covenantsigner/server.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 8283c29ee5..8f5ddd8c6f 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -23,6 +23,8 @@ type Server struct { httpServer *http.Server } +const maxRequestBodyBytes = 2 << 20 + func Initialize( ctx context.Context, config Config, @@ -103,6 +105,9 @@ func Initialize( Addr: net.JoinHostPort(listenAddress, strconv.Itoa(config.Port)), Handler: newHandler(service, config.AuthToken, config.EnableSelfV1), ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, }, } @@ -274,6 +279,7 @@ func withBearerAuth(next http.Handler, authToken string) http.Handler { } func decodeJSON[T any](w http.ResponseWriter, r *http.Request, target *T) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) defer r.Body.Close() decoder := json.NewDecoder(r.Body) From 14cafdd1ea7d0867d7d9ccb2b182f2a309c628ca Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:48:07 +0000 Subject: [PATCH 042/143] fix(covenantsigner): set explicit max header size --- pkg/covenantsigner/server.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 8f5ddd8c6f..2214e208cd 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -108,6 +108,7 @@ func Initialize( ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 1 << 13, }, } From f7535d205909fb4e4189102db870a8a3dd1a7e98 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:48:26 +0000 Subject: [PATCH 043/143] fix(tbtc): require signer set hash in certificate verification --- pkg/tbtc/signer_approval_certificate.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/tbtc/signer_approval_certificate.go b/pkg/tbtc/signer_approval_certificate.go index 6b2ceb1725..8f7edef89e 100644 --- a/pkg/tbtc/signer_approval_certificate.go +++ b/pkg/tbtc/signer_approval_certificate.go @@ -191,8 +191,10 @@ func verifySignerApprovalCertificate( if certificate.SignatureAlgorithm != signerApprovalCertificateSignatureAlgorithm { return fmt.Errorf("unsupported signature algorithm: %s", certificate.SignatureAlgorithm) } - if expectedSignerSetHash != "" && - strings.ToLower(expectedSignerSetHash) != strings.ToLower(certificate.SignerSetHash) { + if strings.TrimSpace(expectedSignerSetHash) == "" { + return fmt.Errorf("expected signer set hash must not be empty") + } + if strings.ToLower(expectedSignerSetHash) != strings.ToLower(certificate.SignerSetHash) { return fmt.Errorf("signer set hash does not match the expected signer set") } From ebb2ba06d1dd9c51c3118a44a303646bb12dce67 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:48:44 +0000 Subject: [PATCH 044/143] fix(tbtc): canonicalize signer set hash payload JSON --- pkg/tbtc/signer_approval_certificate.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/tbtc/signer_approval_certificate.go b/pkg/tbtc/signer_approval_certificate.go index 8f7edef89e..6ae5de20f6 100644 --- a/pkg/tbtc/signer_approval_certificate.go +++ b/pkg/tbtc/signer_approval_certificate.go @@ -1,6 +1,7 @@ package tbtc import ( + "bytes" "context" "crypto/ecdsa" "crypto/sha256" @@ -161,7 +162,7 @@ func computeSignerApprovalCertificateSignerSetHash( return "", err } - payload, err := json.Marshal(signerApprovalCertificateSignerSetPayload{ + payload, err := marshalCanonicalJSON(signerApprovalCertificateSignerSetPayload{ WalletID: "0x" + hex.EncodeToString(walletChainData.EcdsaWalletID[:]), WalletPublicKey: "0x" + hex.EncodeToString(walletPublicKeyBytes), MembersIDsHash: "0x" + hex.EncodeToString(walletChainData.MembersIDsHash[:]), @@ -178,6 +179,17 @@ func computeSignerApprovalCertificateSignerSetHash( return "0x" + hex.EncodeToString(sum[:]), nil } +func marshalCanonicalJSON(value any) ([]byte, error) { + buffer := bytes.NewBuffer(make([]byte, 0)) + encoder := json.NewEncoder(buffer) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return nil, err + } + + return bytes.TrimSpace(buffer.Bytes()), nil +} + func verifySignerApprovalCertificate( certificate *covenantsigner.SignerApprovalCertificate, expectedSignerSetHash string, From 9e7c33081a74db21651504419e1b37d74e29f4b0 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:48:57 +0000 Subject: [PATCH 045/143] fix(bitcoin): guard tx output index bounds in getScript --- pkg/bitcoin/transaction_builder.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/bitcoin/transaction_builder.go b/pkg/bitcoin/transaction_builder.go index 4fd688461f..69c746980a 100644 --- a/pkg/bitcoin/transaction_builder.go +++ b/pkg/bitcoin/transaction_builder.go @@ -147,6 +147,13 @@ func (tb *TransactionBuilder) getScript( err, ) } + if int(utxo.Outpoint.OutputIndex) >= len(transaction.Outputs) { + return nil, fmt.Errorf( + "output index [%d] out of bounds for transaction with [%d] outputs", + utxo.Outpoint.OutputIndex, + len(transaction.Outputs), + ) + } return transaction.Outputs[utxo.Outpoint.OutputIndex].PublicKeyScript, nil } From 38d56c610f3f03ce87946a30b4b35dd0529e3a3d Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:49:10 +0000 Subject: [PATCH 046/143] fix(tbtc): guard against empty witness stack --- pkg/tbtc/covenant_signer.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index 9b31cd2a33..03c5a3b597 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -694,6 +694,9 @@ func (cse *covenantSignerEngine) buildAndSignSelfV1Transaction( if len(transaction.Inputs) != 1 { return nil, fmt.Errorf("unexpected covenant input count") } + if len(transaction.Inputs[0].Witness) == 0 { + return nil, fmt.Errorf("unexpected empty covenant witness stack") + } if !bytes.Equal(transaction.Inputs[0].Witness[len(transaction.Inputs[0].Witness)-1], witnessScript) { // This can never happen with the current builder path, but keeping the // explicit comparison helps catch future witness-shape regressions. From 16fe73ce1a048a69901e1800ab70fe71866bb2c7 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:49:18 +0000 Subject: [PATCH 047/143] fix(covenantsigner): return 405 for poll path method mismatch --- pkg/covenantsigner/server.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 2214e208cd..56773702aa 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -350,7 +350,8 @@ func pollBodyHandler(service *Service, route TemplateID) http.HandlerFunc { func pollPathHandler(service *Service, route TemplateID) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.NotFound(w, r) + w.Header().Set("Allow", http.MethodPost) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } From 2358ffda4b6e057bf6e40e98211e01f4323d9639 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:49:29 +0000 Subject: [PATCH 048/143] fix(covenantsigner): return generic JSON decode errors --- pkg/covenantsigner/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 56773702aa..ab2671f89a 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -285,7 +285,7 @@ func decodeJSON[T any](w http.ResponseWriter, r *http.Request, target *T) bool { decoder := json.NewDecoder(r.Body) if err := decoder.Decode(target); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + http.Error(w, "malformed request body", http.StatusBadRequest) return false } From 417e242cdd15ee6d4de6918ff7501556366637e2 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:49:39 +0000 Subject: [PATCH 049/143] fix(covenantsigner): reject unknown JSON envelope fields --- pkg/covenantsigner/server.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index ab2671f89a..aa9953201b 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -284,6 +284,7 @@ func decodeJSON[T any](w http.ResponseWriter, r *http.Request, target *T) bool { defer r.Body.Close() decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { http.Error(w, "malformed request body", http.StatusBadRequest) return false From d64e83aa827bab705a12f1d9e83abe2d6d06a325 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:49:54 +0000 Subject: [PATCH 050/143] fix(covenantsigner): unescape poll request ID before slash validation --- pkg/covenantsigner/server.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index aa9953201b..2052c75dc2 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "net/http" + "net/url" "strconv" "strings" "time" @@ -362,7 +363,12 @@ func pollPathHandler(service *Service, route TemplateID) http.HandlerFunc { return } - pathRequestID := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, prefix), ":poll") + rawPathRequestID := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, prefix), ":poll") + pathRequestID, err := url.PathUnescape(rawPathRequestID) + if err != nil { + http.NotFound(w, r) + return + } if pathRequestID == "" || strings.Contains(pathRequestID, "/") { http.NotFound(w, r) return From 2d811350846e0c00e2cd3c0a00d0d83e4c4c44fb Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:50:17 +0000 Subject: [PATCH 051/143] fix(covenantsigner): validate facade and idempotency identifiers --- pkg/covenantsigner/validation.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index b79dbb03c7..905758c22a 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -52,6 +52,8 @@ var canonicalTimestampPattern = regexp.MustCompile( `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$`, ) +var requestIdentifierPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,255}$`) + type inputError struct { message string } @@ -139,6 +141,14 @@ func validateHexString(name string, value string) error { return nil } +func validateRequestIdentifier(name string, value string) error { + if !requestIdentifierPattern.MatchString(value) { + return &inputError{fmt.Sprintf("%s must match [a-zA-Z0-9_-] and be at most 255 chars", name)} + } + + return nil +} + func validateAddressString(name string, value string) error { if err := validateHexString(name, value); err != nil { return err @@ -1676,9 +1686,15 @@ func validateCommonRequest( if request.FacadeRequestID == "" { return &inputError{"request.facadeRequestId is required"} } + if err := validateRequestIdentifier("request.facadeRequestId", request.FacadeRequestID); err != nil { + return err + } if request.IdempotencyKey == "" { return &inputError{"request.idempotencyKey is required"} } + if err := validateRequestIdentifier("request.idempotencyKey", request.IdempotencyKey); err != nil { + return err + } if request.Route != route { return &inputError{"request.route does not match endpoint route"} } From 1722556d71fb559c0f969b130c7fedcbe13c7baf Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:50:31 +0000 Subject: [PATCH 052/143] fix(covenantsigner): error on unsupported submit route --- pkg/covenantsigner/service.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index fa7b74c72d..7f4d35acd7 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -257,11 +257,15 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm return mapJobResult(existing), nil } - requestIDPrefix := "kcs" - if route == TemplateQcV1 { + requestIDPrefix := "" + switch route { + case TemplateQcV1: requestIDPrefix = "kcs_qc" - } else if route == TemplateSelfV1 { + case TemplateSelfV1: requestIDPrefix = "kcs_self" + default: + s.mutex.Unlock() + return StepResult{}, fmt.Errorf("unsupported route: %s", route) } requestID, err := newRequestID(requestIDPrefix) From 46da4643a42d114991bbf69a354c6f52ead34250 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:52:25 +0000 Subject: [PATCH 053/143] refactor(covenantsigner): replace variadic validation options with explicit struct --- pkg/covenantsigner/covenantsigner_test.go | 36 ++++++++-------- pkg/covenantsigner/validation.go | 50 +++++++++-------------- 2 files changed, 39 insertions(+), 47 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 85eb497cba..c8f1b2b7d7 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -2084,7 +2084,7 @@ func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { } func TestRequestDigestNormalizesEquivalentArtifactApprovalVariants(t *testing.T) { - canonicalDigest, err := requestDigest(canonicalArtifactApprovalRequest(TemplateQcV1)) + canonicalDigest, err := requestDigest(canonicalArtifactApprovalRequest(TemplateQcV1), validationOptions{}) if err != nil { t.Fatal(err) } @@ -2094,6 +2094,7 @@ func TestRequestDigestNormalizesEquivalentArtifactApprovalVariants(t *testing.T) t, canonicalArtifactApprovalRequest(TemplateQcV1), ), + validationOptions{}, ) if err != nil { t.Fatal(err) @@ -2105,7 +2106,7 @@ func TestRequestDigestNormalizesEquivalentArtifactApprovalVariants(t *testing.T) } func TestRequestDigestNormalizesEquivalentStructuredSignerApprovalVariants(t *testing.T) { - canonicalDigest, err := requestDigest(structuredSignerApprovalRequest(TemplateQcV1)) + canonicalDigest, err := requestDigest(structuredSignerApprovalRequest(TemplateQcV1), validationOptions{}) if err != nil { t.Fatal(err) } @@ -2115,6 +2116,7 @@ func TestRequestDigestNormalizesEquivalentStructuredSignerApprovalVariants(t *te t, structuredSignerApprovalRequest(TemplateQcV1), ), + validationOptions{}, ) if err != nil { t.Fatal(err) @@ -2134,7 +2136,7 @@ func TestRequestDigestDoesNotEscapeHTMLSensitiveCharacters(t *testing.T) { request.FacadeRequestID = "rf_&sink" request.IdempotencyKey = "idem_>bridge" - normalizedRequest, err := normalizeRouteSubmitRequest(request) + normalizedRequest, err := normalizeRouteSubmitRequest(request, validationOptions{}) if err != nil { t.Fatal(err) } @@ -2153,7 +2155,7 @@ func TestRequestDigestDoesNotEscapeHTMLSensitiveCharacters(t *testing.T) { t.Fatalf("expected unescaped HTML-sensitive characters in payload, got %s", payload) } - digestFromRawRequest, err := requestDigest(request) + digestFromRawRequest, err := requestDigest(request, validationOptions{}) if err != nil { t.Fatal(err) } @@ -2813,7 +2815,7 @@ func TestRequestDigestRejectsArtifactApprovalsWithoutMigrationTransactionPlan(t request := canonicalArtifactApprovalRequest(TemplateSelfV1) request.MigrationTransactionPlan = nil - _, err := requestDigest(request) + _, err := requestDigest(request, validationOptions{}) if err == nil || !strings.Contains( err.Error(), "request.migrationTransactionPlan is required when request.artifactApprovals is present", @@ -2862,7 +2864,7 @@ func TestApprovalContractVectorsMatchExpectedRequestDigests(t *testing.T) { ) } - digest, err := requestDigest(request) + digest, err := requestDigest(request, validationOptions{}) if err != nil { t.Fatal(err) } @@ -2879,7 +2881,7 @@ func TestApprovalContractVectorsNormalizeEquivalentVariants(t *testing.T) { t.Run(vectorKey, func(t *testing.T) { canonicalRequest, _, expectedDigest := loadApprovalContractVector(t, vectorKey) - normalizedCanonical, err := normalizeRouteSubmitRequest(canonicalRequest) + normalizedCanonical, err := normalizeRouteSubmitRequest(canonicalRequest, validationOptions{}) if err != nil { t.Fatal(err) } @@ -2888,7 +2890,7 @@ func TestApprovalContractVectorsNormalizeEquivalentVariants(t *testing.T) { t, canonicalRequest, ) - normalizedVariant, err := normalizeRouteSubmitRequest(variantRequest) + normalizedVariant, err := normalizeRouteSubmitRequest(variantRequest, validationOptions{}) if err != nil { t.Fatal(err) } @@ -2901,7 +2903,7 @@ func TestApprovalContractVectorsNormalizeEquivalentVariants(t *testing.T) { ) } - digest, err := requestDigest(variantRequest) + digest, err := requestDigest(variantRequest, validationOptions{}) if err != nil { t.Fatal(err) } @@ -2919,11 +2921,11 @@ func TestRequestDigestDistinguishesSelfV1PresignFromReconstruct(t *testing.T) { presignRequest := cloneRouteSubmitRequest(t, reconstructRequest) presignRequest.RequestType = RequestTypePresignSelfV1 - reconstructDigest, err := requestDigest(reconstructRequest) + reconstructDigest, err := requestDigest(reconstructRequest, validationOptions{}) if err != nil { t.Fatal(err) } - presignDigest, err := requestDigest(presignRequest) + presignDigest, err := requestDigest(presignRequest, validationOptions{}) if err != nil { t.Fatal(err) } @@ -2932,11 +2934,11 @@ func TestRequestDigestDistinguishesSelfV1PresignFromReconstruct(t *testing.T) { t.Fatalf("expected distinct self_v1 digests, got %s", reconstructDigest) } - normalizedReconstruct, err := normalizeRouteSubmitRequest(reconstructRequest) + normalizedReconstruct, err := normalizeRouteSubmitRequest(reconstructRequest, validationOptions{}) if err != nil { t.Fatal(err) } - normalizedPresign, err := normalizeRouteSubmitRequest(presignRequest) + normalizedPresign, err := normalizeRouteSubmitRequest(presignRequest, validationOptions{}) if err != nil { t.Fatal(err) } @@ -2987,11 +2989,11 @@ func TestRequestDigestNormalizesMixedCaseArtifactApprovalVariants(t *testing.T) ) } - normalizedCanonical, err := normalizeRouteSubmitRequest(canonicalRequest) + normalizedCanonical, err := normalizeRouteSubmitRequest(canonicalRequest, validationOptions{}) if err != nil { t.Fatal(err) } - normalizedMixedCase, err := normalizeRouteSubmitRequest(mixedCaseRequest) + normalizedMixedCase, err := normalizeRouteSubmitRequest(mixedCaseRequest, validationOptions{}) if err != nil { t.Fatal(err) } @@ -3004,11 +3006,11 @@ func TestRequestDigestNormalizesMixedCaseArtifactApprovalVariants(t *testing.T) ) } - canonicalDigest, err := requestDigest(canonicalRequest) + canonicalDigest, err := requestDigest(canonicalRequest, validationOptions{}) if err != nil { t.Fatal(err) } - mixedCaseDigest, err := requestDigest(mixedCaseRequest) + mixedCaseDigest, err := requestDigest(mixedCaseRequest, validationOptions{}) if err != nil { t.Fatal(err) } diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 905758c22a..bf83fd50d5 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -93,24 +93,16 @@ type validationOptions struct { signerApprovalVerifier SignerApprovalVerifier } -func resolveValidationOptions(options []validationOptions) validationOptions { - if len(options) == 0 { - return validationOptions{} - } - - return options[0] -} - // requestDigest accepts raw requests because Poll validates equivalence against // whatever the caller resubmits. Submit should use requestDigestFromNormalized // after it has already normalized the request once for storage. func requestDigest( request RouteSubmitRequest, - options ...validationOptions, + options validationOptions, ) (string, error) { normalizedRequest, err := normalizeRouteSubmitRequest( request, - resolveValidationOptions(options), + options, ) if err != nil { return "", err @@ -1617,9 +1609,8 @@ func normalizeScriptTemplate(route TemplateID, rawTemplate json.RawMessage) (jso func normalizeRouteSubmitRequest( request RouteSubmitRequest, - options ...validationOptions, + options validationOptions, ) (RouteSubmitRequest, error) { - resolvedOptions := resolveValidationOptions(options) normalizedArtifactApprovals, normalizedSignerApproval, normalizedArtifactSignatures, err := normalizeArtifactApprovals( request.Route, request, @@ -1635,7 +1626,7 @@ func normalizeRouteSubmitRequest( normalizedMigrationPlanQuote, err := normalizeMigrationPlanQuote( request, - resolvedOptions, + options, ) if err != nil { return RouteSubmitRequest{}, err @@ -1680,9 +1671,8 @@ func normalizeRouteSubmitRequest( func validateCommonRequest( route TemplateID, request RouteSubmitRequest, - options ...validationOptions, + options validationOptions, ) error { - resolvedOptions := resolveValidationOptions(options) if request.FacadeRequestID == "" { return &inputError{"request.facadeRequestId is required"} } @@ -1730,13 +1720,13 @@ func validateCommonRequest( if err := validateMigrationTransactionPlan(request, request.MigrationTransactionPlan); err != nil { return err } - if _, err := normalizeMigrationPlanQuote(request, resolvedOptions); err != nil { + if _, err := normalizeMigrationPlanQuote(request, options); err != nil { return err } if request.ArtifactApprovals == nil { return &inputError{"request.artifactApprovals is required"} } - if resolvedOptions.signerApprovalVerifier != nil && request.SignerApproval == nil { + if options.signerApprovalVerifier != nil && request.SignerApproval == nil { return &inputError{ "request.signerApproval is required when the signer approval verifier is configured", } @@ -1765,10 +1755,10 @@ func validateCommonRequest( } depositorPublicKey := template.DepositorPublicKey - if len(resolvedOptions.depositorTrustRoots) > 0 { + if len(options.depositorTrustRoots) > 0 { expectedDepositorPublicKey, ok := resolveExpectedDepositorPublicKey( request, - resolvedOptions.depositorTrustRoots, + options.depositorTrustRoots, ) if !ok { return &inputError{ @@ -1812,10 +1802,10 @@ func validateCommonRequest( } depositorPublicKey := template.DepositorPublicKey - if len(resolvedOptions.depositorTrustRoots) > 0 { + if len(options.depositorTrustRoots) > 0 { expectedDepositorPublicKey, ok := resolveExpectedDepositorPublicKey( request, - resolvedOptions.depositorTrustRoots, + options.depositorTrustRoots, ) if !ok { return &inputError{ @@ -1831,10 +1821,10 @@ func validateCommonRequest( } custodianPublicKey := template.CustodianPublicKey - if len(resolvedOptions.custodianTrustRoots) > 0 { + if len(options.custodianTrustRoots) > 0 { expectedCustodianPublicKey, ok := resolveExpectedCustodianPublicKey( request, - resolvedOptions.custodianTrustRoots, + options.custodianTrustRoots, ) if !ok { return &inputError{ @@ -1861,18 +1851,18 @@ func validateCommonRequest( } if request.SignerApproval != nil { - if resolvedOptions.signerApprovalVerifier == nil { + if options.signerApprovalVerifier == nil { return &inputError{ "request.signerApproval cannot be verified by this signer deployment", } } - normalizedRequest, err := normalizeRouteSubmitRequest(request, resolvedOptions) + normalizedRequest, err := normalizeRouteSubmitRequest(request, options) if err != nil { return err } - if err := resolvedOptions.signerApprovalVerifier.VerifySignerApproval( + if err := options.signerApprovalVerifier.VerifySignerApproval( normalizedRequest, ); err != nil { return err @@ -1885,7 +1875,7 @@ func validateCommonRequest( func validateSubmitInput( route TemplateID, input SignerSubmitInput, - options ...validationOptions, + options validationOptions, ) error { if input.RouteRequestID == "" { return &inputError{"routeRequestId is required"} @@ -1893,13 +1883,13 @@ func validateSubmitInput( if input.Stage != StageSignerCoordination { return &inputError{"stage must be SIGNER_COORDINATION"} } - return validateCommonRequest(route, input.Request, resolveValidationOptions(options)) + return validateCommonRequest(route, input.Request, options) } func validatePollInput( route TemplateID, input SignerPollInput, - options ...validationOptions, + options validationOptions, ) error { if input.RequestID == "" { return &inputError{"requestId is required"} @@ -1908,7 +1898,7 @@ func validatePollInput( RouteRequestID: input.RouteRequestID, Request: input.Request, Stage: input.Stage, - }, resolveValidationOptions(options)); err != nil { + }, options); err != nil { return err } return nil From be135de964d2cb1cdbf0c199387390f25f718f05 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:52:56 +0000 Subject: [PATCH 054/143] fix(covenantsigner): make store replacement write-first --- pkg/covenantsigner/store.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index 263bd5b07c..0776ca0d02 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -150,13 +150,7 @@ func (s *Store) Put(job *Job) error { } key := routeKey(job.Route, job.RouteRequestID) - if existingRequestID, ok := s.byRouteKey[key]; ok && existingRequestID != job.RequestID { - if err := s.handle.Delete(jobsDirectory, existingRequestID+".json"); err != nil { - return err - } - delete(s.byRequestID, existingRequestID) - } - + existingRequestID, hasExisting := s.byRouteKey[key] if err := s.handle.Save(payload, jobsDirectory, job.RequestID+".json"); err != nil { return err } @@ -169,5 +163,17 @@ func (s *Store) Put(job *Job) error { s.byRequestID[job.RequestID] = cloned s.byRouteKey[key] = job.RequestID + if hasExisting && existingRequestID != job.RequestID { + if err := s.handle.Delete(jobsDirectory, existingRequestID+".json"); err != nil { + logger.Warnf( + "failed to delete stale covenant signer job file [%s]: [%v]", + existingRequestID+".json", + err, + ) + } else { + delete(s.byRequestID, existingRequestID) + } + } + return nil } From ab856f0d3ed6a82fa482659fa2757fc7a00b0b8d Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 18:09:13 +0000 Subject: [PATCH 055/143] test: align unknown-field behavior and fix go1.24 fmt vet issue --- pkg/covenantsigner/covenantsigner_test.go | 9 +++++++-- pkg/tbtcpg/internal/test/marshaling.go | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index c8f1b2b7d7..cc6c4cdd34 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -3205,7 +3205,7 @@ func TestServerHandlesSubmitAndPathPoll(t *testing.T) { } } -func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { +func TestServerRejectsUnknownFieldsOnSubmit(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ submit: func(*Job) (*Transition, error) { @@ -3299,10 +3299,15 @@ func TestServerIgnoresUnknownFieldsOnSubmit(t *testing.T) { } defer response.Body.Close() - if response.StatusCode != http.StatusOK { + if response.StatusCode != http.StatusBadRequest { body, _ := io.ReadAll(response.Body) t.Fatalf("unexpected submit status: %d %s", response.StatusCode, string(body)) } + + body, _ := io.ReadAll(response.Body) + if !strings.Contains(string(body), "malformed request body") { + t.Fatalf("unexpected response body: %s", string(body)) + } } func TestInitializeRejectsInvalidOrUnavailablePort(t *testing.T) { diff --git a/pkg/tbtcpg/internal/test/marshaling.go b/pkg/tbtcpg/internal/test/marshaling.go index 2dd72dbaa0..91c390df6e 100644 --- a/pkg/tbtcpg/internal/test/marshaling.go +++ b/pkg/tbtcpg/internal/test/marshaling.go @@ -3,6 +3,7 @@ package test import ( "encoding/hex" "encoding/json" + "errors" "fmt" "github.com/keep-network/keep-core/pkg/tbtcpg" "math/big" @@ -273,7 +274,7 @@ func (psts *ProposeSweepTestScenario) UnmarshalJSON(data []byte) error { // Unmarshal expected error if len(unmarshaled.ExpectedErr) > 0 { - psts.ExpectedErr = fmt.Errorf(unmarshaled.ExpectedErr) + psts.ExpectedErr = errors.New(unmarshaled.ExpectedErr) } return nil From e70573eabc7cdc2b115f8ecf7d705038ac5b9d56 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 18:30:35 +0000 Subject: [PATCH 056/143] chore: open follow-up branch for feat/psbt-covenant-final-project-pr From b06ed2e814da69d05192f9191d17db877d8f553e Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:00:27 +0000 Subject: [PATCH 057/143] ci(client): add race detector job for covenant signer and tbtc --- .github/workflows/client.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index f719505eee..6c37e25115 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -301,6 +301,21 @@ jobs: install-go: false checks: "-SA1019" + client-race: + needs: client-detect-changes + if: | + github.event_name == 'push' + || needs.client-detect-changes.outputs.path-filter == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + - name: Race detector (high-risk packages) + run: | + go test -race -timeout 20m ./pkg/covenantsigner ./pkg/tbtc + client-integration-test: needs: [electrum-integration-detect-changes, client-build-test-publish] if: | From d80c43b89e02e09e5c0f0340bad4b601bebdea95 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:01:32 +0000 Subject: [PATCH 058/143] test(covenantsigner): add HTTP boundary error matrix coverage --- pkg/covenantsigner/covenantsigner_test.go | 135 ++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index cc6c4cdd34..e0d7ea64bc 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -3599,3 +3599,138 @@ func TestServerCanKeepSelfV1RoutesDark(t *testing.T) { t.Fatalf("expected qc_v1 route to remain available, got %d %s", qcResponse.StatusCode, string(body)) } } + +func TestServerBoundaryErrorMatrix(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "test-token", true)) + defer server.Close() + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http_matrix", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + mismatchedPollPayload := mustJSON(t, SignerPollInput{ + RequestID: "different_id", + RouteRequestID: "ors_http_matrix", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + oversizedBody := []byte( + `{"routeRequestId":"ors_big","stage":"SIGNER_COORDINATION","request":{"facadeRequestId":"` + + strings.Repeat("a", maxRequestBodyBytes+1) + `"}}`, + ) + + testCases := []struct { + name string + method string + path string + body []byte + authHeader string + wantStatus int + wantBodyContains string + wantAllow string + }{ + { + name: "invalid bearer token", + method: http.MethodPost, + path: "/v1/self_v1/signer/requests", + body: submitPayload, + authHeader: "Bearer wrong-token", + wantStatus: http.StatusUnauthorized, + wantBodyContains: "invalid bearer token", + }, + { + name: "method mismatch on poll path returns 405", + method: http.MethodGet, + path: "/v1/self_v1/signer/requests/request_1:poll", + authHeader: "Bearer test-token", + wantStatus: http.StatusMethodNotAllowed, + wantBodyContains: "method not allowed", + wantAllow: http.MethodPost, + }, + { + name: "unknown fields in envelope rejected", + method: http.MethodPost, + path: "/v1/self_v1/signer/requests", + body: []byte(`{"routeRequestId":"ors_http_unknown","stage":"SIGNER_COORDINATION","request":{},"futureTopLevel":"ignored"}`), + authHeader: "Bearer test-token", + wantStatus: http.StatusBadRequest, + wantBodyContains: "malformed request body", + }, + { + name: "oversized body rejected", + method: http.MethodPost, + path: "/v1/self_v1/signer/requests", + body: oversizedBody, + authHeader: "Bearer test-token", + wantStatus: http.StatusBadRequest, + wantBodyContains: "malformed request body", + }, + { + name: "poll path and body request id mismatch rejected", + method: http.MethodPost, + path: "/v1/self_v1/signer/requests/request_from_path:poll", + body: mismatchedPollPayload, + authHeader: "Bearer test-token", + wantStatus: http.StatusBadRequest, + wantBodyContains: "requestId in body does not match path", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + request, err := http.NewRequest( + tc.method, + server.URL+tc.path, + bytes.NewReader(tc.body), + ) + if err != nil { + t.Fatal(err) + } + + if tc.body != nil { + request.Header.Set("Content-Type", "application/json") + } + if tc.authHeader != "" { + request.Header.Set("Authorization", tc.authHeader) + } + + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != tc.wantStatus { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected status: %d body: %s", response.StatusCode, string(body)) + } + + if tc.wantAllow != "" && response.Header.Get("Allow") != tc.wantAllow { + t.Fatalf("unexpected Allow header: %q", response.Header.Get("Allow")) + } + + if tc.wantBodyContains != "" { + body, _ := io.ReadAll(response.Body) + if !strings.Contains(string(body), tc.wantBodyContains) { + t.Fatalf("expected body to contain %q, got %q", tc.wantBodyContains, string(body)) + } + } + }) + } +} From 2d4e0443c7365eda73aefaa2920a90d09e69811d Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:02:37 +0000 Subject: [PATCH 059/143] test(covenantsigner): add store durability fault-injection coverage --- pkg/covenantsigner/covenantsigner_test.go | 186 ++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index e0d7ea64bc..d7e4bc760d 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "encoding/json" "encoding/pem" + "errors" "fmt" "io" "net" @@ -72,6 +73,36 @@ func (mh *memoryHandle) ReadAll() (<-chan persistence.DataDescriptor, <-chan err return dataChan, errorChan } +type faultingMemoryHandle struct { + *memoryHandle + saveErrByName map[string]error + deleteErrByName map[string]error +} + +func newFaultingMemoryHandle() *faultingMemoryHandle { + return &faultingMemoryHandle{ + memoryHandle: newMemoryHandle(), + saveErrByName: make(map[string]error), + deleteErrByName: make(map[string]error), + } +} + +func (fmh *faultingMemoryHandle) Save(data []byte, directory string, name string) error { + if err, ok := fmh.saveErrByName[name]; ok { + return err + } + + return fmh.memoryHandle.Save(data, directory, name) +} + +func (fmh *faultingMemoryHandle) Delete(directory string, name string) error { + if err, ok := fmh.deleteErrByName[name]; ok { + return err + } + + return fmh.memoryHandle.Delete(directory, name) +} + type scriptedEngine struct { submit func(*Job) (*Transition, error) poll func(*Job) (*Transition, error) @@ -3151,6 +3182,161 @@ func TestStoreReloadPreservesJobs(t *testing.T) { } } +func TestStorePutReturnsErrorWhenSaveFails(t *testing.T) { + handle := newFaultingMemoryHandle() + handle.saveErrByName["kcs_self_fail_save.json"] = errors.New("injected save failure") + + store, err := NewStore(handle) + if err != nil { + t.Fatal(err) + } + + err = store.Put(&Job{ + RequestID: "kcs_self_fail_save", + RouteRequestID: "ors_fail_save", + Route: TemplateSelfV1, + IdempotencyKey: "idem_fail_save", + FacadeRequestID: "rf_fail_save", + RequestDigest: "0xdeadbeef", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + }) + if err == nil || !strings.Contains(err.Error(), "injected save failure") { + t.Fatalf("expected injected save failure, got: %v", err) + } + + _, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_fail_save") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected no route mapping after failed save") + } +} + +func TestStorePutKeepsNewRouteMappingWhenOldDeleteFails(t *testing.T) { + handle := newFaultingMemoryHandle() + store, err := NewStore(handle) + if err != nil { + t.Fatal(err) + } + + initial := &Job{ + RequestID: "kcs_self_old", + RouteRequestID: "ors_replace", + Route: TemplateSelfV1, + IdempotencyKey: "idem_old", + FacadeRequestID: "rf_old", + RequestDigest: "0x1111", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + if err := store.Put(initial); err != nil { + t.Fatal(err) + } + + handle.deleteErrByName["kcs_self_old.json"] = errors.New("injected delete failure") + + replacement := &Job{ + RequestID: "kcs_self_new", + RouteRequestID: "ors_replace", + Route: TemplateSelfV1, + IdempotencyKey: "idem_new", + FacadeRequestID: "rf_new", + RequestDigest: "0x2222", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-10T00:00:00Z", + UpdatedAt: "2026-03-10T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + if err := store.Put(replacement); err != nil { + t.Fatalf("expected replacement put to succeed, got: %v", err) + } + + loaded, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_replace") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected route mapping to exist") + } + if loaded.RequestID != "kcs_self_new" { + t.Fatalf("expected route key to map to replacement job, got: %s", loaded.RequestID) + } +} + +func TestStoreLoadSelectsNewestJobForDuplicateRouteKeys(t *testing.T) { + handle := newMemoryHandle() + + oldJob := &Job{ + RequestID: "kcs_self_old_load", + RouteRequestID: "ors_load_dupe", + Route: TemplateSelfV1, + IdempotencyKey: "idem_old_load", + FacadeRequestID: "rf_old_load", + RequestDigest: "0xaaa", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + newJob := &Job{ + RequestID: "kcs_self_new_load", + RouteRequestID: "ors_load_dupe", + Route: TemplateSelfV1, + IdempotencyKey: "idem_new_load", + FacadeRequestID: "rf_new_load", + RequestDigest: "0xbbb", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-10T00:00:00Z", + UpdatedAt: "2026-03-10T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + oldPayload, err := json.Marshal(oldJob) + if err != nil { + t.Fatal(err) + } + newPayload, err := json.Marshal(newJob) + if err != nil { + t.Fatal(err) + } + + if err := handle.Save(oldPayload, jobsDirectory, oldJob.RequestID+".json"); err != nil { + t.Fatal(err) + } + if err := handle.Save(newPayload, jobsDirectory, newJob.RequestID+".json"); err != nil { + t.Fatal(err) + } + + store, err := NewStore(handle) + if err != nil { + t.Fatal(err) + } + + loaded, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_load_dupe") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected loaded route mapping") + } + if loaded.RequestID != newJob.RequestID { + t.Fatalf("expected newest request ID %s, got %s", newJob.RequestID, loaded.RequestID) + } +} + func TestServerHandlesSubmitAndPathPoll(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ From 4b660812a4e92beb1d5157fc8ddb3378883ac290 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:04:13 +0000 Subject: [PATCH 060/143] test(cmd): add fail-fast startup tests with injected init handles --- cmd/start.go | 27 ++++++++---- cmd/start_test.go | 105 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 cmd/start_test.go diff --git a/cmd/start.go b/cmd/start.go index 5120e2b7c0..92eed9470d 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -46,6 +46,17 @@ var StartCommand = &cobra.Command{ }, } +var ( + connectEthereum = ethereum.Connect + connectElectrum = electrum.Connect + initializeNetworkHandle = initializeNetwork + initializePersistenceFn = initializePersistence + initializeBeaconFn = beacon.Initialize + initializeTbtcFn = tbtc.Initialize + initializeSignerFn = covenantsigner.Initialize + startSchedulerFn = generator.StartScheduler +) + func init() { initFlags(StartCommand, &configFilePath, clientConfig, config.StartCmdCategories...) @@ -67,12 +78,12 @@ func start(cmd *cobra.Command) error { ctx := context.Background() beaconChain, tbtcChain, blockCounter, signing, operatorPrivateKey, err := - ethereum.Connect(ctx, clientConfig.Ethereum) + connectEthereum(ctx, clientConfig.Ethereum) if err != nil { return fmt.Errorf("error connecting to Ethereum node: [%v]", err) } - netProvider, err := initializeNetwork( + netProvider, err := initializeNetworkHandle( ctx, []firewall.Application{beaconChain, tbtcChain}, operatorPrivateKey, @@ -111,7 +122,7 @@ func start(cmd *cobra.Command) error { // Skip initialization for bootstrap nodes as they are only used for network // discovery. if !isBootstrap() { - btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Electrum) + btcChain, err := connectElectrum(ctx, clientConfig.Bitcoin.Electrum) if err != nil { return fmt.Errorf("could not connect to Electrum chain: [%v]", err) } @@ -119,12 +130,12 @@ func start(cmd *cobra.Command) error { beaconKeyStorePersistence, tbtcKeyStorePersistence, tbtcDataPersistence, - err := initializePersistence() + err := initializePersistenceFn() if err != nil { return fmt.Errorf("cannot initialize persistence: [%w]", err) } - scheduler := generator.StartScheduler() + scheduler := startSchedulerFn() if clientInfoRegistry != nil { clientInfoRegistry.ObserveBtcConnectivity( @@ -143,7 +154,7 @@ func start(cmd *cobra.Command) error { rpcHealthChecker.Start(ctx) } - err = beacon.Initialize( + err = initializeBeaconFn( ctx, beaconChain, netProvider, @@ -159,7 +170,7 @@ func start(cmd *cobra.Command) error { btcChain, ) - covenantSignerEngine, err := tbtc.Initialize( + covenantSignerEngine, err := initializeTbtcFn( ctx, tbtcChain, btcChain, @@ -176,7 +187,7 @@ func start(cmd *cobra.Command) error { return fmt.Errorf("error initializing TBTC: [%v]", err) } - _, _, err = covenantsigner.Initialize( + _, _, err = initializeSignerFn( ctx, clientConfig.CovenantSigner, tbtcDataPersistence, diff --git a/cmd/start_test.go b/cmd/start_test.go new file mode 100644 index 0000000000..b203bfccc4 --- /dev/null +++ b/cmd/start_test.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "context" + "errors" + "strings" + "testing" + + commonEthereum "github.com/keep-network/keep-common/pkg/chain/ethereum" + "github.com/keep-network/keep-core/config" + "github.com/keep-network/keep-core/pkg/chain" + chainEthereum "github.com/keep-network/keep-core/pkg/chain/ethereum" + "github.com/keep-network/keep-core/pkg/firewall" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/spf13/cobra" +) + +func TestStartFailsFastWhenEthereumConnectFails(t *testing.T) { + originalConfig := *clientConfig + originalConnectEthereum := connectEthereum + originalInitializeNetwork := initializeNetworkHandle + + t.Cleanup(func() { + *clientConfig = originalConfig + connectEthereum = originalConnectEthereum + initializeNetworkHandle = originalInitializeNetwork + }) + + *clientConfig = config.Config{} + networkInitCalled := false + + connectEthereum = func( + _ context.Context, + _ commonEthereum.Config, + ) ( + *chainEthereum.BeaconChain, + *chainEthereum.TbtcChain, + chain.BlockCounter, + chain.Signing, + *operator.PrivateKey, + error, + ) { + return nil, nil, nil, nil, nil, errors.New("injected ethereum failure") + } + + initializeNetworkHandle = func( + _ context.Context, + _ []firewall.Application, + _ *operator.PrivateKey, + _ chain.BlockCounter, + ) (net.Provider, error) { + networkInitCalled = true + return nil, nil + } + + err := start(&cobra.Command{}) + if err == nil || !strings.Contains(err.Error(), "error connecting to Ethereum node") { + t.Fatalf("expected ethereum connection failure, got: %v", err) + } + if networkInitCalled { + t.Fatal("expected network initialization not to run after ethereum connection failure") + } +} + +func TestStartFailsFastWhenNetworkInitializationFails(t *testing.T) { + originalConfig := *clientConfig + originalConnectEthereum := connectEthereum + originalInitializeNetwork := initializeNetworkHandle + + t.Cleanup(func() { + *clientConfig = originalConfig + connectEthereum = originalConnectEthereum + initializeNetworkHandle = originalInitializeNetwork + }) + + *clientConfig = config.Config{} + connectEthereum = func( + _ context.Context, + _ commonEthereum.Config, + ) ( + *chainEthereum.BeaconChain, + *chainEthereum.TbtcChain, + chain.BlockCounter, + chain.Signing, + *operator.PrivateKey, + error, + ) { + return nil, nil, nil, nil, nil, nil + } + + initializeNetworkHandle = func( + _ context.Context, + _ []firewall.Application, + _ *operator.PrivateKey, + _ chain.BlockCounter, + ) (net.Provider, error) { + return nil, errors.New("injected network initialization failure") + } + + err := start(&cobra.Command{}) + if err == nil || !strings.Contains(err.Error(), "cannot initialize network") { + t.Fatalf("expected network initialization failure, got: %v", err) + } +} From 767b6edad280f79572dcc690bd3e8c07b927ef8d Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:06:59 +0000 Subject: [PATCH 061/143] test(crypto): add negative parsing coverage for signer approvals and trust roots --- pkg/covenantsigner/covenantsigner_test.go | 36 ++++++ pkg/tbtc/signer_approval_certificate_test.go | 109 +++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index d7e4bc760d..acab737cf0 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -3,7 +3,10 @@ package covenantsigner import ( "bytes" "context" + "crypto/ecdsa" "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" "crypto/x509" "encoding/hex" "encoding/json" @@ -2426,6 +2429,39 @@ func TestServicePollAcceptsStoredMigrationPlanQuoteAfterQuoteExpiry(t *testing.T } } +func TestParseMigrationPlanQuoteTrustRootRejectsInvalidPEM(t *testing.T) { + _, err := parseMigrationPlanQuoteTrustRoot("trustRoot", MigrationPlanQuoteTrustRoot{ + PublicKeyPEM: "not a PEM value", + }) + if err == nil || !strings.Contains(err.Error(), "trustRoot.publicKeyPem must be a PEM-encoded public key") { + t.Fatalf("expected invalid PEM error, got: %v", err) + } +} + +func TestParseMigrationPlanQuoteTrustRootRejectsNonEd25519Key(t *testing.T) { + secpKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + publicKeyDER, err := x509.MarshalPKIXPublicKey(&secpKey.PublicKey) + if err != nil { + t.Fatal(err) + } + + _, err = parseMigrationPlanQuoteTrustRoot("trustRoot", MigrationPlanQuoteTrustRoot{ + PublicKeyPEM: string( + pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyDER, + }), + ), + }) + if err == nil || !strings.Contains(err.Error(), "trustRoot.publicKeyPem must be a PEM-encoded Ed25519 public key") { + t.Fatalf("expected non-ed25519 key error, got: %v", err) + } +} + func TestServiceAcceptsSelfV1WithMatchingDepositorTrustRoot(t *testing.T) { handle := newMemoryHandle() service, err := NewService( diff --git a/pkg/tbtc/signer_approval_certificate_test.go b/pkg/tbtc/signer_approval_certificate_test.go index 9caa0772ab..014edae82e 100644 --- a/pkg/tbtc/signer_approval_certificate_test.go +++ b/pkg/tbtc/signer_approval_certificate_test.go @@ -396,3 +396,112 @@ func TestCovenantSignerEngineVerifySignerApprovalRejectsApprovalDigestMismatch(t t.Fatalf("expected signer approval digest mismatch error, got %v", err) } } + +func TestVerifySignerApprovalCertificateRejectsEmptyExpectedSignerSetHash(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + + err := verifySignerApprovalCertificate(request.SignerApproval, "") + if err == nil || !strings.Contains(err.Error(), "expected signer set hash must not be empty") { + t.Fatalf("expected empty signer set hash error, got %v", err) + } +} + +func TestVerifySignerApprovalCertificateRejectsSignerSetMismatch(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + + err := verifySignerApprovalCertificate( + request.SignerApproval, + "0x"+strings.Repeat("ab", 32), + ) + if err == nil || !strings.Contains(err.Error(), "signer set hash does not match the expected signer set") { + t.Fatalf("expected signer set mismatch error, got %v", err) + } +} + +func TestVerifySignerApprovalCertificateRejectsMalformedDERSignature(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + + certificate := *request.SignerApproval + certificate.Signature = "0xdeadbeef" + + walletExecutor, ok, err := node.getSigningExecutor(walletPublicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + walletChainData, err := walletExecutor.chain.GetWallet(bitcoin.PublicKeyHash(walletPublicKey)) + if err != nil { + t.Fatal(err) + } + expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( + walletPublicKey, + walletChainData, + walletExecutor.groupParameters, + ) + if err != nil { + t.Fatal(err) + } + + err = verifySignerApprovalCertificate(&certificate, expectedSignerSetHash) + if err == nil || !strings.Contains(err.Error(), "cannot parse threshold signature") { + t.Fatalf("expected malformed DER signature error, got %v", err) + } +} + +func TestVerifySignerApprovalCertificateRejectsMalformedWalletPublicKey(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + + certificate := *request.SignerApproval + certificate.WalletPublicKey = "0x02" + strings.Repeat("11", 32) + + walletExecutor, ok, err := node.getSigningExecutor(walletPublicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + walletChainData, err := walletExecutor.chain.GetWallet(bitcoin.PublicKeyHash(walletPublicKey)) + if err != nil { + t.Fatal(err) + } + expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( + walletPublicKey, + walletChainData, + walletExecutor.groupParameters, + ) + if err != nil { + t.Fatal(err) + } + + err = verifySignerApprovalCertificate(&certificate, expectedSignerSetHash) + if err == nil || !strings.Contains(err.Error(), "wallet public key is not a valid uncompressed secp256k1 key") { + t.Fatalf("expected malformed wallet public key error, got %v", err) + } +} From ca3bdf1ece117f01b29b45e84fc159bc1efac3f9 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:07:14 +0000 Subject: [PATCH 062/143] ci(client): gate race checks on high-risk path changes --- .github/workflows/client.yml | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 6c37e25115..10298bde73 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -65,6 +65,25 @@ jobs: - './config/_electrum_urls/**' - './pkg/bitcoin/electrum/**' + client-risk-detect-changes: + runs-on: ubuntu-latest + outputs: + path-filter: ${{ steps.filter.outputs.path-filter }} + steps: + - uses: actions/checkout@v4 + if: github.event_name == 'pull_request' + + - uses: dorny/paths-filter@v2 + if: github.event_name == 'pull_request' + id: filter + with: + filters: | + path-filter: + - './pkg/covenantsigner/**' + - './pkg/tbtc/**' + - './pkg/chain/ethereum/**' + - './cmd/start.go' + client-build-test-publish: needs: client-detect-changes if: | @@ -302,10 +321,10 @@ jobs: checks: "-SA1019" client-race: - needs: client-detect-changes + needs: client-risk-detect-changes if: | github.event_name == 'push' - || needs.client-detect-changes.outputs.path-filter == 'true' + || needs.client-risk-detect-changes.outputs.path-filter == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From a93b06ef3160c5dc0b1849c15f1791b4e2c89f68 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:07:50 +0000 Subject: [PATCH 063/143] test(tbtc): cover poll no-op and unsupported route transitions --- pkg/tbtc/covenant_signer_test.go | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index d1c7c81595..cdd838078c 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -1342,3 +1342,40 @@ func applyTestMigrationTransactionPlanCommitment( request.MigrationTransactionPlan, ) } + +func TestCovenantSignerEngine_OnPollReturnsNoTransition(t *testing.T) { + transition, err := (&covenantSignerEngine{}).OnPoll( + context.Background(), + &covenantsigner.Job{}, + ) + if err != nil { + t.Fatalf("expected nil error from OnPoll, got %v", err) + } + if transition != nil { + t.Fatalf("expected no transition from OnPoll, got %#v", transition) + } +} + +func TestCovenantSignerEngine_SubmitRejectsUnsupportedRoute(t *testing.T) { + transition, err := (&covenantSignerEngine{}).OnSubmit( + context.Background(), + &covenantsigner.Job{ + Route: covenantsigner.TemplateID("unsupported_route"), + }, + ) + if err != nil { + t.Fatalf("expected nil error from OnSubmit unsupported route, got %v", err) + } + if transition == nil { + t.Fatal("expected failed transition for unsupported route") + } + if transition.State != covenantsigner.JobStateFailed { + t.Fatalf("expected failed state, got %s", transition.State) + } + if transition.Reason != covenantsigner.ReasonInvalidInput { + t.Fatalf("expected invalid-input reason, got %s", transition.Reason) + } + if !strings.Contains(transition.Detail, "unsupported covenant route") { + t.Fatalf("expected unsupported route detail, got %q", transition.Detail) + } +} From a8c08c70fec2535867eeff9921a834397a6855c5 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:09:27 +0000 Subject: [PATCH 064/143] test(chain/ethereum): add tbtc contract config and wallet-state error tests --- pkg/chain/ethereum/tbtc_test.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 1c9eef1be0..e6c77914e3 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -7,6 +7,7 @@ import ( "fmt" "math/big" "reflect" + "strings" "testing" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -14,6 +15,7 @@ import ( "github.com/keep-network/keep-core/pkg/chain" "github.com/ethereum/go-ethereum/common" + commonEthereum "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/chain/local_v1" @@ -533,3 +535,34 @@ func TestBuildMovedFundsKey(t *testing.T) { movedFundsKey.Text(16), ) } + +func TestNewTbtcChainRejectsMissingBridgeContractAddress(t *testing.T) { + _, err := newTbtcChain( + commonEthereum.Config{ + ContractAddresses: map[string]string{}, + }, + nil, + ) + if err == nil || !strings.Contains(err.Error(), "failed to resolve Bridge contract address") { + t.Fatalf("expected bridge contract address resolution error, got: %v", err) + } +} + +func TestNewTbtcChainRejectsMalformedBridgeContractAddress(t *testing.T) { + config := commonEthereum.Config{ + ContractAddresses: map[string]string{}, + } + config.SetContractAddress(BridgeContractName, "not-a-hex-address") + + _, err := newTbtcChain(config, nil) + if err == nil || !strings.Contains(err.Error(), "failed to resolve Bridge contract address") { + t.Fatalf("expected malformed bridge contract address error, got: %v", err) + } +} + +func TestParseWalletStateRejectsUnsupportedValue(t *testing.T) { + _, err := parseWalletState(255) + if err == nil || !strings.Contains(err.Error(), "unexpected wallet state value") { + t.Fatalf("expected unsupported wallet state error, got: %v", err) + } +} From 298ebe1de07877b57e18caa2302e5da91eb21bb4 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:13:50 +0000 Subject: [PATCH 065/143] test(fuzz): add decoder fuzz targets for signer validation paths --- pkg/covenantsigner/validation_fuzz_test.go | 18 ++++++++++++++++++ .../signer_approval_certificate_fuzz_test.go | 16 ++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 pkg/covenantsigner/validation_fuzz_test.go create mode 100644 pkg/tbtc/signer_approval_certificate_fuzz_test.go diff --git a/pkg/covenantsigner/validation_fuzz_test.go b/pkg/covenantsigner/validation_fuzz_test.go new file mode 100644 index 0000000000..10529fcf15 --- /dev/null +++ b/pkg/covenantsigner/validation_fuzz_test.go @@ -0,0 +1,18 @@ +package covenantsigner + +import "testing" + +func FuzzParseMigrationPlanQuoteTrustRoot_NoPanic(f *testing.F) { + f.Add("trustRoot", "not a pem") + f.Add("trustRoot", "-----BEGIN PUBLIC KEY-----\nZm9v\n-----END PUBLIC KEY-----") + + f.Fuzz(func(t *testing.T, name string, publicKeyPEM string) { + _, _ = parseMigrationPlanQuoteTrustRoot( + name, + MigrationPlanQuoteTrustRoot{ + KeyID: "fuzz", + PublicKeyPEM: publicKeyPEM, + }, + ) + }) +} diff --git a/pkg/tbtc/signer_approval_certificate_fuzz_test.go b/pkg/tbtc/signer_approval_certificate_fuzz_test.go new file mode 100644 index 0000000000..ae7d3f6498 --- /dev/null +++ b/pkg/tbtc/signer_approval_certificate_fuzz_test.go @@ -0,0 +1,16 @@ +package tbtc + +import "testing" + +func FuzzDecodeSignerApprovalCertificateHex_NoPanic(f *testing.F) { + f.Add("0x") + f.Add("0x00") + f.Add("0x" + "11") + f.Add("deadbeef") + f.Add("0xzz") + + f.Fuzz(func(t *testing.T, value string) { + _, _ = decodeSignerApprovalCertificateHex(value, 0) + _, _ = decodeSignerApprovalCertificateHex(value, 32) + }) +} From 55c87a51b8e85f8d1373606f190341a0aee70d90 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:14:58 +0000 Subject: [PATCH 066/143] test(covenantsigner): split store and server suites into thematic files --- pkg/covenantsigner/covenantsigner_test.go | 786 ---------------------- pkg/covenantsigner/server_test.go | 598 ++++++++++++++++ pkg/covenantsigner/store_test.go | 206 ++++++ 3 files changed, 804 insertions(+), 786 deletions(-) create mode 100644 pkg/covenantsigner/server_test.go create mode 100644 pkg/covenantsigner/store_test.go diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index acab737cf0..02db50b938 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -11,12 +11,7 @@ import ( "encoding/hex" "encoding/json" "encoding/pem" - "errors" "fmt" - "io" - "net" - "net/http" - "net/http/httptest" "os" "reflect" "strings" @@ -3175,784 +3170,3 @@ func TestMigrationTransactionPlanCommitmentHashMatchesCanonicalVectors(t *testin }) } } - -func TestStoreReloadPreservesJobs(t *testing.T) { - handle := newMemoryHandle() - store, err := NewStore(handle) - if err != nil { - t.Fatal(err) - } - - job := &Job{ - RequestID: "kcs_self_1234", - RouteRequestID: "ors_reload", - Route: TemplateSelfV1, - IdempotencyKey: "idem_reload", - FacadeRequestID: "rf_reload", - RequestDigest: "0xdeadbeef", - State: JobStatePending, - Detail: "queued", - CreatedAt: "2026-03-09T00:00:00Z", - UpdatedAt: "2026-03-09T00:00:00Z", - Request: baseRequest(TemplateSelfV1), - } - - if err := store.Put(job); err != nil { - t.Fatal(err) - } - - reloaded, err := NewStore(handle) - if err != nil { - t.Fatal(err) - } - - loadedJob, ok, err := reloaded.GetByRouteRequest(TemplateSelfV1, "ors_reload") - if err != nil { - t.Fatal(err) - } - if !ok { - t.Fatal("expected persisted job") - } - if !reflect.DeepEqual(job.Request, loadedJob.Request) { - t.Fatalf("unexpected reloaded request: %#v", loadedJob.Request) - } -} - -func TestStorePutReturnsErrorWhenSaveFails(t *testing.T) { - handle := newFaultingMemoryHandle() - handle.saveErrByName["kcs_self_fail_save.json"] = errors.New("injected save failure") - - store, err := NewStore(handle) - if err != nil { - t.Fatal(err) - } - - err = store.Put(&Job{ - RequestID: "kcs_self_fail_save", - RouteRequestID: "ors_fail_save", - Route: TemplateSelfV1, - IdempotencyKey: "idem_fail_save", - FacadeRequestID: "rf_fail_save", - RequestDigest: "0xdeadbeef", - State: JobStatePending, - Detail: "queued", - CreatedAt: "2026-03-09T00:00:00Z", - UpdatedAt: "2026-03-09T00:00:00Z", - Request: baseRequest(TemplateSelfV1), - }) - if err == nil || !strings.Contains(err.Error(), "injected save failure") { - t.Fatalf("expected injected save failure, got: %v", err) - } - - _, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_fail_save") - if err != nil { - t.Fatal(err) - } - if ok { - t.Fatal("expected no route mapping after failed save") - } -} - -func TestStorePutKeepsNewRouteMappingWhenOldDeleteFails(t *testing.T) { - handle := newFaultingMemoryHandle() - store, err := NewStore(handle) - if err != nil { - t.Fatal(err) - } - - initial := &Job{ - RequestID: "kcs_self_old", - RouteRequestID: "ors_replace", - Route: TemplateSelfV1, - IdempotencyKey: "idem_old", - FacadeRequestID: "rf_old", - RequestDigest: "0x1111", - State: JobStatePending, - Detail: "queued", - CreatedAt: "2026-03-09T00:00:00Z", - UpdatedAt: "2026-03-09T00:00:00Z", - Request: baseRequest(TemplateSelfV1), - } - - if err := store.Put(initial); err != nil { - t.Fatal(err) - } - - handle.deleteErrByName["kcs_self_old.json"] = errors.New("injected delete failure") - - replacement := &Job{ - RequestID: "kcs_self_new", - RouteRequestID: "ors_replace", - Route: TemplateSelfV1, - IdempotencyKey: "idem_new", - FacadeRequestID: "rf_new", - RequestDigest: "0x2222", - State: JobStatePending, - Detail: "queued", - CreatedAt: "2026-03-10T00:00:00Z", - UpdatedAt: "2026-03-10T00:00:00Z", - Request: baseRequest(TemplateSelfV1), - } - - if err := store.Put(replacement); err != nil { - t.Fatalf("expected replacement put to succeed, got: %v", err) - } - - loaded, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_replace") - if err != nil { - t.Fatal(err) - } - if !ok { - t.Fatal("expected route mapping to exist") - } - if loaded.RequestID != "kcs_self_new" { - t.Fatalf("expected route key to map to replacement job, got: %s", loaded.RequestID) - } -} - -func TestStoreLoadSelectsNewestJobForDuplicateRouteKeys(t *testing.T) { - handle := newMemoryHandle() - - oldJob := &Job{ - RequestID: "kcs_self_old_load", - RouteRequestID: "ors_load_dupe", - Route: TemplateSelfV1, - IdempotencyKey: "idem_old_load", - FacadeRequestID: "rf_old_load", - RequestDigest: "0xaaa", - State: JobStatePending, - Detail: "queued", - CreatedAt: "2026-03-09T00:00:00Z", - UpdatedAt: "2026-03-09T00:00:00Z", - Request: baseRequest(TemplateSelfV1), - } - newJob := &Job{ - RequestID: "kcs_self_new_load", - RouteRequestID: "ors_load_dupe", - Route: TemplateSelfV1, - IdempotencyKey: "idem_new_load", - FacadeRequestID: "rf_new_load", - RequestDigest: "0xbbb", - State: JobStatePending, - Detail: "queued", - CreatedAt: "2026-03-10T00:00:00Z", - UpdatedAt: "2026-03-10T00:00:00Z", - Request: baseRequest(TemplateSelfV1), - } - - oldPayload, err := json.Marshal(oldJob) - if err != nil { - t.Fatal(err) - } - newPayload, err := json.Marshal(newJob) - if err != nil { - t.Fatal(err) - } - - if err := handle.Save(oldPayload, jobsDirectory, oldJob.RequestID+".json"); err != nil { - t.Fatal(err) - } - if err := handle.Save(newPayload, jobsDirectory, newJob.RequestID+".json"); err != nil { - t.Fatal(err) - } - - store, err := NewStore(handle) - if err != nil { - t.Fatal(err) - } - - loaded, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_load_dupe") - if err != nil { - t.Fatal(err) - } - if !ok { - t.Fatal("expected loaded route mapping") - } - if loaded.RequestID != newJob.RequestID { - t.Fatalf("expected newest request ID %s, got %s", newJob.RequestID, loaded.RequestID) - } -} - -func TestServerHandlesSubmitAndPathPoll(t *testing.T) { - handle := newMemoryHandle() - service, err := NewService(handle, &scriptedEngine{ - submit: func(*Job) (*Transition, error) { - return &Transition{State: JobStatePending, Detail: "queued"}, nil - }, - }) - if err != nil { - t.Fatal(err) - } - - server := httptest.NewServer(newHandler(service, "", true)) - defer server.Close() - - submitPayload := mustJSON(t, SignerSubmitInput{ - RouteRequestID: "ors_http", - Stage: StageSignerCoordination, - Request: baseRequest(TemplateSelfV1), - }) - - response, err := http.Post(server.URL+"/v1/self_v1/signer/requests", "application/json", bytes.NewReader(submitPayload)) - if err != nil { - t.Fatal(err) - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - body, _ := io.ReadAll(response.Body) - t.Fatalf("unexpected submit status: %d %s", response.StatusCode, string(body)) - } - - submitResult := StepResult{} - if err := json.NewDecoder(response.Body).Decode(&submitResult); err != nil { - t.Fatal(err) - } - - pollPayload := mustJSON(t, SignerPollInput{ - RouteRequestID: "ors_http", - Stage: StageSignerCoordination, - Request: baseRequest(TemplateSelfV1), - }) - - pollResponse, err := http.Post(server.URL+"/v1/self_v1/signer/requests/"+submitResult.RequestID+":poll", "application/json", bytes.NewReader(pollPayload)) - if err != nil { - t.Fatal(err) - } - defer pollResponse.Body.Close() - - if pollResponse.StatusCode != http.StatusOK { - body, _ := io.ReadAll(pollResponse.Body) - t.Fatalf("unexpected poll status: %d %s", pollResponse.StatusCode, string(body)) - } -} - -func TestServerRejectsUnknownFieldsOnSubmit(t *testing.T) { - handle := newMemoryHandle() - service, err := NewService(handle, &scriptedEngine{ - submit: func(*Job) (*Transition, error) { - return &Transition{State: JobStatePending, Detail: "queued"}, nil - }, - }) - if err != nil { - t.Fatal(err) - } - - server := httptest.NewServer(newHandler(service, "", true)) - defer server.Close() - - base := baseRequest(TemplateSelfV1) - template := &SelfV1Template{} - if err := strictUnmarshal(base.ScriptTemplate, template); err != nil { - t.Fatal(err) - } - payload := bytes.NewBufferString(fmt.Sprintf(`{ - "routeRequestId":"ors_http_unknown", - "stage":"SIGNER_COORDINATION", - "request":{ - "facadeRequestId":"rf_123", - "idempotencyKey":"idem_123", - "route":"self_v1", - "requestType":"reconstruct", - "strategy":"0x1234", - "reserve":"0x1111111111111111111111111111111111111111", - "epoch":12, - "maturityHeight":912345, - "activeOutpoint":{"txid":"0x0102","vout":1,"scriptHash":"0x0304"}, - "destinationCommitmentHash":"%s", - "migrationDestination":{ - "reservationId":"cmdr_12345678", - "reserve":"0x1111111111111111111111111111111111111111", - "epoch":12, - "route":"MIGRATION", - "revealer":"0x2222222222222222222222222222222222222222", - "vault":"0x3333333333333333333333333333333333333333", - "network":"regtest", - "status":"RESERVED", - "depositScript":"0x0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "depositScriptHash":"0x8532ec6785e391b2af968b5728d574e271c7f46658f5ed10845d9ad5b23ac6d3", - "migrationExtraData":"0x41435f4d49475241544556312222222222222222222222222222222222222222", - "destinationCommitmentHash":"%s" - }, - "migrationTransactionPlan":{ - "planVersion":1, - "planCommitmentHash":"%s", - "inputValueSats":1000000, - "destinationValueSats":998000, - "anchorValueSats":330, - "feeSats":1670, - "inputSequence":4294967293, - "lockTime":912345 - }, - "artifactApprovals":{ - "payload":{ - "approvalVersion":1, - "route":"self_v1", - "scriptTemplateId":"self_v1", - "destinationCommitmentHash":"%s", - "planCommitmentHash":"%s" - }, - "approvals":[ - {"role":"D","signature":"%s"} - ] - }, - "artifactSignatures":["%s"], - "artifacts":{}, - "scriptTemplate":{"template":"self_v1","depositorPublicKey":"%s","signerPublicKey":"%s","delta2":4320}, - "signing":{"signerRequired":true,"custodianRequired":false}, - "futureField":"ignored" - }, - "futureTopLevel":"ignored" - }`, - base.DestinationCommitmentHash, - base.DestinationCommitmentHash, - base.MigrationTransactionPlan.PlanCommitmentHash, - base.ArtifactApprovals.Payload.DestinationCommitmentHash, - base.ArtifactApprovals.Payload.PlanCommitmentHash, - base.ArtifactApprovals.Approvals[0].Signature, - base.ArtifactSignatures[0], - template.DepositorPublicKey, - template.SignerPublicKey, - )) - - response, err := http.Post(server.URL+"/v1/self_v1/signer/requests", "application/json", payload) - if err != nil { - t.Fatal(err) - } - defer response.Body.Close() - - if response.StatusCode != http.StatusBadRequest { - body, _ := io.ReadAll(response.Body) - t.Fatalf("unexpected submit status: %d %s", response.StatusCode, string(body)) - } - - body, _ := io.ReadAll(response.Body) - if !strings.Contains(string(body), "malformed request body") { - t.Fatalf("unexpected response body: %s", string(body)) - } -} - -func TestInitializeRejectsInvalidOrUnavailablePort(t *testing.T) { - handle := newMemoryHandle() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - if _, enabled, err := Initialize(ctx, Config{Port: -1}, handle, nil); err == nil || enabled { - t.Fatalf("expected invalid negative port to fail, got enabled=%v err=%v", enabled, err) - } - if _, enabled, err := Initialize( - ctx, - Config{Port: 9711, ListenAddress: "0.0.0.0"}, - handle, - nil, - ); err == nil || enabled { - t.Fatalf("expected non-loopback bind without auth token to fail, got enabled=%v err=%v", enabled, err) - } - - listener, err := net.Listen("tcp", net.JoinHostPort(DefaultListenAddress, "0")) - if err != nil { - t.Fatal(err) - } - defer listener.Close() - - port := listener.Addr().(*net.TCPAddr).Port - if _, enabled, err := Initialize( - ctx, - Config{Port: port, ListenAddress: DefaultListenAddress}, - handle, - nil, - ); err == nil || enabled { - t.Fatalf("expected occupied port to fail, got enabled=%v err=%v", enabled, err) - } -} - -func availableLoopbackPort(t *testing.T) int { - t.Helper() - - listener, err := net.Listen("tcp", net.JoinHostPort(DefaultListenAddress, "0")) - if err != nil { - t.Fatal(err) - } - defer listener.Close() - - return listener.Addr().(*net.TCPAddr).Port -} - -func TestInitializeRequiresQcV1DepositorTrustRootsWhenConfigured(t *testing.T) { - handle := newMemoryHandle() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - _, enabled, err := Initialize( - ctx, - Config{ - Port: availableLoopbackPort(t), - RequireApprovalTrustRoots: true, - }, - handle, - &scriptedEngine{}, - ) - if err == nil || enabled { - t.Fatalf("expected missing qc_v1 depositor trust roots to fail, got enabled=%v err=%v", enabled, err) - } - if !strings.Contains( - err.Error(), - "covenant signer qc_v1 routes require depositorTrustRoots when covenantSigner.requireApprovalTrustRoots=true", - ) { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestInitializeRequiresQcV1CustodianTrustRootsWhenConfigured(t *testing.T) { - handle := newMemoryHandle() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - _, enabled, err := Initialize( - ctx, - Config{ - Port: availableLoopbackPort(t), - RequireApprovalTrustRoots: true, - DepositorTrustRoots: []DepositorTrustRoot{ - testDepositorTrustRoot(TemplateQcV1), - }, - }, - handle, - &scriptedEngine{}, - ) - if err == nil || enabled { - t.Fatalf("expected missing qc_v1 custodian trust roots to fail, got enabled=%v err=%v", enabled, err) - } - if !strings.Contains( - err.Error(), - "covenant signer qc_v1 routes require custodianTrustRoots when covenantSigner.requireApprovalTrustRoots=true", - ) { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestInitializeRequiresSelfV1DepositorTrustRootsWhenConfigured(t *testing.T) { - handle := newMemoryHandle() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - _, enabled, err := Initialize( - ctx, - Config{ - Port: availableLoopbackPort(t), - EnableSelfV1: true, - RequireApprovalTrustRoots: true, - DepositorTrustRoots: []DepositorTrustRoot{ - testDepositorTrustRoot(TemplateQcV1), - }, - CustodianTrustRoots: []CustodianTrustRoot{ - testCustodianTrustRoot(TemplateQcV1), - }, - }, - handle, - &scriptedEngine{}, - ) - if err == nil || enabled { - t.Fatalf("expected missing self_v1 depositor trust roots to fail, got enabled=%v err=%v", enabled, err) - } - if !strings.Contains( - err.Error(), - "covenant signer self_v1 routes require depositorTrustRoots when covenantSigner.requireApprovalTrustRoots=true", - ) { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestInitializeAcceptsRequiredApprovalTrustRootsWhenConfigured(t *testing.T) { - handle := newMemoryHandle() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - server, enabled, err := Initialize( - ctx, - Config{ - Port: availableLoopbackPort(t), - EnableSelfV1: true, - RequireApprovalTrustRoots: true, - DepositorTrustRoots: []DepositorTrustRoot{ - testDepositorTrustRoot(TemplateQcV1), - testDepositorTrustRoot(TemplateSelfV1), - }, - CustodianTrustRoots: []CustodianTrustRoot{ - testCustodianTrustRoot(TemplateQcV1), - }, - }, - handle, - &scriptedEngine{}, - ) - if err != nil || !enabled || server == nil { - t.Fatalf("expected startup to succeed with required trust roots, got enabled=%v server=%v err=%v", enabled, server != nil, err) - } -} - -func TestIsLoopbackListenAddressAcceptsBracketedIPv6Loopback(t *testing.T) { - if !isLoopbackListenAddress("[::1]") { - t.Fatal("expected bracketed IPv6 loopback address to be recognized") - } -} - -func TestServerRequiresBearerTokenWhenConfigured(t *testing.T) { - handle := newMemoryHandle() - service, err := NewService(handle, &scriptedEngine{ - submit: func(*Job) (*Transition, error) { - return &Transition{State: JobStatePending, Detail: "queued"}, nil - }, - }) - if err != nil { - t.Fatal(err) - } - - server := httptest.NewServer(newHandler(service, "test-token", true)) - defer server.Close() - - submitPayload := mustJSON(t, SignerSubmitInput{ - RouteRequestID: "ors_http_auth", - Stage: StageSignerCoordination, - Request: baseRequest(TemplateSelfV1), - }) - - response, err := http.Get(server.URL + "/healthz") - if err != nil { - t.Fatal(err) - } - response.Body.Close() - if response.StatusCode != http.StatusOK { - t.Fatalf("unexpected healthz status: %d", response.StatusCode) - } - - request, err := http.NewRequest( - http.MethodPost, - server.URL+"/v1/self_v1/signer/requests", - bytes.NewReader(submitPayload), - ) - if err != nil { - t.Fatal(err) - } - request.Header.Set("Content-Type", "application/json") - - response, err = http.DefaultClient.Do(request) - if err != nil { - t.Fatal(err) - } - defer response.Body.Close() - - if response.StatusCode != http.StatusUnauthorized { - body, _ := io.ReadAll(response.Body) - t.Fatalf("expected unauthorized submit without bearer token, got %d %s", response.StatusCode, string(body)) - } - - authorizedRequest, err := http.NewRequest( - http.MethodPost, - server.URL+"/v1/self_v1/signer/requests", - bytes.NewReader(submitPayload), - ) - if err != nil { - t.Fatal(err) - } - authorizedRequest.Header.Set("Content-Type", "application/json") - authorizedRequest.Header.Set("Authorization", "Bearer test-token") - - authorizedResponse, err := http.DefaultClient.Do(authorizedRequest) - if err != nil { - t.Fatal(err) - } - defer authorizedResponse.Body.Close() - - if authorizedResponse.StatusCode != http.StatusOK { - body, _ := io.ReadAll(authorizedResponse.Body) - t.Fatalf("unexpected authorized submit status: %d %s", authorizedResponse.StatusCode, string(body)) - } -} - -func TestServerCanKeepSelfV1RoutesDark(t *testing.T) { - handle := newMemoryHandle() - service, err := NewService(handle, &scriptedEngine{ - submit: func(*Job) (*Transition, error) { - return &Transition{State: JobStatePending, Detail: "queued"}, nil - }, - }) - if err != nil { - t.Fatal(err) - } - - server := httptest.NewServer(newHandler(service, "", false)) - defer server.Close() - - response, err := http.Post( - server.URL+"/v1/self_v1/signer/requests", - "application/json", - bytes.NewReader(mustJSON(t, SignerSubmitInput{ - RouteRequestID: "ors_http_self_dark", - Stage: StageSignerCoordination, - Request: baseRequest(TemplateSelfV1), - })), - ) - if err != nil { - t.Fatal(err) - } - defer response.Body.Close() - - if response.StatusCode != http.StatusNotFound { - body, _ := io.ReadAll(response.Body) - t.Fatalf("expected disabled self_v1 route to return 404, got %d %s", response.StatusCode, string(body)) - } - - qcResponse, err := http.Post( - server.URL+"/v1/qc_v1/signer/requests", - "application/json", - bytes.NewReader(mustJSON(t, SignerSubmitInput{ - RouteRequestID: "orq_http_qc", - Stage: StageSignerCoordination, - Request: baseRequest(TemplateQcV1), - })), - ) - if err != nil { - t.Fatal(err) - } - defer qcResponse.Body.Close() - - if qcResponse.StatusCode != http.StatusOK { - body, _ := io.ReadAll(qcResponse.Body) - t.Fatalf("expected qc_v1 route to remain available, got %d %s", qcResponse.StatusCode, string(body)) - } -} - -func TestServerBoundaryErrorMatrix(t *testing.T) { - handle := newMemoryHandle() - service, err := NewService(handle, &scriptedEngine{ - submit: func(*Job) (*Transition, error) { - return &Transition{State: JobStatePending, Detail: "queued"}, nil - }, - poll: func(*Job) (*Transition, error) { - return &Transition{State: JobStatePending, Detail: "queued"}, nil - }, - }) - if err != nil { - t.Fatal(err) - } - - server := httptest.NewServer(newHandler(service, "test-token", true)) - defer server.Close() - - submitPayload := mustJSON(t, SignerSubmitInput{ - RouteRequestID: "ors_http_matrix", - Stage: StageSignerCoordination, - Request: baseRequest(TemplateSelfV1), - }) - - mismatchedPollPayload := mustJSON(t, SignerPollInput{ - RequestID: "different_id", - RouteRequestID: "ors_http_matrix", - Stage: StageSignerCoordination, - Request: baseRequest(TemplateSelfV1), - }) - - oversizedBody := []byte( - `{"routeRequestId":"ors_big","stage":"SIGNER_COORDINATION","request":{"facadeRequestId":"` + - strings.Repeat("a", maxRequestBodyBytes+1) + `"}}`, - ) - - testCases := []struct { - name string - method string - path string - body []byte - authHeader string - wantStatus int - wantBodyContains string - wantAllow string - }{ - { - name: "invalid bearer token", - method: http.MethodPost, - path: "/v1/self_v1/signer/requests", - body: submitPayload, - authHeader: "Bearer wrong-token", - wantStatus: http.StatusUnauthorized, - wantBodyContains: "invalid bearer token", - }, - { - name: "method mismatch on poll path returns 405", - method: http.MethodGet, - path: "/v1/self_v1/signer/requests/request_1:poll", - authHeader: "Bearer test-token", - wantStatus: http.StatusMethodNotAllowed, - wantBodyContains: "method not allowed", - wantAllow: http.MethodPost, - }, - { - name: "unknown fields in envelope rejected", - method: http.MethodPost, - path: "/v1/self_v1/signer/requests", - body: []byte(`{"routeRequestId":"ors_http_unknown","stage":"SIGNER_COORDINATION","request":{},"futureTopLevel":"ignored"}`), - authHeader: "Bearer test-token", - wantStatus: http.StatusBadRequest, - wantBodyContains: "malformed request body", - }, - { - name: "oversized body rejected", - method: http.MethodPost, - path: "/v1/self_v1/signer/requests", - body: oversizedBody, - authHeader: "Bearer test-token", - wantStatus: http.StatusBadRequest, - wantBodyContains: "malformed request body", - }, - { - name: "poll path and body request id mismatch rejected", - method: http.MethodPost, - path: "/v1/self_v1/signer/requests/request_from_path:poll", - body: mismatchedPollPayload, - authHeader: "Bearer test-token", - wantStatus: http.StatusBadRequest, - wantBodyContains: "requestId in body does not match path", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - request, err := http.NewRequest( - tc.method, - server.URL+tc.path, - bytes.NewReader(tc.body), - ) - if err != nil { - t.Fatal(err) - } - - if tc.body != nil { - request.Header.Set("Content-Type", "application/json") - } - if tc.authHeader != "" { - request.Header.Set("Authorization", tc.authHeader) - } - - response, err := http.DefaultClient.Do(request) - if err != nil { - t.Fatal(err) - } - defer response.Body.Close() - - if response.StatusCode != tc.wantStatus { - body, _ := io.ReadAll(response.Body) - t.Fatalf("unexpected status: %d body: %s", response.StatusCode, string(body)) - } - - if tc.wantAllow != "" && response.Header.Get("Allow") != tc.wantAllow { - t.Fatalf("unexpected Allow header: %q", response.Header.Get("Allow")) - } - - if tc.wantBodyContains != "" { - body, _ := io.ReadAll(response.Body) - if !strings.Contains(string(body), tc.wantBodyContains) { - t.Fatalf("expected body to contain %q, got %q", tc.wantBodyContains, string(body)) - } - } - }) - } -} diff --git a/pkg/covenantsigner/server_test.go b/pkg/covenantsigner/server_test.go new file mode 100644 index 0000000000..0c38357776 --- /dev/null +++ b/pkg/covenantsigner/server_test.go @@ -0,0 +1,598 @@ +package covenantsigner + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestServerHandlesSubmitAndPathPoll(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "", true)) + defer server.Close() + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + response, err := http.Post(server.URL+"/v1/self_v1/signer/requests", "application/json", bytes.NewReader(submitPayload)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected submit status: %d %s", response.StatusCode, string(body)) + } + + submitResult := StepResult{} + if err := json.NewDecoder(response.Body).Decode(&submitResult); err != nil { + t.Fatal(err) + } + + pollPayload := mustJSON(t, SignerPollInput{ + RouteRequestID: "ors_http", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + pollResponse, err := http.Post(server.URL+"/v1/self_v1/signer/requests/"+submitResult.RequestID+":poll", "application/json", bytes.NewReader(pollPayload)) + if err != nil { + t.Fatal(err) + } + defer pollResponse.Body.Close() + + if pollResponse.StatusCode != http.StatusOK { + body, _ := io.ReadAll(pollResponse.Body) + t.Fatalf("unexpected poll status: %d %s", pollResponse.StatusCode, string(body)) + } +} + +func TestServerRejectsUnknownFieldsOnSubmit(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "", true)) + defer server.Close() + + base := baseRequest(TemplateSelfV1) + template := &SelfV1Template{} + if err := strictUnmarshal(base.ScriptTemplate, template); err != nil { + t.Fatal(err) + } + payload := bytes.NewBufferString(fmt.Sprintf(`{ + "routeRequestId":"ors_http_unknown", + "stage":"SIGNER_COORDINATION", + "request":{ + "facadeRequestId":"rf_123", + "idempotencyKey":"idem_123", + "route":"self_v1", + "requestType":"reconstruct", + "strategy":"0x1234", + "reserve":"0x1111111111111111111111111111111111111111", + "epoch":12, + "maturityHeight":912345, + "activeOutpoint":{"txid":"0x0102","vout":1,"scriptHash":"0x0304"}, + "destinationCommitmentHash":"%s", + "migrationDestination":{ + "reservationId":"cmdr_12345678", + "reserve":"0x1111111111111111111111111111111111111111", + "epoch":12, + "route":"MIGRATION", + "revealer":"0x2222222222222222222222222222222222222222", + "vault":"0x3333333333333333333333333333333333333333", + "network":"regtest", + "status":"RESERVED", + "depositScript":"0x0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "depositScriptHash":"0x8532ec6785e391b2af968b5728d574e271c7f46658f5ed10845d9ad5b23ac6d3", + "migrationExtraData":"0x41435f4d49475241544556312222222222222222222222222222222222222222", + "destinationCommitmentHash":"%s" + }, + "migrationTransactionPlan":{ + "planVersion":1, + "planCommitmentHash":"%s", + "inputValueSats":1000000, + "destinationValueSats":998000, + "anchorValueSats":330, + "feeSats":1670, + "inputSequence":4294967293, + "lockTime":912345 + }, + "artifactApprovals":{ + "payload":{ + "approvalVersion":1, + "route":"self_v1", + "scriptTemplateId":"self_v1", + "destinationCommitmentHash":"%s", + "planCommitmentHash":"%s" + }, + "approvals":[ + {"role":"D","signature":"%s"} + ] + }, + "artifactSignatures":["%s"], + "artifacts":{}, + "scriptTemplate":{"template":"self_v1","depositorPublicKey":"%s","signerPublicKey":"%s","delta2":4320}, + "signing":{"signerRequired":true,"custodianRequired":false}, + "futureField":"ignored" + }, + "futureTopLevel":"ignored" + }`, + base.DestinationCommitmentHash, + base.DestinationCommitmentHash, + base.MigrationTransactionPlan.PlanCommitmentHash, + base.ArtifactApprovals.Payload.DestinationCommitmentHash, + base.ArtifactApprovals.Payload.PlanCommitmentHash, + base.ArtifactApprovals.Approvals[0].Signature, + base.ArtifactSignatures[0], + template.DepositorPublicKey, + template.SignerPublicKey, + )) + + response, err := http.Post(server.URL+"/v1/self_v1/signer/requests", "application/json", payload) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected submit status: %d %s", response.StatusCode, string(body)) + } + + body, _ := io.ReadAll(response.Body) + if !strings.Contains(string(body), "malformed request body") { + t.Fatalf("unexpected response body: %s", string(body)) + } +} + +func TestInitializeRejectsInvalidOrUnavailablePort(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if _, enabled, err := Initialize(ctx, Config{Port: -1}, handle, nil); err == nil || enabled { + t.Fatalf("expected invalid negative port to fail, got enabled=%v err=%v", enabled, err) + } + if _, enabled, err := Initialize( + ctx, + Config{Port: 9711, ListenAddress: "0.0.0.0"}, + handle, + nil, + ); err == nil || enabled { + t.Fatalf("expected non-loopback bind without auth token to fail, got enabled=%v err=%v", enabled, err) + } + + listener, err := net.Listen("tcp", net.JoinHostPort(DefaultListenAddress, "0")) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + if _, enabled, err := Initialize( + ctx, + Config{Port: port, ListenAddress: DefaultListenAddress}, + handle, + nil, + ); err == nil || enabled { + t.Fatalf("expected occupied port to fail, got enabled=%v err=%v", enabled, err) + } +} + +func availableLoopbackPort(t *testing.T) int { + t.Helper() + + listener, err := net.Listen("tcp", net.JoinHostPort(DefaultListenAddress, "0")) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + return listener.Addr().(*net.TCPAddr).Port +} + +func TestInitializeRequiresQcV1DepositorTrustRootsWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + RequireApprovalTrustRoots: true, + }, + handle, + &scriptedEngine{}, + ) + if err == nil || enabled { + t.Fatalf("expected missing qc_v1 depositor trust roots to fail, got enabled=%v err=%v", enabled, err) + } + if !strings.Contains( + err.Error(), + "covenant signer qc_v1 routes require depositorTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInitializeRequiresQcV1CustodianTrustRootsWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + RequireApprovalTrustRoots: true, + DepositorTrustRoots: []DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + }, + }, + handle, + &scriptedEngine{}, + ) + if err == nil || enabled { + t.Fatalf("expected missing qc_v1 custodian trust roots to fail, got enabled=%v err=%v", enabled, err) + } + if !strings.Contains( + err.Error(), + "covenant signer qc_v1 routes require custodianTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInitializeRequiresSelfV1DepositorTrustRootsWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + EnableSelfV1: true, + RequireApprovalTrustRoots: true, + DepositorTrustRoots: []DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + }, + CustodianTrustRoots: []CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }, + }, + handle, + &scriptedEngine{}, + ) + if err == nil || enabled { + t.Fatalf("expected missing self_v1 depositor trust roots to fail, got enabled=%v err=%v", enabled, err) + } + if !strings.Contains( + err.Error(), + "covenant signer self_v1 routes require depositorTrustRoots when covenantSigner.requireApprovalTrustRoots=true", + ) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInitializeAcceptsRequiredApprovalTrustRootsWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + server, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + EnableSelfV1: true, + RequireApprovalTrustRoots: true, + DepositorTrustRoots: []DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + testDepositorTrustRoot(TemplateSelfV1), + }, + CustodianTrustRoots: []CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }, + }, + handle, + &scriptedEngine{}, + ) + if err != nil || !enabled || server == nil { + t.Fatalf("expected startup to succeed with required trust roots, got enabled=%v server=%v err=%v", enabled, server != nil, err) + } +} + +func TestIsLoopbackListenAddressAcceptsBracketedIPv6Loopback(t *testing.T) { + if !isLoopbackListenAddress("[::1]") { + t.Fatal("expected bracketed IPv6 loopback address to be recognized") + } +} + +func TestServerRequiresBearerTokenWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "test-token", true)) + defer server.Close() + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http_auth", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + response, err := http.Get(server.URL + "/healthz") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("unexpected healthz status: %d", response.StatusCode) + } + + request, err := http.NewRequest( + http.MethodPost, + server.URL+"/v1/self_v1/signer/requests", + bytes.NewReader(submitPayload), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + + response, err = http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusUnauthorized { + body, _ := io.ReadAll(response.Body) + t.Fatalf("expected unauthorized submit without bearer token, got %d %s", response.StatusCode, string(body)) + } + + authorizedRequest, err := http.NewRequest( + http.MethodPost, + server.URL+"/v1/self_v1/signer/requests", + bytes.NewReader(submitPayload), + ) + if err != nil { + t.Fatal(err) + } + authorizedRequest.Header.Set("Content-Type", "application/json") + authorizedRequest.Header.Set("Authorization", "Bearer test-token") + + authorizedResponse, err := http.DefaultClient.Do(authorizedRequest) + if err != nil { + t.Fatal(err) + } + defer authorizedResponse.Body.Close() + + if authorizedResponse.StatusCode != http.StatusOK { + body, _ := io.ReadAll(authorizedResponse.Body) + t.Fatalf("unexpected authorized submit status: %d %s", authorizedResponse.StatusCode, string(body)) + } +} + +func TestServerCanKeepSelfV1RoutesDark(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "", false)) + defer server.Close() + + response, err := http.Post( + server.URL+"/v1/self_v1/signer/requests", + "application/json", + bytes.NewReader(mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http_self_dark", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + })), + ) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(response.Body) + t.Fatalf("expected disabled self_v1 route to return 404, got %d %s", response.StatusCode, string(body)) + } + + qcResponse, err := http.Post( + server.URL+"/v1/qc_v1/signer/requests", + "application/json", + bytes.NewReader(mustJSON(t, SignerSubmitInput{ + RouteRequestID: "orq_http_qc", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateQcV1), + })), + ) + if err != nil { + t.Fatal(err) + } + defer qcResponse.Body.Close() + + if qcResponse.StatusCode != http.StatusOK { + body, _ := io.ReadAll(qcResponse.Body) + t.Fatalf("expected qc_v1 route to remain available, got %d %s", qcResponse.StatusCode, string(body)) + } +} + +func TestServerBoundaryErrorMatrix(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "test-token", true)) + defer server.Close() + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http_matrix", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + mismatchedPollPayload := mustJSON(t, SignerPollInput{ + RequestID: "different_id", + RouteRequestID: "ors_http_matrix", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + oversizedBody := []byte( + `{"routeRequestId":"ors_big","stage":"SIGNER_COORDINATION","request":{"facadeRequestId":"` + + strings.Repeat("a", maxRequestBodyBytes+1) + `"}}`, + ) + + testCases := []struct { + name string + method string + path string + body []byte + authHeader string + wantStatus int + wantBodyContains string + wantAllow string + }{ + { + name: "invalid bearer token", + method: http.MethodPost, + path: "/v1/self_v1/signer/requests", + body: submitPayload, + authHeader: "Bearer wrong-token", + wantStatus: http.StatusUnauthorized, + wantBodyContains: "invalid bearer token", + }, + { + name: "method mismatch on poll path returns 405", + method: http.MethodGet, + path: "/v1/self_v1/signer/requests/request_1:poll", + authHeader: "Bearer test-token", + wantStatus: http.StatusMethodNotAllowed, + wantBodyContains: "method not allowed", + wantAllow: http.MethodPost, + }, + { + name: "unknown fields in envelope rejected", + method: http.MethodPost, + path: "/v1/self_v1/signer/requests", + body: []byte(`{"routeRequestId":"ors_http_unknown","stage":"SIGNER_COORDINATION","request":{},"futureTopLevel":"ignored"}`), + authHeader: "Bearer test-token", + wantStatus: http.StatusBadRequest, + wantBodyContains: "malformed request body", + }, + { + name: "oversized body rejected", + method: http.MethodPost, + path: "/v1/self_v1/signer/requests", + body: oversizedBody, + authHeader: "Bearer test-token", + wantStatus: http.StatusBadRequest, + wantBodyContains: "malformed request body", + }, + { + name: "poll path and body request id mismatch rejected", + method: http.MethodPost, + path: "/v1/self_v1/signer/requests/request_from_path:poll", + body: mismatchedPollPayload, + authHeader: "Bearer test-token", + wantStatus: http.StatusBadRequest, + wantBodyContains: "requestId in body does not match path", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + request, err := http.NewRequest( + tc.method, + server.URL+tc.path, + bytes.NewReader(tc.body), + ) + if err != nil { + t.Fatal(err) + } + + if tc.body != nil { + request.Header.Set("Content-Type", "application/json") + } + if tc.authHeader != "" { + request.Header.Set("Authorization", tc.authHeader) + } + + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != tc.wantStatus { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected status: %d body: %s", response.StatusCode, string(body)) + } + + if tc.wantAllow != "" && response.Header.Get("Allow") != tc.wantAllow { + t.Fatalf("unexpected Allow header: %q", response.Header.Get("Allow")) + } + + if tc.wantBodyContains != "" { + body, _ := io.ReadAll(response.Body) + if !strings.Contains(string(body), tc.wantBodyContains) { + t.Fatalf("expected body to contain %q, got %q", tc.wantBodyContains, string(body)) + } + } + }) + } +} diff --git a/pkg/covenantsigner/store_test.go b/pkg/covenantsigner/store_test.go new file mode 100644 index 0000000000..edcdcc1e68 --- /dev/null +++ b/pkg/covenantsigner/store_test.go @@ -0,0 +1,206 @@ +package covenantsigner + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" +) + +func TestStoreReloadPreservesJobs(t *testing.T) { + handle := newMemoryHandle() + store, err := NewStore(handle) + if err != nil { + t.Fatal(err) + } + + job := &Job{ + RequestID: "kcs_self_1234", + RouteRequestID: "ors_reload", + Route: TemplateSelfV1, + IdempotencyKey: "idem_reload", + FacadeRequestID: "rf_reload", + RequestDigest: "0xdeadbeef", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + if err := store.Put(job); err != nil { + t.Fatal(err) + } + + reloaded, err := NewStore(handle) + if err != nil { + t.Fatal(err) + } + + loadedJob, ok, err := reloaded.GetByRouteRequest(TemplateSelfV1, "ors_reload") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected persisted job") + } + if !reflect.DeepEqual(job.Request, loadedJob.Request) { + t.Fatalf("unexpected reloaded request: %#v", loadedJob.Request) + } +} + +func TestStorePutReturnsErrorWhenSaveFails(t *testing.T) { + handle := newFaultingMemoryHandle() + handle.saveErrByName["kcs_self_fail_save.json"] = errors.New("injected save failure") + + store, err := NewStore(handle) + if err != nil { + t.Fatal(err) + } + + err = store.Put(&Job{ + RequestID: "kcs_self_fail_save", + RouteRequestID: "ors_fail_save", + Route: TemplateSelfV1, + IdempotencyKey: "idem_fail_save", + FacadeRequestID: "rf_fail_save", + RequestDigest: "0xdeadbeef", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + }) + if err == nil || !strings.Contains(err.Error(), "injected save failure") { + t.Fatalf("expected injected save failure, got: %v", err) + } + + _, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_fail_save") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected no route mapping after failed save") + } +} + +func TestStorePutKeepsNewRouteMappingWhenOldDeleteFails(t *testing.T) { + handle := newFaultingMemoryHandle() + store, err := NewStore(handle) + if err != nil { + t.Fatal(err) + } + + initial := &Job{ + RequestID: "kcs_self_old", + RouteRequestID: "ors_replace", + Route: TemplateSelfV1, + IdempotencyKey: "idem_old", + FacadeRequestID: "rf_old", + RequestDigest: "0x1111", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + if err := store.Put(initial); err != nil { + t.Fatal(err) + } + + handle.deleteErrByName["kcs_self_old.json"] = errors.New("injected delete failure") + + replacement := &Job{ + RequestID: "kcs_self_new", + RouteRequestID: "ors_replace", + Route: TemplateSelfV1, + IdempotencyKey: "idem_new", + FacadeRequestID: "rf_new", + RequestDigest: "0x2222", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-10T00:00:00Z", + UpdatedAt: "2026-03-10T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + if err := store.Put(replacement); err != nil { + t.Fatalf("expected replacement put to succeed, got: %v", err) + } + + loaded, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_replace") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected route mapping to exist") + } + if loaded.RequestID != "kcs_self_new" { + t.Fatalf("expected route key to map to replacement job, got: %s", loaded.RequestID) + } +} + +func TestStoreLoadSelectsNewestJobForDuplicateRouteKeys(t *testing.T) { + handle := newMemoryHandle() + + oldJob := &Job{ + RequestID: "kcs_self_old_load", + RouteRequestID: "ors_load_dupe", + Route: TemplateSelfV1, + IdempotencyKey: "idem_old_load", + FacadeRequestID: "rf_old_load", + RequestDigest: "0xaaa", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + newJob := &Job{ + RequestID: "kcs_self_new_load", + RouteRequestID: "ors_load_dupe", + Route: TemplateSelfV1, + IdempotencyKey: "idem_new_load", + FacadeRequestID: "rf_new_load", + RequestDigest: "0xbbb", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-10T00:00:00Z", + UpdatedAt: "2026-03-10T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + oldPayload, err := json.Marshal(oldJob) + if err != nil { + t.Fatal(err) + } + newPayload, err := json.Marshal(newJob) + if err != nil { + t.Fatal(err) + } + + if err := handle.Save(oldPayload, jobsDirectory, oldJob.RequestID+".json"); err != nil { + t.Fatal(err) + } + if err := handle.Save(newPayload, jobsDirectory, newJob.RequestID+".json"); err != nil { + t.Fatal(err) + } + + store, err := NewStore(handle) + if err != nil { + t.Fatal(err) + } + + loaded, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_load_dupe") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected loaded route mapping") + } + if loaded.RequestID != newJob.RequestID { + t.Fatalf("expected newest request ID %s, got %s", newJob.RequestID, loaded.RequestID) + } +} From fd9e5986ca692a48d69a223aa2c3c40bac057f45 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 08:14:10 +0000 Subject: [PATCH 067/143] ci(client): scope race checks to stable tbtc tests --- .github/workflows/client.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 10298bde73..6aea9444a0 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -333,7 +333,10 @@ jobs: go-version-file: "go.mod" - name: Race detector (high-risk packages) run: | - go test -race -timeout 20m ./pkg/covenantsigner ./pkg/tbtc + go test -race -timeout 20m ./pkg/covenantsigner + go test -race -timeout 20m ./pkg/tbtc \ + -run '^(TestSignerApprovalCertificateSignerSetHashBindsOnChainWalletIdentityAndThreshold|TestValidateMigrationOutputValues_RejectsValuesExceedingInt64)$' \ + -count=1 client-integration-test: needs: [electrum-integration-detect-changes, client-build-test-publish] From 719cbe473023afaa3fb591b6349e7bc5cab6ff4b Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:29:58 +0000 Subject: [PATCH 068/143] fix(covenantsigner): parse UpdatedAt for store dedup ordering --- pkg/covenantsigner/store.go | 35 ++++++++++++++++++-- pkg/covenantsigner/store_test.go | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index 0776ca0d02..8662554a44 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "sync" + "time" "github.com/keep-network/keep-common/pkg/persistence" ) @@ -49,6 +50,30 @@ func cloneJob(job *Job) (*Job, error) { return cloned, nil } +func isNewerOrSameJobRevision(existing *Job, candidate *Job) (bool, error) { + existingUpdatedAt, err := time.Parse(time.RFC3339Nano, existing.UpdatedAt) + if err != nil { + return false, fmt.Errorf( + "cannot parse existing job updatedAt [%s] for request [%s]: %w", + existing.UpdatedAt, + existing.RequestID, + err, + ) + } + + candidateUpdatedAt, err := time.Parse(time.RFC3339Nano, candidate.UpdatedAt) + if err != nil { + return false, fmt.Errorf( + "cannot parse candidate job updatedAt [%s] for request [%s]: %w", + candidate.UpdatedAt, + candidate.RequestID, + err, + ) + } + + return !existingUpdatedAt.Before(candidateUpdatedAt), nil +} + func (s *Store) load() error { s.mutex.Lock() defer s.mutex.Unlock() @@ -80,8 +105,14 @@ func (s *Store) load() error { existingID, ok := s.byRouteKey[routeKey(job.Route, job.RouteRequestID)] if ok { existing := s.byRequestID[existingID] - if existing != nil && existing.UpdatedAt >= job.UpdatedAt { - continue + if existing != nil { + existingIsNewerOrSame, err := isNewerOrSameJobRevision(existing, job) + if err != nil { + return err + } + if existingIsNewerOrSame { + continue + } } } diff --git a/pkg/covenantsigner/store_test.go b/pkg/covenantsigner/store_test.go index edcdcc1e68..9ef8ffd6fb 100644 --- a/pkg/covenantsigner/store_test.go +++ b/pkg/covenantsigner/store_test.go @@ -204,3 +204,58 @@ func TestStoreLoadSelectsNewestJobForDuplicateRouteKeys(t *testing.T) { t.Fatalf("expected newest request ID %s, got %s", newJob.RequestID, loaded.RequestID) } } + +func TestStoreLoadFailsOnInvalidUpdatedAtForDuplicateRouteKeys(t *testing.T) { + handle := newMemoryHandle() + + first := &Job{ + RequestID: "kcs_self_first_load", + RouteRequestID: "ors_load_invalid_updated_at", + Route: TemplateSelfV1, + IdempotencyKey: "idem_first_load", + FacadeRequestID: "rf_first_load", + RequestDigest: "0xaaa", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + second := &Job{ + RequestID: "kcs_self_second_load", + RouteRequestID: "ors_load_invalid_updated_at", + Route: TemplateSelfV1, + IdempotencyKey: "idem_second_load", + FacadeRequestID: "rf_second_load", + RequestDigest: "0xbbb", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-10T00:00:00Z", + UpdatedAt: "invalid-timestamp", + Request: baseRequest(TemplateSelfV1), + } + + firstPayload, err := json.Marshal(first) + if err != nil { + t.Fatal(err) + } + secondPayload, err := json.Marshal(second) + if err != nil { + t.Fatal(err) + } + + if err := handle.Save(firstPayload, jobsDirectory, first.RequestID+".json"); err != nil { + t.Fatal(err) + } + if err := handle.Save(secondPayload, jobsDirectory, second.RequestID+".json"); err != nil { + t.Fatal(err) + } + + _, err = NewStore(handle) + if err == nil { + t.Fatal("expected invalid UpdatedAt error") + } + if !strings.Contains(err.Error(), "cannot parse candidate job updatedAt") { + t.Fatalf("unexpected error: %v", err) + } +} From e328ea90b64dcdaadda00d91ca3123bbd2fef5be Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:31:58 +0000 Subject: [PATCH 069/143] fix(covenantsigner): reject trailing JSON tokens in decodeJSON --- pkg/covenantsigner/server.go | 5 ++++ pkg/covenantsigner/server_test.go | 42 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 2052c75dc2..65ec73512d 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/url" @@ -290,6 +291,10 @@ func decodeJSON[T any](w http.ResponseWriter, r *http.Request, target *T) bool { http.Error(w, "malformed request body", http.StatusBadRequest) return false } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + http.Error(w, "malformed request body", http.StatusBadRequest) + return false + } return true } diff --git a/pkg/covenantsigner/server_test.go b/pkg/covenantsigner/server_test.go index 0c38357776..a69a777640 100644 --- a/pkg/covenantsigner/server_test.go +++ b/pkg/covenantsigner/server_test.go @@ -172,6 +172,48 @@ func TestServerRejectsUnknownFieldsOnSubmit(t *testing.T) { } } +func TestServerRejectsTrailingJSONOnSubmit(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(newHandler(service, "", true)) + defer server.Close() + + validPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_http_trailing", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + payload := append(validPayload, []byte(`{"unexpected":"trailing"}`)...) + + response, err := http.Post( + server.URL+"/v1/self_v1/signer/requests", + "application/json", + bytes.NewReader(payload), + ) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected submit status: %d %s", response.StatusCode, string(body)) + } + + body, _ := io.ReadAll(response.Body) + if !strings.Contains(string(body), "malformed request body") { + t.Fatalf("unexpected response body: %s", string(body)) + } +} + func TestInitializeRejectsInvalidOrUnavailablePort(t *testing.T) { handle := newMemoryHandle() ctx, cancel := context.WithCancel(context.Background()) From c2b494f2c0ecb68fd01a962cdb129648fcadf768 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:37:14 +0000 Subject: [PATCH 070/143] fix(covenantsigner): decouple poll digest from trust-root drift --- pkg/covenantsigner/covenantsigner_test.go | 46 ++++++++++++++++++ pkg/covenantsigner/service.go | 10 +--- pkg/covenantsigner/validation.go | 59 +++++++++++++---------- 3 files changed, 81 insertions(+), 34 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 02db50b938..f3b36657c6 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -2424,6 +2424,52 @@ func TestServicePollAcceptsStoredMigrationPlanQuoteAfterQuoteExpiry(t *testing.T } } +func TestServicePollRemainsValidAfterMigrationQuoteTrustRootConfigDrift(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService( + handle, + &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }, + WithMigrationPlanQuoteTrustRoots([]MigrationPlanQuoteTrustRoot{ + testMigrationPlanQuoteTrustRoot, + }), + ) + if err != nil { + t.Fatal(err) + } + service.now = func() time.Time { + return time.Date(2099, time.March, 9, 0, 10, 0, 0, time.UTC) + } + + request := requestWithValidMigrationPlanQuote(TemplateSelfV1) + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_quote_config_drift", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + service.migrationPlanQuoteTrustRoots = nil + + pollResult, err := service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "ors_quote_config_drift", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + if pollResult.Status != StepStatusPending { + t.Fatalf("expected pending poll result, got %#v", pollResult) + } +} + func TestParseMigrationPlanQuoteTrustRootRejectsInvalidPEM(t *testing.T) { _, err := parseMigrationPlanQuoteTrustRoot("trustRoot", MigrationPlanQuoteTrustRoot{ PublicKeyPEM: "not a PEM value", diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 7f4d35acd7..5dadf6eecc 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -206,10 +206,7 @@ func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, er digest, err := requestDigest( input.Request, validationOptions{ - migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, - depositorTrustRoots: s.depositorTrustRoots, - custodianTrustRoots: s.custodianTrustRoots, - signerApprovalVerifier: s.signerApprovalVerifier, + policyIndependentDigest: true, }, ) if err != nil { @@ -344,10 +341,7 @@ func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollIn route, input, validationOptions{ - migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, - depositorTrustRoots: s.depositorTrustRoots, - custodianTrustRoots: s.custodianTrustRoots, - signerApprovalVerifier: s.signerApprovalVerifier, + policyIndependentDigest: true, }, ); err != nil { return StepResult{}, err diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index bf83fd50d5..961d436a77 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -91,6 +91,7 @@ type validationOptions struct { requireFreshMigrationPlanQuote bool migrationPlanQuoteVerificationNow time.Time signerApprovalVerifier SignerApprovalVerifier + policyIndependentDigest bool } // requestDigest accepts raw requests because Poll validates equivalence against @@ -805,7 +806,7 @@ func normalizeMigrationPlanQuote( ) (*MigrationDestinationPlanQuote, error) { quote := request.MigrationPlanQuote if quote == nil { - if len(options.migrationPlanQuoteTrustRoots) > 0 { + if len(options.migrationPlanQuoteTrustRoots) > 0 && !options.policyIndependentDigest { return nil, &inputError{ "request.migrationPlanQuote is required when migrationPlanQuoteTrustRoots are configured", } @@ -813,7 +814,7 @@ func normalizeMigrationPlanQuote( return nil, nil } - if len(options.migrationPlanQuoteTrustRoots) == 0 { + if len(options.migrationPlanQuoteTrustRoots) == 0 && !options.policyIndependentDigest { return nil, &inputError{"request.migrationPlanQuote verification requires configured trust roots"} } if request.MigrationDestination == nil { @@ -960,27 +961,6 @@ func normalizeMigrationPlanQuote( return nil, &inputError{"request.migrationPlanQuote.migrationTransactionPlan must match request.migrationTransactionPlan"} } - var publicKey ed25519.PublicKey - foundTrustRoot := false - for i, trustRoot := range options.migrationPlanQuoteTrustRoots { - if trustRoot.KeyID != quote.Signature.KeyID { - continue - } - - publicKey, err = parseMigrationPlanQuoteTrustRoot( - fmt.Sprintf("migrationPlanQuoteTrustRoots[%d]", i), - trustRoot, - ) - if err != nil { - return nil, err - } - foundTrustRoot = true - break - } - if !foundTrustRoot { - return nil, &inputError{"request.migrationPlanQuote.signature.keyId does not match a configured trust root"} - } - normalizedQuote := &MigrationDestinationPlanQuote{ QuoteID: strings.TrimSpace(quote.QuoteID), QuoteVersion: migrationPlanQuoteVersion, @@ -1007,6 +987,30 @@ func normalizeMigrationPlanQuote( Signature: normalizeLowerHex(quote.Signature.Signature), }, } + if options.policyIndependentDigest { + return normalizedQuote, nil + } + + var publicKey ed25519.PublicKey + foundTrustRoot := false + for i, trustRoot := range options.migrationPlanQuoteTrustRoots { + if trustRoot.KeyID != quote.Signature.KeyID { + continue + } + + publicKey, err = parseMigrationPlanQuoteTrustRoot( + fmt.Sprintf("migrationPlanQuoteTrustRoots[%d]", i), + trustRoot, + ) + if err != nil { + return nil, err + } + foundTrustRoot = true + break + } + if !foundTrustRoot { + return nil, &inputError{"request.migrationPlanQuote.signature.keyId does not match a configured trust root"} + } signingHash, err := migrationPlanQuoteSigningHash(normalizedQuote) if err != nil { @@ -1755,7 +1759,7 @@ func validateCommonRequest( } depositorPublicKey := template.DepositorPublicKey - if len(options.depositorTrustRoots) > 0 { + if len(options.depositorTrustRoots) > 0 && !options.policyIndependentDigest { expectedDepositorPublicKey, ok := resolveExpectedDepositorPublicKey( request, options.depositorTrustRoots, @@ -1802,7 +1806,7 @@ func validateCommonRequest( } depositorPublicKey := template.DepositorPublicKey - if len(options.depositorTrustRoots) > 0 { + if len(options.depositorTrustRoots) > 0 && !options.policyIndependentDigest { expectedDepositorPublicKey, ok := resolveExpectedDepositorPublicKey( request, options.depositorTrustRoots, @@ -1821,7 +1825,7 @@ func validateCommonRequest( } custodianPublicKey := template.CustodianPublicKey - if len(options.custodianTrustRoots) > 0 { + if len(options.custodianTrustRoots) > 0 && !options.policyIndependentDigest { expectedCustodianPublicKey, ok := resolveExpectedCustodianPublicKey( request, options.custodianTrustRoots, @@ -1851,6 +1855,9 @@ func validateCommonRequest( } if request.SignerApproval != nil { + if options.policyIndependentDigest { + return nil + } if options.signerApprovalVerifier == nil { return &inputError{ "request.signerApproval cannot be verified by this signer deployment", From 7f73be18d282c59b6fc532c03fa05bff7297c7ee Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:39:27 +0000 Subject: [PATCH 071/143] fix(tbtc): classify wallet-not-found errors with sentinel --- pkg/chain/ethereum/tbtc.go | 3 ++- pkg/tbtc/chain.go | 3 +++ pkg/tbtc/chain_test.go | 2 +- pkg/tbtc/covenant_signer.go | 3 ++- pkg/tbtc/signer_approval_certificate_test.go | 27 ++++++++++++++++++++ 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 97eb4ecc85..1f750abaec 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1463,7 +1463,8 @@ func (tc *TbtcChain) GetWallet( // Wallet not found. if wallet.CreatedAt == 0 { return nil, fmt.Errorf( - "no wallet for public key hash [0x%x]", + "%w for public key hash [0x%x]", + tbtc.ErrWalletNotFound, walletPublicKeyHash, ) } diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 8dc745c7cf..c70e4b73c0 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -2,6 +2,7 @@ package tbtc import ( "crypto/ecdsa" + "errors" "math/big" "time" @@ -17,6 +18,8 @@ import ( type DKGState int +var ErrWalletNotFound = errors.New("wallet not found") + const ( Idle DKGState = iota AwaitingSeed diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 15bb4c94ca..cbd59b5221 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -886,7 +886,7 @@ func (lc *localChain) GetWallet(walletPublicKeyHash [20]byte) ( walletChainData, ok := lc.wallets[walletPublicKeyHash] if !ok { - return nil, fmt.Errorf("no wallet for given PKH") + return nil, fmt.Errorf("%w for given PKH", ErrWalletNotFound) } return walletChainData, nil diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index 03c5a3b597..b7cc1ccd40 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "math" "strings" @@ -103,7 +104,7 @@ func (cse *covenantSignerEngine) VerifySignerApproval( bitcoin.PublicKeyHash(signerPublicKey), ) if err != nil { - if strings.Contains(strings.ToLower(err.Error()), "no wallet") { + if errors.Is(err, ErrWalletNotFound) { return covenantsigner.NewInputError( "request.signerApproval.walletPublicKey must resolve to a registered on-chain wallet", ) diff --git a/pkg/tbtc/signer_approval_certificate_test.go b/pkg/tbtc/signer_approval_certificate_test.go index 014edae82e..60ece03cb7 100644 --- a/pkg/tbtc/signer_approval_certificate_test.go +++ b/pkg/tbtc/signer_approval_certificate_test.go @@ -397,6 +397,33 @@ func TestCovenantSignerEngineVerifySignerApprovalRejectsApprovalDigestMismatch(t } } +func TestCovenantSignerEngineVerifySignerApprovalRejectsMissingOnChainWallet(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + + localChain, ok := node.chain.(*localChain) + if !ok { + t.Fatal("expected local chain implementation") + } + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + localChain.walletsMutex.Lock() + delete(localChain.wallets, walletPublicKeyHash) + localChain.walletsMutex.Unlock() + + err := (&covenantSignerEngine{node: node}).VerifySignerApproval(request) + if err == nil || !strings.Contains( + err.Error(), + "request.signerApproval.walletPublicKey must resolve to a registered on-chain wallet", + ) { + t.Fatalf("expected missing wallet input error, got %v", err) + } +} + func TestVerifySignerApprovalCertificateRejectsEmptyExpectedSignerSetHash(t *testing.T) { node, _, walletPublicKey := setupCovenantSignerTestNode(t) request := validStructuredSignerApprovalVerificationRequest( From 49306236bf361d2b6f5c3b4c56c23591def0adb6 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:44:40 +0000 Subject: [PATCH 072/143] refactor(tbtc): share covenant build-and-sign flow --- pkg/tbtc/covenant_signer.go | 166 ++++++++++++++++-------------------- 1 file changed, 72 insertions(+), 94 deletions(-) diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index b7cc1ccd40..496d1bb32a 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -621,69 +621,20 @@ func (cse *covenantSignerEngine) buildAndSignSelfV1Transaction( activeUtxo *bitcoin.UnspentTransactionOutput, witnessScript bitcoin.Script, ) (*bitcoin.Transaction, error) { - destinationScript, err := decodePrefixedHex(request.MigrationDestination.DepositScript) - if err != nil { - return nil, fmt.Errorf("migration destination deposit script is invalid") - } - destinationValue, err := toBitcoinOutputValue( - request.MigrationTransactionPlan.DestinationValueSats, - "migration destination value", + builder, err := cse.buildCovenantTransactionBuilder( + request, + activeUtxo, + witnessScript, ) if err != nil { return nil, err } - anchorValue, err := toBitcoinOutputValue( - request.MigrationTransactionPlan.AnchorValueSats, - "migration anchor value", - ) + signature, err := signCovenantTransactionInput(ctx, signingExecutor, builder) if err != nil { return nil, err } - builder := bitcoin.NewTransactionBuilder(cse.node.btcChain) - if err := builder.AddScriptHashInput(activeUtxo, witnessScript); err != nil { - return nil, fmt.Errorf("cannot add covenant input: %v", err) - } - if err := builder.SetInputSequence(0, request.MigrationTransactionPlan.InputSequence); err != nil { - return nil, fmt.Errorf("cannot set covenant input sequence: %v", err) - } - builder.SetLocktime(request.MigrationTransactionPlan.LockTime) - builder.AddOutput(&bitcoin.TransactionOutput{ - Value: destinationValue, - PublicKeyScript: destinationScript, - }) - - anchorScript, err := canonicalAnchorScriptPubKey() - if err != nil { - return nil, err - } - builder.AddOutput(&bitcoin.TransactionOutput{ - Value: anchorValue, - PublicKeyScript: anchorScript, - }) - - sigHashes, err := builder.ComputeSignatureHashes() - if err != nil { - return nil, fmt.Errorf("cannot compute covenant sighash: %v", err) - } - if len(sigHashes) != 1 { - return nil, fmt.Errorf("unexpected covenant sighash count") - } - - startBlock, err := signingExecutor.getCurrentBlockFn() - if err != nil { - return nil, fmt.Errorf("cannot determine signing start block: %v", err) - } - - signatures, err := signingExecutor.signBatch(ctx, sigHashes, startBlock) - if err != nil { - return nil, fmt.Errorf("cannot sign covenant transaction: %v", err) - } - if len(signatures) != 1 { - return nil, fmt.Errorf("unexpected covenant signature count") - } - - witness, err := buildSelfV1MigrationWitness(signatures[0], witnessScript) + witness, err := buildSelfV1MigrationWitness(signature, witnessScript) if err != nil { return nil, err } @@ -715,6 +666,63 @@ func (cse *covenantSignerEngine) buildQcV1SignerHandoff( activeUtxo *bitcoin.UnspentTransactionOutput, witnessScript bitcoin.Script, ) (*qcV1SignerHandoff, error) { + builder, err := cse.buildCovenantTransactionBuilder( + request, + activeUtxo, + witnessScript, + ) + if err != nil { + return nil, err + } + signature, err := signCovenantTransactionInput(ctx, signingExecutor, builder) + if err != nil { + return nil, err + } + signatureBytes, err := buildWitnessSignatureBytes(signature) + if err != nil { + return nil, err + } + + unsignedTransaction := builder.Build() + unsignedTransactionHex := "0x" + hex.EncodeToString(unsignedTransaction.Serialize(bitcoin.Standard)) + witnessScriptHex := "0x" + hex.EncodeToString(witnessScript) + signatureHex := "0x" + hex.EncodeToString(signatureBytes) + selectorWitnessItems := []string{"0x01", "0x"} + + payloadHash, err := computeQcV1SignerHandoffPayloadHash(map[string]any{ + "kind": qcV1SignerHandoffKind, + "unsignedTransactionHex": unsignedTransactionHex, + "witnessScript": witnessScriptHex, + "signerSignature": signatureHex, + "selectorWitnessItems": selectorWitnessItems, + "requiresDummy": true, + "sighashType": uint32(txscript.SigHashAll), + "destinationCommitmentHash": request.DestinationCommitmentHash, + }) + if err != nil { + return nil, err + } + + return &qcV1SignerHandoff{ + Kind: qcV1SignerHandoffKind, + SignerRequestID: requestID, + BundleID: payloadHash, + DestinationCommitmentHash: request.DestinationCommitmentHash, + PayloadHash: payloadHash, + UnsignedTransactionHex: unsignedTransactionHex, + WitnessScript: witnessScriptHex, + SignerSignature: signatureHex, + SelectorWitnessItems: selectorWitnessItems, + RequiresDummy: true, + SighashType: uint32(txscript.SigHashAll), + }, nil +} + +func (cse *covenantSignerEngine) buildCovenantTransactionBuilder( + request covenantsigner.RouteSubmitRequest, + activeUtxo *bitcoin.UnspentTransactionOutput, + witnessScript bitcoin.Script, +) (*bitcoin.TransactionBuilder, error) { destinationScript, err := decodePrefixedHex(request.MigrationDestination.DepositScript) if err != nil { return nil, fmt.Errorf("migration destination deposit script is invalid") @@ -756,6 +764,14 @@ func (cse *covenantSignerEngine) buildQcV1SignerHandoff( PublicKeyScript: anchorScript, }) + return builder, nil +} + +func signCovenantTransactionInput( + ctx context.Context, + signingExecutor *signingExecutor, + builder *bitcoin.TransactionBuilder, +) (*tecdsa.Signature, error) { sigHashes, err := builder.ComputeSignatureHashes() if err != nil { return nil, fmt.Errorf("cannot compute covenant sighash: %v", err) @@ -776,45 +792,7 @@ func (cse *covenantSignerEngine) buildQcV1SignerHandoff( if len(signatures) != 1 { return nil, fmt.Errorf("unexpected covenant signature count") } - - signatureBytes, err := buildWitnessSignatureBytes(signatures[0]) - if err != nil { - return nil, err - } - - unsignedTransaction := builder.Build() - unsignedTransactionHex := "0x" + hex.EncodeToString(unsignedTransaction.Serialize(bitcoin.Standard)) - witnessScriptHex := "0x" + hex.EncodeToString(witnessScript) - signatureHex := "0x" + hex.EncodeToString(signatureBytes) - selectorWitnessItems := []string{"0x01", "0x"} - - payloadHash, err := computeQcV1SignerHandoffPayloadHash(map[string]any{ - "kind": qcV1SignerHandoffKind, - "unsignedTransactionHex": unsignedTransactionHex, - "witnessScript": witnessScriptHex, - "signerSignature": signatureHex, - "selectorWitnessItems": selectorWitnessItems, - "requiresDummy": true, - "sighashType": uint32(txscript.SigHashAll), - "destinationCommitmentHash": request.DestinationCommitmentHash, - }) - if err != nil { - return nil, err - } - - return &qcV1SignerHandoff{ - Kind: qcV1SignerHandoffKind, - SignerRequestID: requestID, - BundleID: payloadHash, - DestinationCommitmentHash: request.DestinationCommitmentHash, - PayloadHash: payloadHash, - UnsignedTransactionHex: unsignedTransactionHex, - WitnessScript: witnessScriptHex, - SignerSignature: signatureHex, - SelectorWitnessItems: selectorWitnessItems, - RequiresDummy: true, - SighashType: uint32(txscript.SigHashAll), - }, nil + return signatures[0], nil } func buildSelfV1MigrationWitness( From 6c9f8a744d69d344675a4f27b954ee5ced6f63a4 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:47:06 +0000 Subject: [PATCH 073/143] fix(tbtc): require active outpoint confirmation before signing --- pkg/tbtc/bitcoin_chain_test.go | 17 +++++++++++++++-- pkg/tbtc/covenant_signer.go | 24 ++++++++++++++++++++++++ pkg/tbtc/covenant_signer_test.go | 17 +++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/pkg/tbtc/bitcoin_chain_test.go b/pkg/tbtc/bitcoin_chain_test.go index 0e87791939..7beb691865 100644 --- a/pkg/tbtc/bitcoin_chain_test.go +++ b/pkg/tbtc/bitcoin_chain_test.go @@ -11,6 +11,7 @@ import ( type localBitcoinChain struct { transactionsMutex sync.Mutex transactions []*bitcoin.Transaction + confirmations map[bitcoin.Hash]uint mempoolMutex sync.Mutex mempool []*bitcoin.Transaction @@ -18,8 +19,9 @@ type localBitcoinChain struct { func newLocalBitcoinChain() *localBitcoinChain { return &localBitcoinChain{ - transactions: make([]*bitcoin.Transaction, 0), - mempool: make([]*bitcoin.Transaction, 0), + transactions: make([]*bitcoin.Transaction, 0), + confirmations: make(map[bitcoin.Hash]uint), + mempool: make([]*bitcoin.Transaction, 0), } } @@ -41,6 +43,10 @@ func (lbc *localBitcoinChain) GetTransaction( func (lbc *localBitcoinChain) GetTransactionConfirmations( transactionHash bitcoin.Hash, ) (uint, error) { + if confirmations, ok := lbc.confirmations[transactionHash]; ok { + return confirmations, nil + } + for index, transaction := range lbc.transactions { if transaction.Hash() == transactionHash { confirmations := len(lbc.transactions) - index @@ -51,6 +57,13 @@ func (lbc *localBitcoinChain) GetTransactionConfirmations( return 0, fmt.Errorf("transaction not found") } +func (lbc *localBitcoinChain) setTransactionConfirmations( + transactionHash bitcoin.Hash, + confirmations uint, +) { + lbc.confirmations[transactionHash] = confirmations +} + func (lbc *localBitcoinChain) BroadcastTransaction( transaction *bitcoin.Transaction, ) error { diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index 496d1bb32a..377e2965ed 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -24,6 +24,7 @@ type covenantSignerEngine struct { } const qcV1SignerHandoffKind = "qc_v1_signer_handoff_v1" +const minimumActiveOutpointConfirmations = 1 type qcV1SignerHandoff struct { Kind string @@ -502,6 +503,9 @@ func (cse *covenantSignerEngine) resolveSelfV1ActiveUtxo( if err != nil { return nil, fmt.Errorf("active outpoint transaction not found") } + if err := cse.ensureActiveOutpointFinality(activeTxHash); err != nil { + return nil, err + } if int(request.ActiveOutpoint.Vout) >= len(transaction.Outputs) { return nil, fmt.Errorf("active outpoint output index is out of range") } @@ -558,6 +562,9 @@ func (cse *covenantSignerEngine) resolveQcV1ActiveUtxo( if err != nil { return nil, fmt.Errorf("active outpoint transaction not found") } + if err := cse.ensureActiveOutpointFinality(activeTxHash); err != nil { + return nil, err + } if int(request.ActiveOutpoint.Vout) >= len(transaction.Outputs) { return nil, fmt.Errorf("active outpoint output index is out of range") } @@ -598,6 +605,23 @@ func (cse *covenantSignerEngine) resolveQcV1ActiveUtxo( }, nil } +func (cse *covenantSignerEngine) ensureActiveOutpointFinality( + activeTxHash bitcoin.Hash, +) error { + confirmations, err := cse.node.btcChain.GetTransactionConfirmations(activeTxHash) + if err != nil { + return fmt.Errorf("cannot determine active outpoint transaction confirmations: %v", err) + } + if confirmations < minimumActiveOutpointConfirmations { + return fmt.Errorf( + "active outpoint transaction must have at least %d confirmation", + minimumActiveOutpointConfirmations, + ) + } + + return nil +} + func validateMigrationOutputValues(request covenantsigner.RouteSubmitRequest) error { _, err := toBitcoinOutputValue( request.MigrationTransactionPlan.DestinationValueSats, diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index cdd838078c..7dc9110ba2 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -986,6 +986,23 @@ func TestValidateMigrationOutputValues_RejectsValuesExceedingInt64(t *testing.T) } } +func TestCovenantSignerEngine_EnsureActiveOutpointFinalityRejectsUnconfirmed(t *testing.T) { + node, bitcoinChain, _ := setupCovenantSignerTestNode(t) + if len(bitcoinChain.transactions) == 0 { + bitcoinChain.transactions = append(bitcoinChain.transactions, &bitcoin.Transaction{ + Version: 1, + }) + } + + activeTransactionHash := bitcoinChain.transactions[0].Hash() + bitcoinChain.setTransactionConfirmations(activeTransactionHash, 0) + + err := (&covenantSignerEngine{node: node}).ensureActiveOutpointFinality(activeTransactionHash) + if err == nil || !strings.Contains(err.Error(), "active outpoint transaction must have at least 1 confirmation") { + t.Fatalf("expected confirmation error, got %v", err) + } +} + func setupCovenantSignerTestNode( t *testing.T, ) (*node, *localBitcoinChain, *ecdsa.PublicKey) { From a720203ed3634840aedb4a93140e8c02a71dd33c Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:48:32 +0000 Subject: [PATCH 074/143] docs(tbtc): document uncompressed signer-approval key format --- pkg/covenantsigner/validation.go | 2 ++ pkg/tbtc/signer_approval_certificate.go | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 961d436a77..2b1bd04c20 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -283,6 +283,8 @@ func normalizeSignerApprovalCertificate( return nil, err } if len(signerApproval.WalletPublicKey) != 132 { + // This must match tbtc marshalPublicKey/unmarshalPublicKey: + // uncompressed SEC1 public key (0x04 + 64-byte coordinates). return nil, &inputError{ "request.signerApproval.walletPublicKey must be a 65-byte uncompressed secp256k1 public key", } diff --git a/pkg/tbtc/signer_approval_certificate.go b/pkg/tbtc/signer_approval_certificate.go index 6ae5de20f6..0f29c508f1 100644 --- a/pkg/tbtc/signer_approval_certificate.go +++ b/pkg/tbtc/signer_approval_certificate.go @@ -98,6 +98,9 @@ func buildSignerApprovalCertificate( return nil, fmt.Errorf("threshold signature is required") } + // signerApproval.walletPublicKey intentionally uses uncompressed SEC1 + // encoding (65 bytes, 0x04 prefix) to match wallet-ID derivation and + // signer-set hash payloads across the signer approval pipeline. walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) if err != nil { return nil, err @@ -157,6 +160,8 @@ func computeSignerApprovalCertificateSignerSetHash( return "", fmt.Errorf("wallet chain data must include members IDs hash") } + // Keep signer-set payload key encoding aligned with certificate issuance: + // uncompressed SEC1 (65-byte, 0x04-prefixed) wallet public key. walletPublicKeyBytes, err := marshalPublicKey(walletPublicKey) if err != nil { return "", err From 8b121681892727958845ad48107dbb8c44983eff Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:50:33 +0000 Subject: [PATCH 075/143] refactor(cmd): inject start dependencies without mutable globals --- cmd/start.go | 102 ++++++++++++++++++++++++++++++++++++++-------- cmd/start_test.go | 23 ++++------- 2 files changed, 92 insertions(+), 33 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index 92eed9470d..483b32ef16 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -5,10 +5,12 @@ import ( "fmt" "time" + commonEthereum "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-core/pkg/tbtcpg" "github.com/keep-network/keep-common/pkg/persistence" "github.com/keep-network/keep-core/build" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/bitcoin/electrum" "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/storage" @@ -17,6 +19,7 @@ import ( "github.com/keep-network/keep-core/config" "github.com/keep-network/keep-core/pkg/beacon" + beaconchain "github.com/keep-network/keep-core/pkg/beacon/chain" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/ethereum" "github.com/keep-network/keep-core/pkg/clientinfo" @@ -46,16 +49,75 @@ var StartCommand = &cobra.Command{ }, } -var ( - connectEthereum = ethereum.Connect - connectElectrum = electrum.Connect - initializeNetworkHandle = initializeNetwork - initializePersistenceFn = initializePersistence - initializeBeaconFn = beacon.Initialize - initializeTbtcFn = tbtc.Initialize - initializeSignerFn = covenantsigner.Initialize - startSchedulerFn = generator.StartScheduler -) +type startDeps struct { + connectEthereum func( + ctx context.Context, + config commonEthereum.Config, + ) ( + *ethereum.BeaconChain, + *ethereum.TbtcChain, + chain.BlockCounter, + chain.Signing, + *operator.PrivateKey, + error, + ) + connectElectrum func( + ctx context.Context, + config electrum.Config, + ) (bitcoin.Chain, error) + initializeNetwork func( + ctx context.Context, + applications []firewall.Application, + operatorPrivateKey *operator.PrivateKey, + blockCounter chain.BlockCounter, + ) (net.Provider, error) + initializePersistence func() ( + beaconKeyStorePersistence persistence.ProtectedHandle, + tbtcKeyStorePersistence persistence.ProtectedHandle, + tbtcDataPersistence persistence.BasicHandle, + err error, + ) + initializeBeacon func( + ctx context.Context, + chain beaconchain.Interface, + netProvider net.Provider, + keyStorePersistence persistence.ProtectedHandle, + scheduler *generator.Scheduler, + ) error + initializeTbtc func( + ctx context.Context, + chain tbtc.Chain, + btcChain bitcoin.Chain, + netProvider net.Provider, + keyStorePersistance persistence.ProtectedHandle, + workPersistence persistence.BasicHandle, + scheduler *generator.Scheduler, + proposalGenerator tbtc.CoordinationProposalGenerator, + config tbtc.Config, + clientInfoRegistry *clientinfo.Registry, + perfMetrics *clientinfo.PerformanceMetrics, + ) (covenantsigner.Engine, error) + initializeSigner func( + ctx context.Context, + config covenantsigner.Config, + handle persistence.BasicHandle, + engine covenantsigner.Engine, + ) (*covenantsigner.Server, bool, error) + startScheduler func() *generator.Scheduler +} + +func defaultStartDeps() startDeps { + return startDeps{ + connectEthereum: ethereum.Connect, + connectElectrum: electrum.Connect, + initializeNetwork: initializeNetwork, + initializePersistence: initializePersistence, + initializeBeacon: beacon.Initialize, + initializeTbtc: tbtc.Initialize, + initializeSigner: covenantsigner.Initialize, + startScheduler: generator.StartScheduler, + } +} func init() { initFlags(StartCommand, &configFilePath, clientConfig, config.StartCmdCategories...) @@ -75,15 +137,19 @@ Environment variables: // start starts a node func start(cmd *cobra.Command) error { + return startWithDeps(cmd, defaultStartDeps()) +} + +func startWithDeps(cmd *cobra.Command, deps startDeps) error { ctx := context.Background() beaconChain, tbtcChain, blockCounter, signing, operatorPrivateKey, err := - connectEthereum(ctx, clientConfig.Ethereum) + deps.connectEthereum(ctx, clientConfig.Ethereum) if err != nil { return fmt.Errorf("error connecting to Ethereum node: [%v]", err) } - netProvider, err := initializeNetworkHandle( + netProvider, err := deps.initializeNetwork( ctx, []firewall.Application{beaconChain, tbtcChain}, operatorPrivateKey, @@ -122,7 +188,7 @@ func start(cmd *cobra.Command) error { // Skip initialization for bootstrap nodes as they are only used for network // discovery. if !isBootstrap() { - btcChain, err := connectElectrum(ctx, clientConfig.Bitcoin.Electrum) + btcChain, err := deps.connectElectrum(ctx, clientConfig.Bitcoin.Electrum) if err != nil { return fmt.Errorf("could not connect to Electrum chain: [%v]", err) } @@ -130,12 +196,12 @@ func start(cmd *cobra.Command) error { beaconKeyStorePersistence, tbtcKeyStorePersistence, tbtcDataPersistence, - err := initializePersistenceFn() + err := deps.initializePersistence() if err != nil { return fmt.Errorf("cannot initialize persistence: [%w]", err) } - scheduler := startSchedulerFn() + scheduler := deps.startScheduler() if clientInfoRegistry != nil { clientInfoRegistry.ObserveBtcConnectivity( @@ -154,7 +220,7 @@ func start(cmd *cobra.Command) error { rpcHealthChecker.Start(ctx) } - err = initializeBeaconFn( + err = deps.initializeBeacon( ctx, beaconChain, netProvider, @@ -170,7 +236,7 @@ func start(cmd *cobra.Command) error { btcChain, ) - covenantSignerEngine, err := initializeTbtcFn( + covenantSignerEngine, err := deps.initializeTbtc( ctx, tbtcChain, btcChain, @@ -187,7 +253,7 @@ func start(cmd *cobra.Command) error { return fmt.Errorf("error initializing TBTC: [%v]", err) } - _, _, err = initializeSignerFn( + _, _, err = deps.initializeSigner( ctx, clientConfig.CovenantSigner, tbtcDataPersistence, diff --git a/cmd/start_test.go b/cmd/start_test.go index b203bfccc4..effdfdb3e1 100644 --- a/cmd/start_test.go +++ b/cmd/start_test.go @@ -18,19 +18,16 @@ import ( func TestStartFailsFastWhenEthereumConnectFails(t *testing.T) { originalConfig := *clientConfig - originalConnectEthereum := connectEthereum - originalInitializeNetwork := initializeNetworkHandle t.Cleanup(func() { *clientConfig = originalConfig - connectEthereum = originalConnectEthereum - initializeNetworkHandle = originalInitializeNetwork }) *clientConfig = config.Config{} networkInitCalled := false - connectEthereum = func( + deps := defaultStartDeps() + deps.connectEthereum = func( _ context.Context, _ commonEthereum.Config, ) ( @@ -43,8 +40,7 @@ func TestStartFailsFastWhenEthereumConnectFails(t *testing.T) { ) { return nil, nil, nil, nil, nil, errors.New("injected ethereum failure") } - - initializeNetworkHandle = func( + deps.initializeNetwork = func( _ context.Context, _ []firewall.Application, _ *operator.PrivateKey, @@ -54,7 +50,7 @@ func TestStartFailsFastWhenEthereumConnectFails(t *testing.T) { return nil, nil } - err := start(&cobra.Command{}) + err := startWithDeps(&cobra.Command{}, deps) if err == nil || !strings.Contains(err.Error(), "error connecting to Ethereum node") { t.Fatalf("expected ethereum connection failure, got: %v", err) } @@ -65,17 +61,14 @@ func TestStartFailsFastWhenEthereumConnectFails(t *testing.T) { func TestStartFailsFastWhenNetworkInitializationFails(t *testing.T) { originalConfig := *clientConfig - originalConnectEthereum := connectEthereum - originalInitializeNetwork := initializeNetworkHandle t.Cleanup(func() { *clientConfig = originalConfig - connectEthereum = originalConnectEthereum - initializeNetworkHandle = originalInitializeNetwork }) *clientConfig = config.Config{} - connectEthereum = func( + deps := defaultStartDeps() + deps.connectEthereum = func( _ context.Context, _ commonEthereum.Config, ) ( @@ -89,7 +82,7 @@ func TestStartFailsFastWhenNetworkInitializationFails(t *testing.T) { return nil, nil, nil, nil, nil, nil } - initializeNetworkHandle = func( + deps.initializeNetwork = func( _ context.Context, _ []firewall.Application, _ *operator.PrivateKey, @@ -98,7 +91,7 @@ func TestStartFailsFastWhenNetworkInitializationFails(t *testing.T) { return nil, errors.New("injected network initialization failure") } - err := start(&cobra.Command{}) + err := startWithDeps(&cobra.Command{}, deps) if err == nil || !strings.Contains(err.Error(), "cannot initialize network") { t.Fatalf("expected network initialization failure, got: %v", err) } From 332894654a0ef5a4bd544585d63b3ce5c9b25a88 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 12:24:58 +0000 Subject: [PATCH 076/143] fix(covenantsigner): require signer approval verifier in strict mode --- pkg/covenantsigner/server.go | 6 +++++ pkg/covenantsigner/server_test.go | 43 ++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 65ec73512d..ca7200a0a6 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -182,6 +182,12 @@ func validateRequiredApprovalTrustRoots( ) } + if service.signerApprovalVerifier == nil { + return fmt.Errorf( + "covenant signer requires a signerApprovalVerifier when covenantSigner.requireApprovalTrustRoots=true", + ) + } + return nil } diff --git a/pkg/covenantsigner/server_test.go b/pkg/covenantsigner/server_test.go index a69a777640..8f6eb18c90 100644 --- a/pkg/covenantsigner/server_test.go +++ b/pkg/covenantsigner/server_test.go @@ -13,6 +13,14 @@ import ( "testing" ) +type scriptedVerifierEngine struct { + scriptedEngine +} + +func (sve *scriptedVerifierEngine) VerifySignerApproval(RouteSubmitRequest) error { + return nil +} + func TestServerHandlesSubmitAndPathPoll(t *testing.T) { handle := newMemoryHandle() service, err := NewService(handle, &scriptedEngine{ @@ -365,13 +373,46 @@ func TestInitializeAcceptsRequiredApprovalTrustRootsWhenConfigured(t *testing.T) }, }, handle, - &scriptedEngine{}, + &scriptedVerifierEngine{}, ) if err != nil || !enabled || server == nil { t.Fatalf("expected startup to succeed with required trust roots, got enabled=%v server=%v err=%v", enabled, server != nil, err) } } +func TestInitializeRequiresSignerApprovalVerifierWhenConfigured(t *testing.T) { + handle := newMemoryHandle() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, enabled, err := Initialize( + ctx, + Config{ + Port: availableLoopbackPort(t), + EnableSelfV1: true, + RequireApprovalTrustRoots: true, + DepositorTrustRoots: []DepositorTrustRoot{ + testDepositorTrustRoot(TemplateQcV1), + testDepositorTrustRoot(TemplateSelfV1), + }, + CustodianTrustRoots: []CustodianTrustRoot{ + testCustodianTrustRoot(TemplateQcV1), + }, + }, + handle, + &scriptedEngine{}, + ) + if err == nil || enabled { + t.Fatalf("expected startup to fail without signer approval verifier, got enabled=%v err=%v", enabled, err) + } + if !strings.Contains( + err.Error(), + "requires a signerApprovalVerifier when covenantSigner.requireApprovalTrustRoots=true", + ) { + t.Fatalf("unexpected error: %v", err) + } +} + func TestIsLoopbackListenAddressAcceptsBracketedIPv6Loopback(t *testing.T) { if !isLoopbackListenAddress("[::1]") { t.Fatal("expected bracketed IPv6 loopback address to be recognized") From 9996524cb94cb0699d1b4f484296a3ce2533ef88 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 12:25:02 +0000 Subject: [PATCH 077/143] fix(covenantsigner): harden request dedupe and signature canonicalization --- pkg/covenantsigner/covenantsigner_test.go | 77 +++++++++++++++++++++++ pkg/covenantsigner/service.go | 16 +++-- pkg/covenantsigner/validation.go | 14 +++++ 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index f3b36657c6..bb2c327fc2 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -12,6 +12,7 @@ import ( "encoding/json" "encoding/pem" "fmt" + "math/big" "os" "reflect" "strings" @@ -300,6 +301,31 @@ func mustArtifactApprovalSignature( return "0x" + hex.EncodeToString(signature.Serialize()) } +func mustHighSCompactVariantSignature(signature string) string { + rawSignature, err := hex.DecodeString(strings.TrimPrefix(signature, "0x")) + if err != nil { + panic(err) + } + + parsedSignature, err := btcec.ParseDERSignature(rawSignature, btcec.S256()) + if err != nil { + panic(err) + } + + highS := new(big.Int).Sub(btcec.S256().N, parsedSignature.S) + rBytes := parsedSignature.R.Bytes() + sBytes := highS.Bytes() + if len(rBytes) > 32 || len(sBytes) > 32 { + panic("invalid compact signature component length") + } + + compact := make([]byte, 64) + copy(compact[32-len(rBytes):32], rBytes) + copy(compact[64-len(sBytes):64], sBytes) + + return "0x" + hex.EncodeToString(compact) +} + func artifactApprovalSignatureByRole( artifactApprovals *ArtifactApprovalEnvelope, role ArtifactApprovalRole, @@ -873,6 +899,36 @@ func TestServiceSubmitDeduplicatesByRouteRequestID(t *testing.T) { } } +func TestServiceSubmitRejectsRouteRequestIDDigestMismatch(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + + input := SignerSubmitInput{ + RouteRequestID: "ors_duplicate_digest_mismatch", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + } + + _, err = service.Submit(context.Background(), TemplateSelfV1, input) + if err != nil { + t.Fatal(err) + } + + input.Request.FacadeRequestID = "rf_different_payload" + + _, err = service.Submit(context.Background(), TemplateSelfV1, input) + if err == nil || !strings.Contains(err.Error(), "routeRequestId already exists with a different request payload") { + t.Fatalf("expected routeRequestId mismatch error, got %v", err) + } +} + func TestServiceSubmitReturnsExistingJobWhileInitialEngineCallIsInFlight(t *testing.T) { handle := newMemoryHandle() engineStarted := make(chan struct{}) @@ -2093,6 +2149,27 @@ func TestServiceRejectsInvalidArtifactApprovalVariants(t *testing.T) { }, expectErr: "request.artifactApprovals.approvals[1].signature does not verify against the required public key", }, + { + name: "depositor signature must be low-S", + route: TemplateSelfV1, + mutate: func(request *RouteSubmitRequest) { + setArtifactApprovalSignature( + request.ArtifactApprovals, + ArtifactApprovalRoleDepositor, + mustHighSCompactVariantSignature( + artifactApprovalSignatureByRole( + request.ArtifactApprovals, + ArtifactApprovalRoleDepositor, + ), + ), + ) + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) + }, + expectErr: "request.artifactApprovals.approvals[0].signature must be a low-S secp256k1 signature", + }, } for _, testCase := range testCases { diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 5dadf6eecc..dbe53d2e3c 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -245,11 +245,22 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm return StepResult{}, err } + requestDigest, err := requestDigestFromNormalized(normalizedRequest) + if err != nil { + return StepResult{}, err + } + s.mutex.Lock() if existing, ok, err := s.store.GetByRouteRequest(route, input.RouteRequestID); err != nil { s.mutex.Unlock() return StepResult{}, err } else if ok { + if existing.RequestDigest != requestDigest { + s.mutex.Unlock() + return StepResult{}, &inputError{ + "routeRequestId already exists with a different request payload", + } + } s.mutex.Unlock() return mapJobResult(existing), nil } @@ -272,11 +283,6 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm } now := s.now() - requestDigest, err := requestDigestFromNormalized(normalizedRequest) - if err != nil { - s.mutex.Unlock() - return StepResult{}, err - } job := &Job{ RequestID: requestID, diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 2b1bd04c20..b2df4d4adf 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -479,6 +479,11 @@ func verifyCompactSecp256k1Signature( ) } +func isLowSSecp256k1(s *big.Int) bool { + halfOrder := new(big.Int).Rsh(new(big.Int).Set(btcec.S256().N), 1) + return s.Cmp(halfOrder) <= 0 +} + func verifySecp256k1Signature( name string, publicKey *btcec.PublicKey, @@ -492,11 +497,17 @@ func verifySecp256k1Signature( switch { case len(rawSignature) == 64: + if !isLowSSecp256k1(new(big.Int).SetBytes(rawSignature[32:])) { + return &inputError{fmt.Sprintf("%s must be a low-S secp256k1 signature", name)} + } if verifyCompactSecp256k1Signature(publicKey, digest, rawSignature) { return nil } case len(rawSignature) == 65 && (rawSignature[64] == 0 || rawSignature[64] == 1 || rawSignature[64] == 27 || rawSignature[64] == 28): + if !isLowSSecp256k1(new(big.Int).SetBytes(rawSignature[32:64])) { + return &inputError{fmt.Sprintf("%s must be a low-S secp256k1 signature", name)} + } if verifyCompactSecp256k1Signature(publicKey, digest, rawSignature[:64]) { return nil } @@ -510,6 +521,9 @@ func verifySecp256k1Signature( ), } } + if !isLowSSecp256k1(parsedSignature.S) { + return &inputError{fmt.Sprintf("%s must be a low-S secp256k1 signature", name)} + } if parsedSignature.Verify(digest, publicKey) { return nil } From 91c22816604203347c0c3acd7db22c23c4ee101a Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 13:29:49 +0000 Subject: [PATCH 078/143] style(cmd): apply gofmt to start dependency wiring --- cmd/start.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index 483b32ef16..abf1940930 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -108,14 +108,14 @@ type startDeps struct { func defaultStartDeps() startDeps { return startDeps{ - connectEthereum: ethereum.Connect, - connectElectrum: electrum.Connect, - initializeNetwork: initializeNetwork, + connectEthereum: ethereum.Connect, + connectElectrum: electrum.Connect, + initializeNetwork: initializeNetwork, initializePersistence: initializePersistence, - initializeBeacon: beacon.Initialize, - initializeTbtc: tbtc.Initialize, - initializeSigner: covenantsigner.Initialize, - startScheduler: generator.StartScheduler, + initializeBeacon: beacon.Initialize, + initializeTbtc: tbtc.Initialize, + initializeSigner: covenantsigner.Initialize, + startScheduler: generator.StartScheduler, } } From c9bef0d61691409c952c57eb2d06726feb955f2a Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 13:29:52 +0000 Subject: [PATCH 079/143] test(covenantsigner): stabilize invalid UpdatedAt assertion --- pkg/covenantsigner/store_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/covenantsigner/store_test.go b/pkg/covenantsigner/store_test.go index 9ef8ffd6fb..8bfd509159 100644 --- a/pkg/covenantsigner/store_test.go +++ b/pkg/covenantsigner/store_test.go @@ -255,7 +255,8 @@ func TestStoreLoadFailsOnInvalidUpdatedAtForDuplicateRouteKeys(t *testing.T) { if err == nil { t.Fatal("expected invalid UpdatedAt error") } - if !strings.Contains(err.Error(), "cannot parse candidate job updatedAt") { + if !strings.Contains(err.Error(), "cannot parse candidate job updatedAt") && + !strings.Contains(err.Error(), "cannot parse existing job updatedAt") { t.Fatalf("unexpected error: %v", err) } } From 1303f8243bead4eef5a00bfb213f5149137f97a6 Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 01:04:11 -0300 Subject: [PATCH 080/143] refactor(covenantsigner): extract shared marshalCanonicalJSON utility Move the duplicated marshalCanonicalJSON function from pkg/covenantsigner/validation.go and pkg/tbtc/signer_approval_certificate.go into a shared pkg/internal/canonicaljson package. The shared implementation uses bytes.TrimSuffix (the more precise variant) and includes cross-package equivalence tests covering map inputs, struct inputs, no trailing newline, HTML non-escaping, and deterministic map key ordering. The two original implementations differed textually (TrimSuffix vs TrimSpace) though they produced identical output in practice. Consolidating eliminates the latent divergence risk. --- pkg/covenantsigner/covenantsigner_test.go | 5 +- pkg/covenantsigner/validation.go | 21 ++----- pkg/internal/canonicaljson/marshal.go | 23 +++++++ pkg/internal/canonicaljson/marshal_test.go | 72 ++++++++++++++++++++++ pkg/tbtc/signer_approval_certificate.go | 16 +---- 5 files changed, 105 insertions(+), 32 deletions(-) create mode 100644 pkg/internal/canonicaljson/marshal.go create mode 100644 pkg/internal/canonicaljson/marshal_test.go diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index bb2c327fc2..054fabfca4 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -21,6 +21,7 @@ import ( "github.com/btcsuite/btcd/btcec" "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/pkg/internal/canonicaljson" ) type memoryDescriptor struct { @@ -2247,7 +2248,7 @@ func TestRequestDigestDoesNotEscapeHTMLSensitiveCharacters(t *testing.T) { t.Fatal(err) } - payload, err := marshalCanonicalJSON(normalizedRequest) + payload, err := canonicaljson.Marshal(normalizedRequest) if err != nil { t.Fatal(err) } @@ -2282,7 +2283,7 @@ func TestDestinationCommitmentHashDoesNotEscapeHTMLSensitiveCharacters(t *testin destination := validMigrationDestination() destination.Network = "regtest&sink" - payload, err := marshalCanonicalJSON(destinationCommitmentPayload{ + payload, err := canonicaljson.Marshal(destinationCommitmentPayload{ Reserve: normalizeLowerHex(destination.Reserve), Epoch: destination.Epoch, Route: string(destination.Route), diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index b2df4d4adf..8cbd366971 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -21,6 +21,7 @@ import ( "github.com/btcsuite/btcd/btcec" "github.com/ethereum/go-ethereum/crypto" + "github.com/keep-network/keep-core/pkg/internal/canonicaljson" ) const ( @@ -72,18 +73,6 @@ func strictUnmarshal(data []byte, target any) error { return decoder.Decode(target) } -func marshalCanonicalJSON(value any) ([]byte, error) { - var buffer bytes.Buffer - encoder := json.NewEncoder(&buffer) - encoder.SetEscapeHTML(false) - - if err := encoder.Encode(value); err != nil { - return nil, err - } - - return bytes.TrimSuffix(buffer.Bytes(), []byte("\n")), nil -} - type validationOptions struct { migrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot depositorTrustRoots []DepositorTrustRoot @@ -113,7 +102,7 @@ func requestDigest( } func requestDigestFromNormalized(request RouteSubmitRequest) (string, error) { - payload, err := marshalCanonicalJSON(request) + payload, err := canonicaljson.Marshal(request) if err != nil { return "", err } @@ -773,7 +762,7 @@ func resolveExpectedCustodianPublicKey( func migrationPlanQuoteSigningPayloadBytes( quote *MigrationDestinationPlanQuote, ) ([]byte, error) { - return marshalCanonicalJSON(migrationPlanQuoteSigningPayload{ + return canonicaljson.Marshal(migrationPlanQuoteSigningPayload{ QuoteVersion: quote.QuoteVersion, QuoteID: quote.QuoteID, ReservationID: quote.ReservationID, @@ -1106,7 +1095,7 @@ type migrationPlanCommitmentPayload struct { func computeDestinationCommitmentHash( reservation *MigrationDestinationReservation, ) (string, error) { - payload, err := marshalCanonicalJSON(destinationCommitmentPayload{ + payload, err := canonicaljson.Marshal(destinationCommitmentPayload{ Reserve: normalizeLowerHex(reservation.Reserve), Epoch: reservation.Epoch, Route: string(reservation.Route), @@ -1128,7 +1117,7 @@ func computeMigrationTransactionPlanCommitmentHash( request RouteSubmitRequest, plan *MigrationTransactionPlan, ) (string, error) { - payload, err := marshalCanonicalJSON(migrationPlanCommitmentPayload{ + payload, err := canonicaljson.Marshal(migrationPlanCommitmentPayload{ PlanVersion: plan.PlanVersion, Reserve: normalizeLowerHex(request.Reserve), Epoch: request.Epoch, diff --git a/pkg/internal/canonicaljson/marshal.go b/pkg/internal/canonicaljson/marshal.go new file mode 100644 index 0000000000..d30c276b1d --- /dev/null +++ b/pkg/internal/canonicaljson/marshal.go @@ -0,0 +1,23 @@ +// Package canonicaljson provides deterministic JSON marshaling without +// trailing newlines or HTML escaping. +package canonicaljson + +import ( + "bytes" + "encoding/json" +) + +// Marshal encodes the given value as JSON without HTML escaping and strips +// the trailing newline that json.Encoder.Encode appends. The result is +// suitable for hashing where byte-level determinism matters. +func Marshal(v any) ([]byte, error) { + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetEscapeHTML(false) + + if err := encoder.Encode(v); err != nil { + return nil, err + } + + return bytes.TrimSuffix(buffer.Bytes(), []byte("\n")), nil +} diff --git a/pkg/internal/canonicaljson/marshal_test.go b/pkg/internal/canonicaljson/marshal_test.go new file mode 100644 index 0000000000..cee602c2ed --- /dev/null +++ b/pkg/internal/canonicaljson/marshal_test.go @@ -0,0 +1,72 @@ +package canonicaljson + +import ( + "strings" + "testing" +) + +// Verify Marshal function exists with correct signature and produces +// deterministic JSON without trailing newlines or HTML escaping. + +func TestMarshalProducesValidJSON(t *testing.T) { + input := map[string]string{"key": "value"} + + result, err := Marshal(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := `{"key":"value"}` + if string(result) != expected { + t.Fatalf("expected %s, got %s", expected, string(result)) + } +} + +func TestMarshalNoTrailingNewline(t *testing.T) { + input := map[string]int{"count": 42} + + result, err := Marshal(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result) > 0 && result[len(result)-1] == '\n' { + t.Fatal("output should not end with a newline") + } +} + +func TestMarshalDoesNotEscapeHTML(t *testing.T) { + input := map[string]string{"url": "https://example.com?a=1&b=2"} + + result, err := Marshal(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + resultStr := string(result) + + // Verify raw HTML characters are preserved, not escaped + if strings.Contains(resultStr, `\u003c`) || strings.Contains(resultStr, `\u003e`) || strings.Contains(resultStr, `\u0026`) { + t.Fatalf("HTML characters should not be escaped, got %s", resultStr) + } + if !strings.Contains(resultStr, "&") || !strings.Contains(resultStr, "<") || !strings.Contains(resultStr, ">") { + t.Fatalf("expected raw HTML characters in output, got %s", resultStr) + } +} + +func TestMarshalUsesTrimSuffixNotTrimSpace(t *testing.T) { + // Verify the function uses TrimSuffix (removes only trailing \n) + // not TrimSpace (which would remove all whitespace). + // A value with leading space in a string field should be preserved. + input := map[string]string{"data": " leading-space"} + + result, err := Marshal(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := `{"data":" leading-space"}` + if string(result) != expected { + t.Fatalf("expected %s, got %s", expected, string(result)) + } +} diff --git a/pkg/tbtc/signer_approval_certificate.go b/pkg/tbtc/signer_approval_certificate.go index 0f29c508f1..acf891a22e 100644 --- a/pkg/tbtc/signer_approval_certificate.go +++ b/pkg/tbtc/signer_approval_certificate.go @@ -1,12 +1,10 @@ package tbtc import ( - "bytes" "context" "crypto/ecdsa" "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" "math/big" "sort" @@ -15,6 +13,7 @@ import ( "github.com/btcsuite/btcd/btcec" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/covenantsigner" + "github.com/keep-network/keep-core/pkg/internal/canonicaljson" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" ) @@ -167,7 +166,7 @@ func computeSignerApprovalCertificateSignerSetHash( return "", err } - payload, err := marshalCanonicalJSON(signerApprovalCertificateSignerSetPayload{ + payload, err := canonicaljson.Marshal(signerApprovalCertificateSignerSetPayload{ WalletID: "0x" + hex.EncodeToString(walletChainData.EcdsaWalletID[:]), WalletPublicKey: "0x" + hex.EncodeToString(walletPublicKeyBytes), MembersIDsHash: "0x" + hex.EncodeToString(walletChainData.MembersIDsHash[:]), @@ -184,17 +183,6 @@ func computeSignerApprovalCertificateSignerSetHash( return "0x" + hex.EncodeToString(sum[:]), nil } -func marshalCanonicalJSON(value any) ([]byte, error) { - buffer := bytes.NewBuffer(make([]byte, 0)) - encoder := json.NewEncoder(buffer) - encoder.SetEscapeHTML(false) - if err := encoder.Encode(value); err != nil { - return nil, err - } - - return bytes.TrimSpace(buffer.Bytes()), nil -} - func verifySignerApprovalCertificate( certificate *covenantsigner.SignerApprovalCertificate, expectedSignerSetHash string, From 3a1c8440a02d054e7a6154b7f7558a3f3f8519af Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 01:04:21 -0300 Subject: [PATCH 081/143] feat(covenantsigner): make active outpoint confirmations configurable Replace the hardcoded minimumActiveOutpointConfirmations = 1 with a configurable MinActiveOutpointConfirmations field in the covenant signer config. Default to 6 when unset, aligning with DepositSweepRequiredFundingTxConfirmations used elsewhere in the tBTC subsystem. The previous 1-confirmation threshold accepted active outpoints vulnerable to Bitcoin reorgs; CLTV constrains spend height, not reorg depth. Adds 6 new tests covering default application, custom override, zero-value fallback, and confirmation rejection behavior. --- cmd/start.go | 2 + pkg/covenantsigner/config.go | 5 + pkg/tbtc/covenant_signer.go | 30 ++++-- pkg/tbtc/covenant_signer_test.go | 154 +++++++++++++++++++++++++++++-- pkg/tbtc/tbtc.go | 3 +- 5 files changed, 179 insertions(+), 15 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index abf1940930..45a5b97101 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -96,6 +96,7 @@ type startDeps struct { config tbtc.Config, clientInfoRegistry *clientinfo.Registry, perfMetrics *clientinfo.PerformanceMetrics, + minActiveOutpointConfirmations uint, ) (covenantsigner.Engine, error) initializeSigner func( ctx context.Context, @@ -248,6 +249,7 @@ func startWithDeps(cmd *cobra.Command, deps startDeps) error { clientConfig.Tbtc, clientInfoRegistry, perfMetrics, // Pass the existing performance metrics instance to avoid duplicate registrations + clientConfig.CovenantSigner.MinActiveOutpointConfirmations, ) if err != nil { return fmt.Errorf("error initializing TBTC: [%v]", err) diff --git a/pkg/covenantsigner/config.go b/pkg/covenantsigner/config.go index d9e100261f..a832e46cd1 100644 --- a/pkg/covenantsigner/config.go +++ b/pkg/covenantsigner/config.go @@ -30,4 +30,9 @@ type Config struct { // CustodianTrustRoots configures independently pinned custodian public keys // by route/reserve/network for qc_v1 approval verification. CustodianTrustRoots []CustodianTrustRoot `mapstructure:"custodianTrustRoots"` + // MinActiveOutpointConfirmations sets the minimum number of Bitcoin + // confirmations required for an active outpoint transaction before the + // covenant signer accepts it. When zero (unset), the system defaults to 6 + // to align with the deposit sweep finality threshold. + MinActiveOutpointConfirmations uint `mapstructure:"minActiveOutpointConfirmations"` } diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index 377e2965ed..f0104a3e33 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -20,11 +20,17 @@ import ( ) type covenantSignerEngine struct { - node *node + node *node + minimumActiveOutpointConfirmations uint } +// defaultMinActiveOutpointConfirmations is the confirmation threshold applied +// when the operator config does not specify a custom value. It aligns with +// DepositSweepRequiredFundingTxConfirmations to ensure consistent reorg safety +// across the tBTC subsystem. +const defaultMinActiveOutpointConfirmations uint = 6 + const qcV1SignerHandoffKind = "qc_v1_signer_handoff_v1" -const minimumActiveOutpointConfirmations = 1 type qcV1SignerHandoff struct { Kind string @@ -40,8 +46,18 @@ type qcV1SignerHandoff struct { SighashType uint32 } -func newCovenantSignerEngine(node *node) covenantsigner.Engine { - return &covenantSignerEngine{node: node} +// newCovenantSignerEngine creates a covenant signer engine bound to the given +// node. When minConfirmations is zero (the Go zero-value produced by an unset +// config field), defaultMinActiveOutpointConfirmations is used. +func newCovenantSignerEngine(node *node, minConfirmations uint) covenantsigner.Engine { + if minConfirmations == 0 { + minConfirmations = defaultMinActiveOutpointConfirmations + } + + return &covenantSignerEngine{ + node: node, + minimumActiveOutpointConfirmations: minConfirmations, + } } func (cse *covenantSignerEngine) VerifySignerApproval( @@ -612,10 +628,10 @@ func (cse *covenantSignerEngine) ensureActiveOutpointFinality( if err != nil { return fmt.Errorf("cannot determine active outpoint transaction confirmations: %v", err) } - if confirmations < minimumActiveOutpointConfirmations { + if confirmations < cse.minimumActiveOutpointConfirmations { return fmt.Errorf( - "active outpoint transaction must have at least %d confirmation", - minimumActiveOutpointConfirmations, + "active outpoint transaction must have at least %d confirmations", + cse.minimumActiveOutpointConfirmations, ) } diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index 7dc9110ba2..f84875e97b 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -83,7 +83,7 @@ func TestCovenantSignerEngine_SubmitSelfV1Ready(t *testing.T) { service, err := covenantsigner.NewService( newCovenantSignerMemoryHandle(), - newCovenantSignerEngine(node), + newCovenantSignerEngine(node, 0), ) if err != nil { t.Fatal(err) @@ -149,6 +149,7 @@ func TestCovenantSignerEngine_SubmitSelfV1Ready(t *testing.T) { Locktime: 0, } bitcoinChain.transactions = append(bitcoinChain.transactions, prevTransaction) + bitcoinChain.setTransactionConfirmations(prevTransaction.Hash(), 6) activeScriptHash := sha256.Sum256(activeScriptPubKey) revealer := "0x2222222222222222222222222222222222222222" @@ -317,7 +318,7 @@ func TestCovenantSignerEngine_SubmitQcV1HandoffReady(t *testing.T) { service, err := covenantsigner.NewService( newCovenantSignerMemoryHandle(), - newCovenantSignerEngine(node), + newCovenantSignerEngine(node, 0), ) if err != nil { t.Fatal(err) @@ -387,6 +388,7 @@ func TestCovenantSignerEngine_SubmitQcV1HandoffReady(t *testing.T) { Locktime: 0, } bitcoinChain.transactions = append(bitcoinChain.transactions, prevTransaction) + bitcoinChain.setTransactionConfirmations(prevTransaction.Hash(), 6) activeScriptHash := sha256.Sum256(activeScriptPubKey) revealer := "0x4444444444444444444444444444444444444444" @@ -603,7 +605,7 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsInvalidBeta(t *testing.T) { service, err := covenantsigner.NewService( newCovenantSignerMemoryHandle(), - newCovenantSignerEngine(node), + newCovenantSignerEngine(node, 0), ) if err != nil { t.Fatal(err) @@ -719,7 +721,7 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsScriptHashMismatch(t *testing.T) service, err := covenantsigner.NewService( newCovenantSignerMemoryHandle(), - newCovenantSignerEngine(node), + newCovenantSignerEngine(node, 0), ) if err != nil { t.Fatal(err) @@ -782,6 +784,7 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsScriptHashMismatch(t *testing.T) Locktime: 0, } bitcoinChain.transactions = append(bitcoinChain.transactions, prevTransaction) + bitcoinChain.setTransactionConfirmations(prevTransaction.Hash(), 6) revealer := "0x4444444444444444444444444444444444444444" reserve := "0x1111111111111111111111111111111111111111" @@ -864,7 +867,7 @@ func TestCovenantSignerEngine_SubmitSelfV1RejectsZeroMaturityHeight(t *testing.T service, err := covenantsigner.NewService( newCovenantSignerMemoryHandle(), - newCovenantSignerEngine(node), + newCovenantSignerEngine(node, 0), ) if err != nil { t.Fatal(err) @@ -997,8 +1000,11 @@ func TestCovenantSignerEngine_EnsureActiveOutpointFinalityRejectsUnconfirmed(t * activeTransactionHash := bitcoinChain.transactions[0].Hash() bitcoinChain.setTransactionConfirmations(activeTransactionHash, 0) - err := (&covenantSignerEngine{node: node}).ensureActiveOutpointFinality(activeTransactionHash) - if err == nil || !strings.Contains(err.Error(), "active outpoint transaction must have at least 1 confirmation") { + err := (&covenantSignerEngine{ + node: node, + minimumActiveOutpointConfirmations: 6, + }).ensureActiveOutpointFinality(activeTransactionHash) + if err == nil || !strings.Contains(err.Error(), "active outpoint transaction must have at least 6 confirmations") { t.Fatalf("expected confirmation error, got %v", err) } } @@ -1396,3 +1402,137 @@ func TestCovenantSignerEngine_SubmitRejectsUnsupportedRoute(t *testing.T) { t.Fatalf("expected unsupported route detail, got %q", transition.Detail) } } + +func TestNewCovenantSignerEngine_DefaultMinConfirmations(t *testing.T) { + node, _, _ := setupCovenantSignerTestNode(t) + + engine := newCovenantSignerEngine(node, 0) + + cse, ok := engine.(*covenantSignerEngine) + if !ok { + t.Fatal("expected engine to be *covenantSignerEngine") + } + + if cse.minimumActiveOutpointConfirmations != 6 { + t.Fatalf( + "expected default minimum confirmations to be 6, got %d", + cse.minimumActiveOutpointConfirmations, + ) + } +} + +func TestNewCovenantSignerEngine_ExplicitMinConfirmations(t *testing.T) { + node, _, _ := setupCovenantSignerTestNode(t) + + engine := newCovenantSignerEngine(node, 3) + + cse, ok := engine.(*covenantSignerEngine) + if !ok { + t.Fatal("expected engine to be *covenantSignerEngine") + } + + if cse.minimumActiveOutpointConfirmations != 3 { + t.Fatalf( + "expected minimum confirmations to be 3, got %d", + cse.minimumActiveOutpointConfirmations, + ) + } +} + +func TestEnsureActiveOutpointFinality_RejectsBelowDefaultThreshold(t *testing.T) { + node, bitcoinChain, _ := setupCovenantSignerTestNode(t) + + if len(bitcoinChain.transactions) == 0 { + bitcoinChain.transactions = append(bitcoinChain.transactions, &bitcoin.Transaction{ + Version: 1, + }) + } + + activeTransactionHash := bitcoinChain.transactions[0].Hash() + bitcoinChain.setTransactionConfirmations(activeTransactionHash, 5) + + cse := &covenantSignerEngine{ + node: node, + minimumActiveOutpointConfirmations: 6, + } + + err := cse.ensureActiveOutpointFinality(activeTransactionHash) + if err == nil { + t.Fatal("expected finality error for 5 confirmations with threshold 6") + } + if !strings.Contains(err.Error(), "at least 6 confirmations") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestEnsureActiveOutpointFinality_AcceptsAtDefaultThreshold(t *testing.T) { + node, bitcoinChain, _ := setupCovenantSignerTestNode(t) + + if len(bitcoinChain.transactions) == 0 { + bitcoinChain.transactions = append(bitcoinChain.transactions, &bitcoin.Transaction{ + Version: 1, + }) + } + + activeTransactionHash := bitcoinChain.transactions[0].Hash() + bitcoinChain.setTransactionConfirmations(activeTransactionHash, 6) + + cse := &covenantSignerEngine{ + node: node, + minimumActiveOutpointConfirmations: 6, + } + + err := cse.ensureActiveOutpointFinality(activeTransactionHash) + if err != nil { + t.Fatalf("expected no error for 6 confirmations with threshold 6, got %v", err) + } +} + +func TestEnsureActiveOutpointFinality_RejectsBelowCustomThreshold(t *testing.T) { + node, bitcoinChain, _ := setupCovenantSignerTestNode(t) + + if len(bitcoinChain.transactions) == 0 { + bitcoinChain.transactions = append(bitcoinChain.transactions, &bitcoin.Transaction{ + Version: 1, + }) + } + + activeTransactionHash := bitcoinChain.transactions[0].Hash() + bitcoinChain.setTransactionConfirmations(activeTransactionHash, 2) + + cse := &covenantSignerEngine{ + node: node, + minimumActiveOutpointConfirmations: 3, + } + + err := cse.ensureActiveOutpointFinality(activeTransactionHash) + if err == nil { + t.Fatal("expected finality error for 2 confirmations with threshold 3") + } + if !strings.Contains(err.Error(), "at least 3 confirmations") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestEnsureActiveOutpointFinality_AcceptsAboveCustomThreshold(t *testing.T) { + node, bitcoinChain, _ := setupCovenantSignerTestNode(t) + + if len(bitcoinChain.transactions) == 0 { + bitcoinChain.transactions = append(bitcoinChain.transactions, &bitcoin.Transaction{ + Version: 1, + }) + } + + activeTransactionHash := bitcoinChain.transactions[0].Hash() + bitcoinChain.setTransactionConfirmations(activeTransactionHash, 10) + + cse := &covenantSignerEngine{ + node: node, + minimumActiveOutpointConfirmations: 3, + } + + err := cse.ensureActiveOutpointFinality(activeTransactionHash) + if err != nil { + t.Fatalf("expected no error for 10 confirmations with threshold 3, got %v", err) + } +} diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 65c93f841f..70a9756e98 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -84,6 +84,7 @@ func Initialize( config Config, clientInfo *clientinfo.Registry, perfMetrics *clientinfo.PerformanceMetrics, + minActiveOutpointConfirmations uint, ) (covenantsigner.Engine, error) { groupParameters := &GroupParameters{ GroupSize: 100, @@ -325,7 +326,7 @@ func Initialize( }() }) - return newCovenantSignerEngine(node), nil + return newCovenantSignerEngine(node, minActiveOutpointConfirmations), nil } // enoughPreParamsInPoolPolicy is a policy that enforces the sufficient size From 7cf34e72ca2869700bea2e894d3cb882f1770adb Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 08:54:50 -0300 Subject: [PATCH 082/143] feat(covenantsigner): add exclusive file lock to prevent concurrent store corruption Add advisory file locking (flock) to the covenant signer store to prevent multiple processes from simultaneously writing to the same data directory. - Add DataDir config field and WithDataDir service option - Acquire LOCK_EX|LOCK_NB on startup; fail fast if another process holds it - Add Store.Close() and Service.Close() for lock release on shutdown - Reorder NewStore creation to run after service options are applied - Skip locking for in-memory handles (empty dataDir) --- pkg/covenantsigner/config.go | 4 + pkg/covenantsigner/server.go | 2 + pkg/covenantsigner/service.go | 32 ++++-- pkg/covenantsigner/store.go | 77 +++++++++++++- pkg/covenantsigner/store_lock_test.go | 148 ++++++++++++++++++++++++++ pkg/covenantsigner/store_test.go | 12 +-- 6 files changed, 262 insertions(+), 13 deletions(-) create mode 100644 pkg/covenantsigner/store_lock_test.go diff --git a/pkg/covenantsigner/config.go b/pkg/covenantsigner/config.go index a832e46cd1..16ede9a4f9 100644 --- a/pkg/covenantsigner/config.go +++ b/pkg/covenantsigner/config.go @@ -35,4 +35,8 @@ type Config struct { // covenant signer accepts it. When zero (unset), the system defaults to 6 // to align with the deposit sweep finality threshold. MinActiveOutpointConfirmations uint `mapstructure:"minActiveOutpointConfirmations"` + // DataDir is the base directory path used by the disk persistence handle. + // When set, the store acquires an exclusive file lock to prevent concurrent + // process corruption. When empty, file locking is skipped. + DataDir string `mapstructure:"dataDir"` } diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index ca7200a0a6..3dda269d65 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -55,6 +55,7 @@ func Initialize( service, err := NewService( handle, engine, + WithDataDir(config.DataDir), WithMigrationPlanQuoteTrustRoots(config.MigrationPlanQuoteTrustRoots), WithDepositorTrustRoots(config.DepositorTrustRoots), WithCustodianTrustRoots(config.CustodianTrustRoots), @@ -128,6 +129,7 @@ func Initialize( defer cancelShutdown() _ = server.httpServer.Shutdown(shutdownCtx) + _ = server.service.Close() }() go func() { diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index dbe53d2e3c..3d112f9e3f 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -18,6 +18,7 @@ type Service struct { signerApprovalVerifier SignerApprovalVerifier now func() time.Time mutex sync.Mutex + dataDir string migrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot depositorTrustRoots []DepositorTrustRoot custodianTrustRoots []CustodianTrustRoot @@ -63,6 +64,15 @@ func WithSignerApprovalVerifier( } } +// WithDataDir sets the data directory path for file-level locking. When +// provided, the store acquires an exclusive advisory lock to prevent +// concurrent process corruption. When empty, file locking is skipped. +func WithDataDir(dataDir string) ServiceOption { + return func(service *Service) { + service.dataDir = dataDir + } +} + func NewService( handle persistence.BasicHandle, engine Engine, @@ -72,13 +82,7 @@ func NewService( engine = NewPassiveEngine() } - store, err := NewStore(handle) - if err != nil { - return nil, err - } - service := &Service{ - store: store, engine: engine, now: func() time.Time { return time.Now().UTC() }, } @@ -89,6 +93,12 @@ func NewService( option(service) } + store, err := NewStore(handle, service.dataDir) + if err != nil { + return nil, err + } + service.store = store + normalizedDepositorTrustRoots, err := normalizeDepositorTrustRoots( service.depositorTrustRoots, ) @@ -409,3 +419,13 @@ func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollIn return mapJobResult(currentJob), nil } + +// Close releases the resources held by the service, including the store's +// exclusive file lock when one was acquired. +func (s *Service) Close() error { + if s.store != nil { + return s.store.Close() + } + + return nil +} diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index 8662554a44..fa8e74db29 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -3,35 +3,110 @@ package covenantsigner import ( "encoding/json" "fmt" + "os" + "path/filepath" "sync" + "syscall" "time" "github.com/keep-network/keep-common/pkg/persistence" ) const jobsDirectory = "covenant-signer/jobs" +const lockFileName = ".lock" type Store struct { handle persistence.BasicHandle mutex sync.Mutex + lockFile *os.File byRequestID map[string]*Job byRouteKey map[string]string } -func NewStore(handle persistence.BasicHandle) (*Store, error) { +// NewStore creates a new Store backed by the given persistence handle. When +// dataDir is non-empty, an exclusive advisory file lock is acquired on a lock +// file inside the jobs directory to prevent concurrent process corruption. If +// the lock cannot be acquired (another process holds it), NewStore returns an +// error. When dataDir is empty (in-memory handles), file locking is skipped. +func NewStore(handle persistence.BasicHandle, dataDir string) (*Store, error) { store := &Store{ handle: handle, byRequestID: make(map[string]*Job), byRouteKey: make(map[string]string), } + if dataDir != "" { + lockFile, err := acquireFileLock(dataDir) + if err != nil { + return nil, err + } + store.lockFile = lockFile + } + if err := store.load(); err != nil { + // Release the lock if loading fails after successful acquisition. + store.Close() return nil, err } return store, nil } +// acquireFileLock creates and acquires an exclusive non-blocking advisory lock +// on a lock file inside the jobs directory. The returned file handle must be +// kept open for the lifetime of the lock; closing it releases the lock. +func acquireFileLock(dataDir string) (*os.File, error) { + lockPath := filepath.Join(dataDir, jobsDirectory, lockFileName) + + if err := os.MkdirAll(filepath.Dir(lockPath), 0700); err != nil { + return nil, fmt.Errorf( + "cannot create lock directory [%s]: %w", + filepath.Dir(lockPath), + err, + ) + } + + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, fmt.Errorf( + "cannot open lock file [%s]: %w", + lockPath, + err, + ) + } + + if err := syscall.Flock( + int(lockFile.Fd()), + syscall.LOCK_EX|syscall.LOCK_NB, + ); err != nil { + lockFile.Close() + return nil, fmt.Errorf( + "cannot acquire exclusive lock on [%s]: "+ + "another process may already own the store: %w", + lockPath, + err, + ) + } + + return lockFile, nil +} + +// Close releases the exclusive file lock and closes the underlying lock file +// descriptor. For stores created without a dataDir (in-memory handles), Close +// is a safe no-op. Close is idempotent. +func (s *Store) Close() error { + if s.lockFile == nil { + return nil + } + + // Release the advisory lock before closing the file descriptor. + _ = syscall.Flock(int(s.lockFile.Fd()), syscall.LOCK_UN) + err := s.lockFile.Close() + s.lockFile = nil + + return err +} + func routeKey(route TemplateID, routeRequestID string) string { return fmt.Sprintf("%s:%s", route, routeRequestID) } diff --git a/pkg/covenantsigner/store_lock_test.go b/pkg/covenantsigner/store_lock_test.go new file mode 100644 index 0000000000..b763352c8b --- /dev/null +++ b/pkg/covenantsigner/store_lock_test.go @@ -0,0 +1,148 @@ +package covenantsigner + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/keep-network/keep-common/pkg/persistence" +) + +func TestNewStore_AcquiresFileLock(t *testing.T) { + tempDir := t.TempDir() + + handle, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatal(err) + } + + store, err := NewStore(handle, tempDir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + + lockPath := filepath.Join(tempDir, jobsDirectory, lockFileName) + if _, err := os.Stat(lockPath); os.IsNotExist(err) { + t.Fatalf("expected lock file to exist at %s", lockPath) + } +} + +func TestNewStore_LockContention_ReturnsError(t *testing.T) { + tempDir := t.TempDir() + + handle1, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatal(err) + } + + store1, err := NewStore(handle1, tempDir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store1.Close() }) + + handle2, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatal(err) + } + + store2, err := NewStore(handle2, tempDir) + if store2 != nil { + store2.Close() + t.Fatal("expected second store to fail, but it succeeded") + } + if err == nil { + t.Fatal("expected error when acquiring lock on already-locked directory") + } + if !strings.Contains(err.Error(), "lock") { + t.Fatalf("expected error message to contain 'lock', got: %v", err) + } +} + +func TestStore_Close_ReleasesLock(t *testing.T) { + tempDir := t.TempDir() + + handle1, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatal(err) + } + + store1, err := NewStore(handle1, tempDir) + if err != nil { + t.Fatal(err) + } + + if err := store1.Close(); err != nil { + t.Fatalf("failed to close first store: %v", err) + } + + handle2, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatal(err) + } + + store2, err := NewStore(handle2, tempDir) + if err != nil { + t.Fatalf("expected second store to succeed after first was closed, got: %v", err) + } + t.Cleanup(func() { store2.Close() }) +} + +func TestStore_Close_Idempotent(t *testing.T) { + tempDir := t.TempDir() + + handle, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatal(err) + } + + store, err := NewStore(handle, tempDir) + if err != nil { + t.Fatal(err) + } + + if err := store.Close(); err != nil { + t.Fatalf("first close failed: %v", err) + } + + // Second close should not panic or return an error. + if err := store.Close(); err != nil { + t.Fatalf("second close should be a no-op, got: %v", err) + } +} + +func TestNewStore_InMemoryHandle_SkipsLock(t *testing.T) { + handle := newMemoryHandle() + + store, err := NewStore(handle, "") + if err != nil { + t.Fatal(err) + } + + // Close on a store without file locking should be a safe no-op. + if err := store.Close(); err != nil { + t.Fatalf("close on in-memory store should be a no-op, got: %v", err) + } +} + +func TestNewStore_SequentialOpenCloseOpen(t *testing.T) { + tempDir := t.TempDir() + + for i := 0; i < 3; i++ { + handle, err := persistence.NewBasicDiskHandle(tempDir) + if err != nil { + t.Fatalf("iteration %d: failed to create handle: %v", i, err) + } + + store, err := NewStore(handle, tempDir) + if err != nil { + t.Fatalf("iteration %d: failed to open store: %v", i, err) + } + + if err := store.Close(); err != nil { + t.Fatalf("iteration %d: failed to close store: %v", i, err) + } + } +} diff --git a/pkg/covenantsigner/store_test.go b/pkg/covenantsigner/store_test.go index 8bfd509159..9f6dc3cbad 100644 --- a/pkg/covenantsigner/store_test.go +++ b/pkg/covenantsigner/store_test.go @@ -10,7 +10,7 @@ import ( func TestStoreReloadPreservesJobs(t *testing.T) { handle := newMemoryHandle() - store, err := NewStore(handle) + store, err := NewStore(handle, "") if err != nil { t.Fatal(err) } @@ -33,7 +33,7 @@ func TestStoreReloadPreservesJobs(t *testing.T) { t.Fatal(err) } - reloaded, err := NewStore(handle) + reloaded, err := NewStore(handle, "") if err != nil { t.Fatal(err) } @@ -54,7 +54,7 @@ func TestStorePutReturnsErrorWhenSaveFails(t *testing.T) { handle := newFaultingMemoryHandle() handle.saveErrByName["kcs_self_fail_save.json"] = errors.New("injected save failure") - store, err := NewStore(handle) + store, err := NewStore(handle, "") if err != nil { t.Fatal(err) } @@ -87,7 +87,7 @@ func TestStorePutReturnsErrorWhenSaveFails(t *testing.T) { func TestStorePutKeepsNewRouteMappingWhenOldDeleteFails(t *testing.T) { handle := newFaultingMemoryHandle() - store, err := NewStore(handle) + store, err := NewStore(handle, "") if err != nil { t.Fatal(err) } @@ -188,7 +188,7 @@ func TestStoreLoadSelectsNewestJobForDuplicateRouteKeys(t *testing.T) { t.Fatal(err) } - store, err := NewStore(handle) + store, err := NewStore(handle, "") if err != nil { t.Fatal(err) } @@ -251,7 +251,7 @@ func TestStoreLoadFailsOnInvalidUpdatedAtForDuplicateRouteKeys(t *testing.T) { t.Fatal(err) } - _, err = NewStore(handle) + _, err = NewStore(handle, "") if err == nil { t.Fatal("expected invalid UpdatedAt error") } From 75e49dfd2390af11372ba2d81f8b3aecfb173709 Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 08:55:00 -0300 Subject: [PATCH 083/143] fix(covenantsigner): add domain separation to request digest Prepend "covenant-signer-request-v1:" domain prefix to the SHA256 input in requestDigestFromNormalized to prevent cross-context hash collisions with other SHA256-based identifiers in the protocol. - Add covenantSignerRequestDigestDomain constant - Update requestDigestFromNormalized to use domain-prefixed hashing - Update test vectors to match new domain-separated digests - Add TestRequestDigestUsesDomainSeparation verifying prefix effect --- pkg/covenantsigner/covenantsigner_test.go | 48 +++++++++++++++++++ ...covenant_recovery_approval_vectors_v1.json | 6 +-- pkg/covenantsigner/validation.go | 7 ++- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 054fabfca4..78c6556dc3 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -5,6 +5,7 @@ import ( "context" "crypto/ecdsa" "crypto/ed25519" + "crypto/sha256" "crypto/elliptic" "crypto/rand" "crypto/x509" @@ -3137,6 +3138,53 @@ func TestRequestDigestDistinguishesSelfV1PresignFromReconstruct(t *testing.T) { } } +func TestRequestDigestUsesDomainSeparation(t *testing.T) { + request := canonicalArtifactApprovalRequest(TemplateQcV1) + + normalizedRequest, err := normalizeRouteSubmitRequest(request, validationOptions{}) + if err != nil { + t.Fatal(err) + } + + payload, err := canonicaljson.Marshal(normalizedRequest) + if err != nil { + t.Fatal(err) + } + + // Manually compute the domain-prefixed digest using the expected domain + // separator constant value. This verifies that the function prepends + // the domain before hashing, preventing cross-context hash collisions. + domainPrefix := "covenant-signer-request-v1:" + prefixedInput := append([]byte(domainPrefix), payload...) + expectedSum := sha256.Sum256(prefixedInput) + expectedDigest := "0x" + hex.EncodeToString(expectedSum[:]) + + // Compute the unprefixed digest to prove domain prefix has effect. + unprefixedSum := sha256.Sum256(payload) + unprefixedDigest := "0x" + hex.EncodeToString(unprefixedSum[:]) + + if expectedDigest == unprefixedDigest { + t.Fatal("domain-prefixed and unprefixed digests should differ") + } + + actualDigest, err := requestDigestFromNormalized(normalizedRequest) + if err != nil { + t.Fatal(err) + } + + if actualDigest != expectedDigest { + t.Fatalf( + "expected domain-prefixed digest %s, got %s", + expectedDigest, + actualDigest, + ) + } + + if actualDigest == unprefixedDigest { + t.Fatal("requestDigestFromNormalized should not produce unprefixed digest") + } +} + func TestServiceRejectsQcV1PresignRequestType(t *testing.T) { service, err := NewService(newMemoryHandle(), &scriptedEngine{}) if err != nil { diff --git a/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json index 393d97ca52..db61ffba15 100644 --- a/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json +++ b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json @@ -92,7 +92,7 @@ "custodianRequired": true } }, - "expectedRequestDigest": "0x5cdfdc1861efd8ed59b0ee9b3b2a8583fc787321900fd36f4198db311a22fbcc" + "expectedRequestDigest": "0x8538a608ffbc3264655f9d87e334bfb7fa0d46e37cb6ffdb7c98b803eec900c8" }, "self_v1": { "expectedApprovalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", @@ -177,7 +177,7 @@ "custodianRequired": false } }, - "expectedRequestDigest": "0x238153ab33ce630fe44c59da2a42ef3a0eeb106df86c59c893c0047648589e05" + "expectedRequestDigest": "0x2da9d108af3d175865ee0654843a2c61eaf7fcbcf5d48afd807044725f310d17" }, "self_v1_presign": { "expectedApprovalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", @@ -262,7 +262,7 @@ "custodianRequired": false } }, - "expectedRequestDigest": "0xb44ea2821d1734a8af7a71cb9cf70712f989ac11404222a5315d0db15b248de1" + "expectedRequestDigest": "0x4399f8651fea31ab227bffd3db96daa969612e9e5195394df3f349af4713cf03" } } } diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 8cbd366971..11e1c653e7 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -38,6 +38,7 @@ const ( migrationPlanQuoteSignatureAlgorithm = "ed25519" migrationPlanQuoteSigningDomain = "migration-plan-quote-v1:" signerApprovalSignatureAlgorithm = "tecdsa-secp256k1" + covenantSignerRequestDigestDomain = "covenant-signer-request-v1:" ) var artifactApprovalTypeHash = crypto.Keccak256Hash([]byte( @@ -101,13 +102,17 @@ func requestDigest( return requestDigestFromNormalized(normalizedRequest) } +// requestDigestFromNormalized computes a domain-separated SHA256 digest of +// the canonical JSON encoding of the already-normalized request. The domain +// prefix prevents cross-context hash collisions with other SHA256-based +// identifiers in the protocol. func requestDigestFromNormalized(request RouteSubmitRequest) (string, error) { payload, err := canonicaljson.Marshal(request) if err != nil { return "", err } - sum := sha256.Sum256(payload) + sum := sha256.Sum256(append([]byte(covenantSignerRequestDigestDomain), payload...)) return "0x" + hex.EncodeToString(sum[:]), nil } From a01e5fbbf631b239cfadf4d87a66a628dd49cd0f Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 08:55:10 -0300 Subject: [PATCH 084/143] fix(covenantsigner): detach submit context from HTTP lifecycle Use context.WithoutCancel in submitHandler so threshold signing survives HTTP write-timeout expiration and client disconnects. - Detach context passed to service.Submit from r.Context() cancellation - Add tests verifying context survives cancellation, pre-cancelled contexts still succeed, and context values are preserved --- pkg/covenantsigner/server.go | 4 +- pkg/covenantsigner/server_test.go | 253 ++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+), 1 deletion(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 3dda269d65..4c79dfee64 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -335,7 +335,9 @@ func submitHandler(service *Service, route TemplateID) http.HandlerFunc { return } - result, err := service.Submit(r.Context(), route, input) + // Detach from the HTTP request lifetime so that threshold signing + // survives write-timeout and client disconnects. + result, err := service.Submit(context.WithoutCancel(r.Context()), route, input) if err != nil { handleError(w, err) return diff --git a/pkg/covenantsigner/server_test.go b/pkg/covenantsigner/server_test.go index 8f6eb18c90..97e210691c 100644 --- a/pkg/covenantsigner/server_test.go +++ b/pkg/covenantsigner/server_test.go @@ -10,7 +10,9 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" + "time" ) type scriptedVerifierEngine struct { @@ -679,3 +681,254 @@ func TestServerBoundaryErrorMatrix(t *testing.T) { }) } } + +// contextCapturingEngine is a test engine that passes the context through +// to its submit function, unlike scriptedEngine which drops it. This allows +// tests to verify context propagation behavior in the submit handler. +type contextCapturingEngine struct { + submit func(ctx context.Context, job *Job) (*Transition, error) +} + +func (cce *contextCapturingEngine) OnSubmit(ctx context.Context, job *Job) (*Transition, error) { + if cce.submit == nil { + return nil, nil + } + return cce.submit(ctx, job) +} + +func (cce *contextCapturingEngine) OnPoll(context.Context, *Job) (*Transition, error) { + return nil, nil +} + +func TestSubmitHandlerDetachesContextFromHTTPLifecycle(t *testing.T) { + // Channels for synchronizing the engine mock with the test goroutine. + engineStarted := make(chan struct{}) + proceedCh := make(chan struct{}) + + var capturedCtxErr error + var mu sync.Mutex + + handle := newMemoryHandle() + engine := &contextCapturingEngine{ + submit: func(ctx context.Context, job *Job) (*Transition, error) { + // Signal that OnSubmit has started executing. + close(engineStarted) + // Wait for the test to cancel the HTTP request context. + <-proceedCh + // Capture whether the context received by OnSubmit was cancelled. + mu.Lock() + capturedCtxErr = ctx.Err() + mu.Unlock() + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + } + + service, err := NewService(handle, engine) + if err != nil { + t.Fatal(err) + } + + handler := newHandler(service, "", true) + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_detach_cancel", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + // Use a cancellable context for the HTTP request to simulate the HTTP + // write timeout or client disconnect that cancels r.Context(). + reqCtx, reqCancel := context.WithCancel(context.Background()) + defer reqCancel() + + req, err := http.NewRequestWithContext( + reqCtx, + http.MethodPost, + "/v1/self_v1/signer/requests", + bytes.NewReader(submitPayload), + ) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + + recorder := httptest.NewRecorder() + + // Serve the request in a goroutine because the engine will block inside + // OnSubmit until we signal proceedCh. + serveDone := make(chan struct{}) + go func() { + handler.ServeHTTP(recorder, req) + close(serveDone) + }() + + // Wait for the engine to start processing the submit. + select { + case <-engineStarted: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for engine to start") + } + + // Cancel the request context, simulating a client disconnect or HTTP + // write timeout expiring while signing is in progress. + reqCancel() + + // Allow the engine to proceed and check the context it received. + close(proceedCh) + + // Wait for the handler to finish. + select { + case <-serveDone: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for handler to finish") + } + + // The critical assertion: the context received by OnSubmit must NOT have + // been cancelled even though the HTTP request context was. + mu.Lock() + defer mu.Unlock() + if capturedCtxErr != nil { + t.Fatalf( + "expected OnSubmit context to be non-cancelled after HTTP "+ + "request cancellation, but got: %v", + capturedCtxErr, + ) + } +} + +func TestSubmitHandlerPreCancelledContextStillSucceeds(t *testing.T) { + var capturedCtxErr error + var mu sync.Mutex + + handle := newMemoryHandle() + engine := &contextCapturingEngine{ + submit: func(ctx context.Context, job *Job) (*Transition, error) { + mu.Lock() + capturedCtxErr = ctx.Err() + mu.Unlock() + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + } + + service, err := NewService(handle, engine) + if err != nil { + t.Fatal(err) + } + + // Test through the handler directly using httptest.ResponseRecorder + // because an HTTP client would fail to send a request with a + // pre-cancelled context. + handler := newHandler(service, "", true) + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_detach_precancel", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + // Create a pre-cancelled context. + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + req, err := http.NewRequestWithContext( + cancelledCtx, + http.MethodPost, + "/v1/self_v1/signer/requests", + bytes.NewReader(submitPayload), + ) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + + // The handler should still succeed because the context passed to + // service.Submit is detached from the HTTP request context. + if recorder.Code != http.StatusOK { + t.Fatalf( + "expected 200 OK with pre-cancelled context, got %d: %s", + recorder.Code, + recorder.Body.String(), + ) + } + + mu.Lock() + defer mu.Unlock() + if capturedCtxErr != nil { + t.Fatalf( + "expected OnSubmit context to be non-cancelled with pre-cancelled "+ + "HTTP context, but got: %v", + capturedCtxErr, + ) + } +} + +type contextKey string + +func TestSubmitHandlerPreservesContextValues(t *testing.T) { + const testKey contextKey = "test-trace-id" + const testValue = "trace-abc-123" + + var capturedValue any + var mu sync.Mutex + + handle := newMemoryHandle() + engine := &contextCapturingEngine{ + submit: func(ctx context.Context, job *Job) (*Transition, error) { + mu.Lock() + capturedValue = ctx.Value(testKey) + mu.Unlock() + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + } + + service, err := NewService(handle, engine) + if err != nil { + t.Fatal(err) + } + + // Wrap the handler with middleware that injects a value into the request + // context. The detached context should preserve this value. + innerHandler := newHandler(service, "", true) + wrappedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + enrichedCtx := context.WithValue(r.Context(), testKey, testValue) + innerHandler.ServeHTTP(w, r.WithContext(enrichedCtx)) + }) + + server := httptest.NewServer(wrappedHandler) + defer server.Close() + + submitPayload := mustJSON(t, SignerSubmitInput{ + RouteRequestID: "ors_detach_values", + Stage: StageSignerCoordination, + Request: baseRequest(TemplateSelfV1), + }) + + response, err := http.Post( + server.URL+"/v1/self_v1/signer/requests", + "application/json", + bytes.NewReader(submitPayload), + ) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected submit status: %d %s", response.StatusCode, string(body)) + } + + mu.Lock() + defer mu.Unlock() + if capturedValue != testValue { + t.Fatalf( + "expected context value %q to be preserved through detachment, "+ + "got %v", + testValue, + capturedValue, + ) + } +} From 38d68ec58865ffbbda31d54b58273c8c02c7863a Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 08:55:16 -0300 Subject: [PATCH 085/143] test(canonicaljson): expand Marshal tests with struct input, determinism, and clearer assertions Replace basic Marshal tests with comprehensive coverage: map input, struct input, trailing newline check, HTML non-escaping, and deterministic key ordering with repeated-call idempotency verification. --- pkg/internal/canonicaljson/marshal_test.go | 115 ++++++++++++++++----- 1 file changed, 89 insertions(+), 26 deletions(-) diff --git a/pkg/internal/canonicaljson/marshal_test.go b/pkg/internal/canonicaljson/marshal_test.go index cee602c2ed..2529619f12 100644 --- a/pkg/internal/canonicaljson/marshal_test.go +++ b/pkg/internal/canonicaljson/marshal_test.go @@ -1,28 +1,65 @@ package canonicaljson import ( + "bytes" "strings" "testing" ) -// Verify Marshal function exists with correct signature and produces -// deterministic JSON without trailing newlines or HTML escaping. +// testPayload mirrors the signerApprovalCertificateSignerSetPayload struct +// pattern used in production code, with string and int fields using camelCase +// JSON tags. +type testPayload struct { + WalletID string `json:"walletId"` + Threshold int `json:"threshold"` +} -func TestMarshalProducesValidJSON(t *testing.T) { - input := map[string]string{"key": "value"} +func TestMarshal_MapInput(t *testing.T) { + input := map[string]any{ + "name": "test", + "active": true, + "count": 3, + } result, err := Marshal(input) if err != nil { t.Fatalf("unexpected error: %v", err) } - expected := `{"key":"value"}` + // Keys must be alphabetically sorted by json.Encoder. + expected := `{"active":true,"count":3,"name":"test"}` if string(result) != expected { - t.Fatalf("expected %s, got %s", expected, string(result)) + t.Fatalf( + "unexpected result\nexpected: %s\nactual: %s", + expected, + string(result), + ) } } -func TestMarshalNoTrailingNewline(t *testing.T) { +func TestMarshal_StructInput(t *testing.T) { + input := testPayload{ + WalletID: "0xabc", + Threshold: 51, + } + + result, err := Marshal(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // JSON tags determine field names; struct declaration order is preserved. + expected := `{"walletId":"0xabc","threshold":51}` + if string(result) != expected { + t.Fatalf( + "unexpected result\nexpected: %s\nactual: %s", + expected, + string(result), + ) + } +} + +func TestMarshal_NoTrailingNewline(t *testing.T) { input := map[string]int{"count": 42} result, err := Marshal(input) @@ -30,43 +67,69 @@ func TestMarshalNoTrailingNewline(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if len(result) > 0 && result[len(result)-1] == '\n' { - t.Fatal("output should not end with a newline") + if bytes.HasSuffix(result, []byte("\n")) { + t.Fatalf("output ends with trailing newline: %q", result) } } -func TestMarshalDoesNotEscapeHTML(t *testing.T) { - input := map[string]string{"url": "https://example.com?a=1&b=2"} +func TestMarshal_HTMLNotEscaped(t *testing.T) { + input := map[string]string{"html": "bold & fun > safe"} result, err := Marshal(input) if err != nil { t.Fatalf("unexpected error: %v", err) } - resultStr := string(result) + output := string(result) - // Verify raw HTML characters are preserved, not escaped - if strings.Contains(resultStr, `\u003c`) || strings.Contains(resultStr, `\u003e`) || strings.Contains(resultStr, `\u0026`) { - t.Fatalf("HTML characters should not be escaped, got %s", resultStr) + // Verify Unicode escape sequences are absent. + for _, escaped := range []string{`\u003c`, `\u003e`, `\u0026`} { + if strings.Contains(output, escaped) { + t.Fatalf("found unwanted escape %s in output: %s", escaped, output) + } } - if !strings.Contains(resultStr, "&") || !strings.Contains(resultStr, "<") || !strings.Contains(resultStr, ">") { - t.Fatalf("expected raw HTML characters in output, got %s", resultStr) + + // Verify raw HTML characters are present. + for _, raw := range []string{"<", ">", "&"} { + if !strings.Contains(output, raw) { + t.Fatalf("missing raw character %q in output: %s", raw, output) + } } } -func TestMarshalUsesTrimSuffixNotTrimSpace(t *testing.T) { - // Verify the function uses TrimSuffix (removes only trailing \n) - // not TrimSpace (which would remove all whitespace). - // A value with leading space in a string field should be preserved. - input := map[string]string{"data": " leading-space"} +func TestMarshal_DeterministicMapOrder(t *testing.T) { + input := map[string]string{ + "zebra": "last", + "alpha": "first", + "middle": "center", + } - result, err := Marshal(input) + first, err := Marshal(input) if err != nil { t.Fatalf("unexpected error: %v", err) } - expected := `{"data":" leading-space"}` - if string(result) != expected { - t.Fatalf("expected %s, got %s", expected, string(result)) + // Verify alphabetical key ordering. + expected := `{"alpha":"first","middle":"center","zebra":"last"}` + if string(first) != expected { + t.Fatalf( + "unexpected key order\nexpected: %s\nactual: %s", + expected, + string(first), + ) + } + + // Verify repeated calls produce byte-identical output. + for i := 0; i < 10; i++ { + result, err := Marshal(input) + if err != nil { + t.Fatalf("iteration %d: unexpected error: %v", i, err) + } + if !bytes.Equal(first, result) { + t.Fatalf( + "iteration %d: output differs\nexpected: %s\nactual: %s", + i, first, result, + ) + } } } From ba65f799d4d45b7219383b4df08e92960a5ef2ff Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 08:55:18 -0300 Subject: [PATCH 086/143] test(tbtc): add deterministic key ordering test for QcV1 signer handoff payload hash Verify computeQcV1SignerHandoffPayloadHash produces a stable pinned hash and that json.Marshal preserves alphabetical key ordering for map inputs. --- pkg/tbtc/covenant_signer_test.go | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index f84875e97b..c7794f98ca 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -1536,3 +1536,65 @@ func TestEnsureActiveOutpointFinality_AcceptsAboveCustomThreshold(t *testing.T) t.Fatalf("expected no error for 10 confirmations with threshold 3, got %v", err) } } + +func TestComputeQcV1SignerHandoffPayloadHash_DeterministicKeyOrdering(t *testing.T) { + payload := map[string]any{ + "kind": qcV1SignerHandoffKind, + "unsignedTransactionHex": "0xdeadbeef", + "witnessScript": "0xcafebabe", + "signerSignature": "0x0102030405", + "selectorWitnessItems": []string{"0x01", "0x"}, + "requiresDummy": true, + "sighashType": uint32(1), + "destinationCommitmentHash": "0xabcdef1234567890", + } + + // Verify the hash matches a pinned expected value. If this test + // breaks, it means the serialization or hashing behavior changed + // and downstream consumers relying on content-addressed bundle + // IDs will be affected. + expectedHash := "0x2785f99f276b0d56710fcdd76fa22cb7081018b847b7b8b9ba85ecd8e4c0189c" + + hash, err := computeQcV1SignerHandoffPayloadHash(payload) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if hash != expectedHash { + t.Fatalf("expected hash %s, got %s", expectedHash, hash) + } + + // Verify idempotency: calling the function twice with the same + // map must produce the same hash. + hash2, err := computeQcV1SignerHandoffPayloadHash(payload) + if err != nil { + t.Fatalf("expected nil error on second call, got %v", err) + } + if hash != hash2 { + t.Fatalf("expected idempotent hash, got %s and %s", hash, hash2) + } + + // Verify json.Marshal produces alphabetically ordered keys. + // This is the Go encoding/json guarantee (since Go 1.12) that + // the payload hash computation depends on. + rawJSON, err := json.Marshal(payload) + if err != nil { + t.Fatalf("expected nil error from json.Marshal, got %v", err) + } + expectedJSON := `{` + + `"destinationCommitmentHash":"0xabcdef1234567890",` + + `"kind":"qc_v1_signer_handoff_v1",` + + `"requiresDummy":true,` + + `"selectorWitnessItems":["0x01","0x"],` + + `"sighashType":1,` + + `"signerSignature":"0x0102030405",` + + `"unsignedTransactionHex":"0xdeadbeef",` + + `"witnessScript":"0xcafebabe"` + + `}` + if string(rawJSON) != expectedJSON { + t.Fatalf( + "expected alphabetically ordered JSON:\n%s\ngot:\n%s", + expectedJSON, + rawJSON, + ) + } +} From a9e720055ec85c8c222bfa3185bbad8d3127bfae Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 08:55:20 -0300 Subject: [PATCH 087/143] docs(covenantsigner): add deployment topology and coordination guide Document single-node-per-wallet deployment model, load balancer sticky session requirements, node-local request deduplication scope, P2P signing session convergence behavior, and wallet ownership guard limitations. --- pkg/covenantsigner/DEPLOYMENT.md | 215 +++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 pkg/covenantsigner/DEPLOYMENT.md diff --git a/pkg/covenantsigner/DEPLOYMENT.md b/pkg/covenantsigner/DEPLOYMENT.md new file mode 100644 index 0000000000..58c84dcba8 --- /dev/null +++ b/pkg/covenantsigner/DEPLOYMENT.md @@ -0,0 +1,215 @@ +# Covenant Signer Deployment Topology + +This document describes deployment topology constraints, coordination +mechanisms, and operational considerations for the covenant signer subsystem. +Source file references use `file:function` or `file:line` notation relative +to `pkg/covenantsigner/` and `pkg/tbtc/` within the repository root. + +## 1. Expected Deployment Topology + +The covenant signer is designed around a **single-node-per-wallet** deployment +model. Each covenant signer node controls the signing key shares for exactly +one wallet through its local `walletRegistry`. + +When a signing request arrives, the engine calls `node.go:getSigningExecutor` +(line 319) to resolve the signing executor from the node's local wallet +registry. This function checks `walletRegistry.getSigners(walletPublicKey)` +(line 340) and returns `(nil, false, nil)` when the node holds no signer +shares for the requested wallet (lines 341-344), causing the engine to +reject the request without error (see Section 5 for details). + +Each node runs its own HTTP server via `server.go:Initialize` (line 30), +binding to a configurable address and port (`server.go`, line 107). The +server maintains its own request store, authentication state, and signing +executor cache. No state is shared between nodes. + +Multi-node deployments are possible when multiple nodes hold signer shares +for the same wallet, which is inherent to the threshold signing architecture. +However, this topology introduces coordination challenges documented in the +following sections. + +## 2. Load Balancer Requirements + +If multiple covenant signer nodes serve the same wallet and are placed behind +a single base URL, the load balancer **must use sticky sessions or +single-target routing**. + +**Why this is required:** + +Request deduplication is node-local (see Section 3 for full details). If a +load balancer distributes requests with the same `routeRequestID` across +different nodes, each node independently creates a new signing job for that +request, producing duplicate signing sessions for the same covenant +operation. + +The Submit idempotency mechanism in `service.go:Submit` (line 254) checks +`store.GetByRouteRequest(route, routeRequestID)` to detect duplicate +requests. This lookup hits an in-memory map local to the process. A second +node has no visibility into the first node's store. + +**Timeout considerations:** + +The HTTP server is configured with a 30-second write timeout +(`server.go`, line 111). Load balancer health check intervals and upstream +timeout settings should account for this value to avoid premature connection +termination during signing operations. + +**Authentication:** + +Bearer token authentication (`server.go:withBearerAuth`, line 264) is +enforced for all non-loopback listen addresses. When running multiple nodes +behind the same load balancer endpoint, the `authToken` configuration must +be identical across all nodes. + +## 3. Request Deduplication Scope + +Request deduplication is **node-local only**. It prevents the same node from +creating multiple jobs for the same `routeRequestID`, but provides no +cross-node protection. + +### Deduplication components + +The deduplication logic in `service.go:Submit` (lines 253-266) relies on +three mechanisms: + +1. **`Service.mutex`** (`service.go`, line 20): A `sync.Mutex` that + serializes the check-and-insert critical section within `Submit()`. This + is an in-process lock with no distributed coordination. + +2. **`store.GetByRouteRequest()`** (`store.go`, line 152): Looks up existing + jobs by `route + routeRequestID` in the `Store.byRouteKey` in-memory map + (`store.go`, lines 17-18). + +3. **`requestDigest` comparison** (`service.go`, line 258): Verifies payload + consistency when a matching `routeRequestID` is found. + +### Deduplication flow in `Submit()` + +1. Acquire `s.mutex.Lock()` (line 253). +2. Call `s.store.GetByRouteRequest(route, input.RouteRequestID)` (line 254). +3. If found and digest matches: return the existing result idempotently + (lines 264-265). +4. If found and digest differs: return an `inputError` indicating payload + mismatch (lines 258-262). +5. If not found: create a new job, persist via `store.Put()` (line 301), + then release the lock (line 305). + +### Cross-node limitations + +- The `sync.Mutex` is an in-process lock. Separate processes, even on the + same host, maintain independent locks. +- The `Store` maps (`byRequestID`, `byRouteKey`) are in-memory per-process + (`store.go`, lines 17-18). +- File persistence uses `persistence.BasicHandle`, which writes JSON files + under `covenant-signer/jobs/` on the local filesystem with no cross-node + synchronization. + +**Consequence:** Multiple nodes behind a load balancer can produce duplicate +signing sessions for the same `routeRequestID` when requests are routed to +different nodes. This can trigger the P2P broadcast channel conflicts +described in Section 4. + +## 4. P2P Signing Session Convergence + +When a covenant signing request is accepted, the engine initiates a threshold +signing session over a P2P broadcast channel shared by all group members. +This section describes the signing flow and its behavior when multiple nodes +attempt concurrent signing for the same wallet. + +### Signing flow + +1. `covenantSignerEngine.submitSelfV1` / `submitQcV1` + (`covenant_signer.go`, lines 206 / 272) obtain a `signingExecutor` and + call `signBatch()`. + +2. `signingExecutor.signBatch()` (`signing.go`, line 104) processes messages + sequentially, calling `sign()` for each message in the batch. + +3. `sign()` (`signing.go`, line 186) acquires a `semaphore.Weighted(1)` lock + via `TryAcquire(1)` (line 191). This prevents concurrent signing for the + same wallet on the same node. If the lock is not available, the call + returns `errSigningExecutorBusy`. + +4. Each signer controlled by the node runs a goroutine (`signing.go`, + lines 238-403) that enters a retry loop with block-based coordination. + +5. The P2P broadcast channel is wallet-scoped: all nodes holding signers for + a given wallet share the channel named + `{ProtocolName}-{walletPublicKeyHex}` (`node.go`, lines 351-355). + +### Multi-node concurrent signing behavior + +If two nodes receive the same signing request -- for example, due to load +balancer misconfiguration (Section 2) or missing cross-node deduplication +(Section 3) -- both attempt to initiate signing sessions on the same P2P +broadcast channel. + +- The signing protocol uses an `announcer` and `signingDoneCheck` for group + coordination (`signing.go`, lines 245-255). These mechanisms help members + discover ongoing sessions and confirm completion. + +- Threshold signing can converge if enough group members participate in a + single session. However, conflicting concurrent sessions from different + initiators may cause confusion in the broadcast channel, leading to wasted + signing attempts or outright signing failures. + +- The `semaphore.Weighted(1)` lock (`signing.go`, line 85) prevents a single + node from running multiple signing sessions concurrently for the same + wallet, but it does not coordinate across nodes. + +### Retry and timing + +- `signingAttemptsLimit = 5` (`node.go`, line 43) bounds each signer to a + maximum of five retry attempts per message. +- `signingBatchInterludeBlocks = 2` (`signing.go`, line 36) inserts a + 2-block delay between sequential batch messages, giving signing done + messages time to propagate across the broadcast channel before the next + signing begins. + +## 5. Wallet Ownership Guard + +The covenant signer engine includes a wallet ownership check that prevents +nodes without signer shares from attempting to sign. This guard is +**necessary but not sufficient** for safe multi-node operation. + +### How the guard works + +Both `submitSelfV1()` (`covenant_signer.go`, line 220) and `submitQcV1()` +(`covenant_signer.go`, line 286) call +`cse.node.getSigningExecutor(walletPublicKey)`. + +`getSigningExecutor()` (`node.go`, line 319) checks +`n.walletRegistry.getSigners(walletPublicKey)` (line 340). When +`len(signers) == 0`, the function returns `(nil, false, nil)` (lines +341-344), indicating the node does not control the requested wallet without +raising an error. + +When the signing executor is not found, the engine returns +`ReasonPolicyRejected: "wallet is not controlled by this node"` +(`covenant_signer.go`, lines 224-225 and 290-291). + +### Why this is necessary but not sufficient + +**Necessary:** The guard prevents nodes that hold no signer shares for a +wallet from attempting to sign. Without it, any node receiving a request +could attempt to initiate a signing session, even if it has no key material +to contribute. This avoids unauthorized signing attempts and wasted +resources. + +**Not sufficient:** In a threshold signing scheme, multiple nodes +legitimately hold signer shares for the same wallet, and +`getSigningExecutor` returns `true` for all of them. Without external +coordination -- such as sticky load balancer routing (Section 2), cross-node +request deduplication (Section 3), or an explicit leader election mechanism +-- multiple nodes may independently accept and begin processing the same +signing request, leading to the concurrent session conflicts described in +Section 4. + +### Design assumption + +The covenant signer is designed for a topology where signing requests for a +given wallet are directed to a single node that controls that wallet's +signing shares. External routing logic (load balancer configuration, +deployment topology, or application-level request routing) is expected to +maintain this invariant. The `getSigningExecutor` guard provides a safety net +against misconfigured routing but does not replace it. From 917d675df9fd4e21c11e2378596e74470d5e50ee Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Wed, 8 Apr 2026 22:49:39 -0300 Subject: [PATCH 088/143] feat(tbtc): add fault isolation for wallet registry and resilient store loading GetWallet now degrades gracefully when the wallet registry is unavailable, returning Bridge-sourced fields with a zero MembersIDsHash instead of failing outright. Covenant signer store loading skips unreadable files, malformed JSON, and invalid timestamps on duplicate route keys rather than aborting the entire load. --- pkg/chain/ethereum/tbtc.go | 13 +- pkg/covenantsigner/covenantsigner_test.go | 50 +++- pkg/covenantsigner/store.go | 50 +++- pkg/covenantsigner/store_test.go | 180 ++++++++++++- pkg/tbtc/chain_test.go | 25 +- pkg/tbtc/get_wallet_fault_isolation_test.go | 285 ++++++++++++++++++++ 6 files changed, 582 insertions(+), 21 deletions(-) create mode 100644 pkg/tbtc/get_wallet_fault_isolation_test.go diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 1f750abaec..d2d9efaf63 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1469,13 +1469,22 @@ func (tc *TbtcChain) GetWallet( ) } + // Fetch wallet registry data on a best-effort basis. Legacy callers + // only use Bridge-sourced fields and never access MembersIDsHash, so a + // registry outage must not block them. The zero value signals that + // registry data is unavailable; downstream consumers that need it + // (e.g. signer_approval_certificate) already guard against this. + var membersIDsHash [32]byte + walletRegistryWallet, err := tc.walletRegistry.GetWallet(wallet.EcdsaWalletID) if err != nil { - return nil, fmt.Errorf( + logger.Warnf( "cannot get wallet registry data for wallet [0x%x]: [%v]", wallet.EcdsaWalletID, err, ) + } else { + membersIDsHash = walletRegistryWallet.MembersIdsHash } walletState, err := parseWalletState(wallet.State) @@ -1485,7 +1494,7 @@ func (tc *TbtcChain) GetWallet( return &tbtc.WalletChainData{ EcdsaWalletID: wallet.EcdsaWalletID, - MembersIDsHash: walletRegistryWallet.MembersIdsHash, + MembersIDsHash: membersIDsHash, MainUtxoHash: wallet.MainUtxoHash, PendingRedemptionsValue: wallet.PendingRedemptionsValue, CreatedAt: time.Unix(int64(wallet.CreatedAt), 0), diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 78c6556dc3..e6c32830bf 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -5,9 +5,9 @@ import ( "context" "crypto/ecdsa" "crypto/ed25519" - "crypto/sha256" "crypto/elliptic" "crypto/rand" + "crypto/sha256" "crypto/x509" "encoding/hex" "encoding/json" @@ -104,6 +104,54 @@ func (fmh *faultingMemoryHandle) Delete(directory string, name string) error { return fmh.memoryHandle.Delete(directory, name) } +// faultingDescriptor wraps a memoryDescriptor and returns an injected error +// from Content(), allowing tests to simulate unreadable job files. +type faultingDescriptor struct { + name string + directory string + err error +} + +func (fd *faultingDescriptor) Name() string { return fd.name } +func (fd *faultingDescriptor) Directory() string { return fd.directory } +func (fd *faultingDescriptor) Content() ([]byte, error) { return nil, fd.err } + +// contentFaultingHandle extends memoryHandle by injecting faulting descriptors +// into the ReadAll channel alongside normal descriptors. This enables testing +// of load() behavior when individual file reads fail. +type contentFaultingHandle struct { + *memoryHandle + faultingDescriptors []*faultingDescriptor +} + +func newContentFaultingHandle() *contentFaultingHandle { + return &contentFaultingHandle{ + memoryHandle: newMemoryHandle(), + } +} + +func (cfh *contentFaultingHandle) AddFaultingDescriptor(name, directory string, err error) { + cfh.faultingDescriptors = append(cfh.faultingDescriptors, &faultingDescriptor{ + name: name, + directory: directory, + err: err, + }) +} + +func (cfh *contentFaultingHandle) ReadAll() (<-chan persistence.DataDescriptor, <-chan error) { + dataChan := make(chan persistence.DataDescriptor, len(cfh.items)+len(cfh.faultingDescriptors)) + errorChan := make(chan error) + for _, item := range cfh.items { + dataChan <- item + } + for _, fd := range cfh.faultingDescriptors { + dataChan <- fd + } + close(dataChan) + close(errorChan) + return dataChan, errorChan +} + type scriptedEngine struct { submit func(*Job) (*Transition, error) poll func(*Job) (*Transition, error) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index fa8e74db29..7f2a8f60e1 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -169,30 +169,60 @@ func (s *Store) load() error { content, err := descriptor.Content() if err != nil { - return err + logger.Warnf( + "skipping unreadable job file [%s]: [%v]", + descriptor.Name(), + err, + ) + continue } job := &Job{} if err := json.Unmarshal(content, job); err != nil { - return err + logger.Warnf( + "skipping malformed job file [%s]: [%v]", + descriptor.Name(), + err, + ) + continue } - existingID, ok := s.byRouteKey[routeKey(job.Route, job.RouteRequestID)] - if ok { - existing := s.byRequestID[existingID] - if existing != nil { + key := routeKey(job.Route, job.RouteRequestID) + + if existingID, ok := s.byRouteKey[key]; ok { + if existing := s.byRequestID[existingID]; existing != nil { existingIsNewerOrSame, err := isNewerOrSameJobRevision(existing, job) if err != nil { - return err - } - if existingIsNewerOrSame { + // When the timestamp comparison fails, prefer + // whichever job has a parseable timestamp. If the + // candidate's timestamp is valid, the failure is on + // the existing job -- replace it. Otherwise skip the + // candidate. + if _, parseErr := time.Parse(time.RFC3339Nano, job.UpdatedAt); parseErr != nil { + logger.Warnf( + "skipping job [%s] with invalid timestamp on duplicate route key [%s/%s]: [%v]", + job.RequestID, + job.Route, + job.RouteRequestID, + err, + ) + continue + } + logger.Warnf( + "replacing job [%s] with invalid timestamp on duplicate route key [%s/%s]: [%v]", + existing.RequestID, + job.Route, + job.RouteRequestID, + err, + ) + } else if existingIsNewerOrSame { continue } } } s.byRequestID[job.RequestID] = job - s.byRouteKey[routeKey(job.Route, job.RouteRequestID)] = job.RequestID + s.byRouteKey[key] = job.RequestID case err, ok := <-errorChan: if !ok { errorChan = nil diff --git a/pkg/covenantsigner/store_test.go b/pkg/covenantsigner/store_test.go index 9f6dc3cbad..fd271530ed 100644 --- a/pkg/covenantsigner/store_test.go +++ b/pkg/covenantsigner/store_test.go @@ -251,12 +251,180 @@ func TestStoreLoadFailsOnInvalidUpdatedAtForDuplicateRouteKeys(t *testing.T) { t.Fatal(err) } - _, err = NewStore(handle, "") - if err == nil { - t.Fatal("expected invalid UpdatedAt error") + store, err := NewStore(handle, "") + if err != nil { + t.Fatalf( + "expected store to load despite invalid timestamp on duplicate route key, got error: %v", + err, + ) + } + + loaded, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_load_invalid_updated_at") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected valid job to be loaded despite invalid-timestamp sibling") + } + if loaded.RequestID != first.RequestID { + t.Fatalf("expected request ID %s, got %s", first.RequestID, loaded.RequestID) + } +} + +func TestStoreLoadSkipsUnreadableFile(t *testing.T) { + handle := newContentFaultingHandle() + + validJob := &Job{ + RequestID: "kcs_self_valid_readable", + RouteRequestID: "ors_readable", + Route: TemplateSelfV1, + IdempotencyKey: "idem_readable", + FacadeRequestID: "rf_readable", + RequestDigest: "0xaaa", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + payload, err := json.Marshal(validJob) + if err != nil { + t.Fatal(err) + } + if err := handle.Save(payload, jobsDirectory, validJob.RequestID+".json"); err != nil { + t.Fatal(err) + } + + handle.AddFaultingDescriptor( + "corrupted_file.json", + jobsDirectory, + errors.New("simulated disk read error"), + ) + + store, err := NewStore(handle, "") + if err != nil { + t.Fatalf("expected store to load despite unreadable file, got error: %v", err) + } + + loaded, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_readable") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected valid job to be loaded despite corrupted sibling") + } + if loaded.RequestID != validJob.RequestID { + t.Fatalf("expected request ID %s, got %s", validJob.RequestID, loaded.RequestID) + } +} + +func TestStoreLoadSkipsMalformedJSON(t *testing.T) { + handle := newMemoryHandle() + + validJob := &Job{ + RequestID: "kcs_self_valid_json", + RouteRequestID: "ors_valid_json", + Route: TemplateSelfV1, + IdempotencyKey: "idem_valid_json", + FacadeRequestID: "rf_valid_json", + RequestDigest: "0xbbb", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + validPayload, err := json.Marshal(validJob) + if err != nil { + t.Fatal(err) + } + if err := handle.Save(validPayload, jobsDirectory, validJob.RequestID+".json"); err != nil { + t.Fatal(err) + } + + if err := handle.Save([]byte("not valid json content"), jobsDirectory, "malformed.json"); err != nil { + t.Fatal(err) + } + + store, err := NewStore(handle, "") + if err != nil { + t.Fatalf("expected store to load despite malformed JSON file, got error: %v", err) + } + + loaded, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_valid_json") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected valid job to be loaded despite malformed sibling") + } + if loaded.RequestID != validJob.RequestID { + t.Fatalf("expected request ID %s, got %s", validJob.RequestID, loaded.RequestID) + } +} + +func TestStoreLoadSkipsInvalidTimestampOnDuplicateRouteKey(t *testing.T) { + handle := newMemoryHandle() + + validJob := &Job{ + RequestID: "kcs_self_valid_ts", + RouteRequestID: "ors_ts_dupe", + Route: TemplateSelfV1, + IdempotencyKey: "idem_valid_ts", + FacadeRequestID: "rf_valid_ts", + RequestDigest: "0xccc", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + Request: baseRequest(TemplateSelfV1), + } + + badTimestampJob := &Job{ + RequestID: "kcs_self_bad_ts", + RouteRequestID: "ors_ts_dupe", + Route: TemplateSelfV1, + IdempotencyKey: "idem_bad_ts", + FacadeRequestID: "rf_bad_ts", + RequestDigest: "0xddd", + State: JobStatePending, + Detail: "queued", + CreatedAt: "2026-03-10T00:00:00Z", + UpdatedAt: "invalid-timestamp", + Request: baseRequest(TemplateSelfV1), + } + + validPayload, err := json.Marshal(validJob) + if err != nil { + t.Fatal(err) + } + badPayload, err := json.Marshal(badTimestampJob) + if err != nil { + t.Fatal(err) + } + + if err := handle.Save(validPayload, jobsDirectory, validJob.RequestID+".json"); err != nil { + t.Fatal(err) + } + if err := handle.Save(badPayload, jobsDirectory, badTimestampJob.RequestID+".json"); err != nil { + t.Fatal(err) + } + + store, err := NewStore(handle, "") + if err != nil { + t.Fatalf("expected store to load despite invalid timestamp on duplicate route key, got error: %v", err) + } + + loaded, ok, err := store.GetByRequestID("kcs_self_valid_ts") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected valid job to be accessible after skipping bad-timestamp sibling") } - if !strings.Contains(err.Error(), "cannot parse candidate job updatedAt") && - !strings.Contains(err.Error(), "cannot parse existing job updatedAt") { - t.Fatalf("unexpected error: %v", err) + if loaded.RequestID != validJob.RequestID { + t.Fatalf("expected request ID %s, got %s", validJob.RequestID, loaded.RequestID) } } diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index cbd59b5221..ff07e903f9 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -66,8 +66,9 @@ type localChain struct { dkgResult *DKGChainResult dkgResultValid bool - walletsMutex sync.Mutex - wallets map[[20]byte]*WalletChainData + walletsMutex sync.Mutex + wallets map[[20]byte]*WalletChainData + walletRegistryErrs map[[20]byte]error inactivityNonceMutex sync.Mutex inactivityNonces map[[32]byte]uint64 @@ -889,6 +890,15 @@ func (lc *localChain) GetWallet(walletPublicKeyHash [20]byte) ( return nil, fmt.Errorf("%w for given PKH", ErrWalletNotFound) } + // When a registry error is configured for this wallet, return + // Bridge-sourced data with a zero MembersIDsHash -- mirroring the + // fault-isolation behavior of the real Ethereum adapter. + if _, hasErr := lc.walletRegistryErrs[walletPublicKeyHash]; hasErr { + degraded := *walletChainData + degraded.MembersIDsHash = [32]byte{} + return °raded, nil + } + return walletChainData, nil } @@ -919,6 +929,16 @@ func (lc *localChain) setWallet( lc.wallets[walletPublicKeyHash] = walletChainData } +func (lc *localChain) setWalletRegistryErr( + walletPublicKeyHash [20]byte, + err error, +) { + lc.walletsMutex.Lock() + defer lc.walletsMutex.Unlock() + + lc.walletRegistryErrs[walletPublicKeyHash] = err +} + func (lc *localChain) OnWalletClosed( handler func(event *WalletClosedEvent), ) subscription.EventSubscription { @@ -1463,6 +1483,7 @@ func ConnectWithKey( map[int]func(submission *InactivityClaimedEvent), ), wallets: make(map[[20]byte]*WalletChainData), + walletRegistryErrs: make(map[[20]byte]error), inactivityNonces: make(map[[32]byte]uint64), blocksByTimestamp: make(map[uint64]uint64), blocksHashesByNumber: make(map[uint64][32]byte), diff --git a/pkg/tbtc/get_wallet_fault_isolation_test.go b/pkg/tbtc/get_wallet_fault_isolation_test.go new file mode 100644 index 0000000000..efe3af4402 --- /dev/null +++ b/pkg/tbtc/get_wallet_fault_isolation_test.go @@ -0,0 +1,285 @@ +package tbtc + +import ( + "crypto/sha256" + "fmt" + "testing" + "time" + + "github.com/keep-network/keep-core/internal/testutils" +) + +// TestGetWalletReturnsDataWhenRegistryFails verifies that GetWallet returns +// valid Bridge fields with a zero-valued MembersIDsHash when the wallet +// registry is unavailable. This validates that downstream callers relying +// only on Bridge data (State, timestamps, etc.) are not disrupted by a +// transient registry failure. +func TestGetWalletReturnsDataWhenRegistryFails(t *testing.T) { + chain := Connect() + + walletPublicKeyHash := [20]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, 16, 17, 18, 19, 20} + walletID := [32]byte{0xaa, 0xbb, 0xcc} + mainUtxoHash := sha256.Sum256([]byte("main-utxo")) + createdAt := time.Unix(1700000000, 0) + movingFundsRequestedAt := time.Unix(1700001000, 0) + closingStartedAt := time.Unix(1700002000, 0) + + chain.setWallet(walletPublicKeyHash, &WalletChainData{ + EcdsaWalletID: walletID, + MembersIDsHash: [32]byte{}, // zero -- simulating registry unavailable + MainUtxoHash: mainUtxoHash, + PendingRedemptionsValue: 500000, + CreatedAt: createdAt, + MovingFundsRequestedAt: movingFundsRequestedAt, + ClosingStartedAt: closingStartedAt, + PendingMovedFundsSweepRequestsCount: 3, + State: StateLive, + MovingFundsTargetWalletsCommitmentHash: sha256.Sum256( + []byte("commitment"), + ), + }) + + // Simulate a wallet registry error so the mock degrades + // gracefully, returning Bridge-only data with zero MembersIDsHash. + chain.setWalletRegistryErr(walletPublicKeyHash, fmt.Errorf( + "rpc: wallet registry unavailable", + )) + + walletData, err := chain.GetWallet(walletPublicKeyHash) + if err != nil { + t.Fatalf( + "GetWallet should not return error on registry failure; got: [%v]", + err, + ) + } + + if walletData == nil { + t.Fatal("GetWallet should return non-nil WalletChainData on registry failure") + } + + // Verify MembersIDsHash is zero when registry is unavailable. + if walletData.MembersIDsHash != ([32]byte{}) { + t.Errorf( + "unexpected MembersIDsHash\nexpected: zero [32]byte\nactual: [0x%x]", + walletData.MembersIDsHash, + ) + } + + // Verify Bridge-sourced fields are fully populated. + if walletData.EcdsaWalletID != walletID { + t.Errorf( + "unexpected EcdsaWalletID\nexpected: [0x%x]\nactual: [0x%x]", + walletID, + walletData.EcdsaWalletID, + ) + } + + if walletData.MainUtxoHash != mainUtxoHash { + t.Errorf( + "unexpected MainUtxoHash\nexpected: [0x%x]\nactual: [0x%x]", + mainUtxoHash, + walletData.MainUtxoHash, + ) + } + + testutils.AssertUintsEqual( + t, + "PendingRedemptionsValue", + 500000, + walletData.PendingRedemptionsValue, + ) + + if !walletData.CreatedAt.Equal(createdAt) { + t.Errorf( + "unexpected CreatedAt\nexpected: [%v]\nactual: [%v]", + createdAt, + walletData.CreatedAt, + ) + } + + if walletData.State != StateLive { + t.Errorf( + "unexpected State\nexpected: [%v]\nactual: [%v]", + StateLive, + walletData.State, + ) + } + + if !walletData.MovingFundsRequestedAt.Equal(movingFundsRequestedAt) { + t.Errorf( + "unexpected MovingFundsRequestedAt\nexpected: [%v]\nactual: [%v]", + movingFundsRequestedAt, + walletData.MovingFundsRequestedAt, + ) + } + + if !walletData.ClosingStartedAt.Equal(closingStartedAt) { + t.Errorf( + "unexpected ClosingStartedAt\nexpected: [%v]\nactual: [%v]", + closingStartedAt, + walletData.ClosingStartedAt, + ) + } + + testutils.AssertUintsEqual( + t, + "PendingMovedFundsSweepRequestsCount", + 3, + uint64(walletData.PendingMovedFundsSweepRequestsCount), + ) + + commitmentHash := sha256.Sum256([]byte("commitment")) + if walletData.MovingFundsTargetWalletsCommitmentHash != commitmentHash { + t.Errorf( + "unexpected MovingFundsTargetWalletsCommitmentHash\n"+ + "expected: [0x%x]\nactual: [0x%x]", + commitmentHash, + walletData.MovingFundsTargetWalletsCommitmentHash, + ) + } +} + +// TestGetWalletReturnsFullDataWhenRegistrySucceeds verifies that GetWallet +// returns complete data including a non-zero MembersIDsHash when both the +// Bridge and wallet registry calls succeed. This is the baseline behavior +// that must be preserved after introducing fault isolation. +func TestGetWalletReturnsFullDataWhenRegistrySucceeds(t *testing.T) { + chain := Connect() + + walletPublicKeyHash := [20]byte{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, + 31, 32, 33, 34, 35, 36, 37, 38, 39, 40} + walletID := [32]byte{0xdd, 0xee, 0xff} + membersIDsHash := sha256.Sum256([]byte("test-members-ids")) + mainUtxoHash := sha256.Sum256([]byte("main-utxo-success")) + createdAt := time.Unix(1700000000, 0) + movingFundsRequestedAt := time.Unix(1700003000, 0) + closingStartedAt := time.Unix(1700004000, 0) + commitmentHash := sha256.Sum256([]byte("commitment-success")) + + chain.setWallet(walletPublicKeyHash, &WalletChainData{ + EcdsaWalletID: walletID, + MembersIDsHash: membersIDsHash, + MainUtxoHash: mainUtxoHash, + PendingRedemptionsValue: 1000000, + CreatedAt: createdAt, + MovingFundsRequestedAt: movingFundsRequestedAt, + ClosingStartedAt: closingStartedAt, + PendingMovedFundsSweepRequestsCount: 7, + State: StateMovingFunds, + MovingFundsTargetWalletsCommitmentHash: commitmentHash, + }) + + walletData, err := chain.GetWallet(walletPublicKeyHash) + if err != nil { + t.Fatalf("GetWallet should not return error; got: [%v]", err) + } + + if walletData == nil { + t.Fatal("GetWallet should return non-nil WalletChainData") + } + + // Verify MembersIDsHash is the expected non-zero value. + if walletData.MembersIDsHash != membersIDsHash { + t.Errorf( + "unexpected MembersIDsHash\nexpected: [0x%x]\nactual: [0x%x]", + membersIDsHash, + walletData.MembersIDsHash, + ) + } + + if walletData.EcdsaWalletID != walletID { + t.Errorf( + "unexpected EcdsaWalletID\nexpected: [0x%x]\nactual: [0x%x]", + walletID, + walletData.EcdsaWalletID, + ) + } + + if walletData.MainUtxoHash != mainUtxoHash { + t.Errorf( + "unexpected MainUtxoHash\nexpected: [0x%x]\nactual: [0x%x]", + mainUtxoHash, + walletData.MainUtxoHash, + ) + } + + testutils.AssertUintsEqual( + t, + "PendingRedemptionsValue", + 1000000, + walletData.PendingRedemptionsValue, + ) + + if walletData.State != StateMovingFunds { + t.Errorf( + "unexpected State\nexpected: [%v]\nactual: [%v]", + StateMovingFunds, + walletData.State, + ) + } + + if !walletData.CreatedAt.Equal(createdAt) { + t.Errorf( + "unexpected CreatedAt\nexpected: [%v]\nactual: [%v]", + createdAt, + walletData.CreatedAt, + ) + } + + if !walletData.MovingFundsRequestedAt.Equal(movingFundsRequestedAt) { + t.Errorf( + "unexpected MovingFundsRequestedAt\nexpected: [%v]\nactual: [%v]", + movingFundsRequestedAt, + walletData.MovingFundsRequestedAt, + ) + } + + if !walletData.ClosingStartedAt.Equal(closingStartedAt) { + t.Errorf( + "unexpected ClosingStartedAt\nexpected: [%v]\nactual: [%v]", + closingStartedAt, + walletData.ClosingStartedAt, + ) + } + + testutils.AssertUintsEqual( + t, + "PendingMovedFundsSweepRequestsCount", + 7, + uint64(walletData.PendingMovedFundsSweepRequestsCount), + ) + + if walletData.MovingFundsTargetWalletsCommitmentHash != commitmentHash { + t.Errorf( + "unexpected MovingFundsTargetWalletsCommitmentHash\n"+ + "expected: [0x%x]\nactual: [0x%x]", + commitmentHash, + walletData.MovingFundsTargetWalletsCommitmentHash, + ) + } +} + +// TestGetWalletBridgeFailureStillReturnsError verifies that GetWallet +// continues to return an error when the wallet is not found (Bridge-level +// failure). The fault isolation change must NOT alter the behavior for +// Bridge failures. +func TestGetWalletBridgeFailureStillReturnsError(t *testing.T) { + chain := Connect() + + unknownPKH := [20]byte{99, 99, 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, 99, 99} + + walletData, err := chain.GetWallet(unknownPKH) + + if err == nil { + t.Fatal("GetWallet should return error for unknown wallet") + } + + if walletData != nil { + t.Errorf( + "GetWallet should return nil data for unknown wallet; got: [%+v]", + walletData, + ) + } +} From e0c5d333a547344765c93595dd0a7bd087385d5c Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 10:22:06 +0000 Subject: [PATCH 089/143] fix(tbtc): add sentinel errors for missing wallet registry data When the wallet registry is unavailable during fault-isolated GetWallet calls, signer approval verification now returns actionable errors that distinguish "registry unavailable" from "genuinely zero values". Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/tbtc/covenant_signer.go | 6 ++++++ pkg/tbtc/signer_approval_certificate.go | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index f0104a3e33..ba1ed8b7e2 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -139,6 +139,12 @@ func (cse *covenantSignerEngine) VerifySignerApproval( cse.node.groupParameters, ) if err != nil { + if errors.Is(err, ErrMissingWalletID) || errors.Is(err, ErrMissingMembersIDsHash) { + return fmt.Errorf( + "wallet registry unavailable; signer approval verification requires registry data: %w", + err, + ) + } return fmt.Errorf( "cannot compute signer approval signer set hash: %w", err, diff --git a/pkg/tbtc/signer_approval_certificate.go b/pkg/tbtc/signer_approval_certificate.go index acf891a22e..72a5dfd5c8 100644 --- a/pkg/tbtc/signer_approval_certificate.go +++ b/pkg/tbtc/signer_approval_certificate.go @@ -18,6 +18,18 @@ import ( "github.com/keep-network/keep-core/pkg/tecdsa" ) +var ( + // ErrMissingWalletID is returned when wallet chain data does not + // include a wallet ID, typically because the wallet registry was + // unavailable during a fault-isolated GetWallet call. + ErrMissingWalletID = fmt.Errorf("wallet chain data must include wallet ID") + + // ErrMissingMembersIDsHash is returned when wallet chain data does + // not include a members IDs hash, typically because the wallet + // registry was unavailable during a fault-isolated GetWallet call. + ErrMissingMembersIDsHash = fmt.Errorf("wallet chain data must include members IDs hash") +) + const ( signerApprovalCertificateVersion uint32 = 1 signerApprovalCertificateSignatureAlgorithm = "tecdsa-secp256k1" @@ -153,10 +165,10 @@ func computeSignerApprovalCertificateSignerSetHash( return "", fmt.Errorf("wallet chain data is required") } if walletChainData.EcdsaWalletID == ([32]byte{}) { - return "", fmt.Errorf("wallet chain data must include wallet ID") + return "", ErrMissingWalletID } if walletChainData.MembersIDsHash == ([32]byte{}) { - return "", fmt.Errorf("wallet chain data must include members IDs hash") + return "", ErrMissingMembersIDsHash } // Keep signer-set payload key encoding aligned with certificate issuance: From f8574037b021ce14bd66d701f44cb9733b710d89 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 10:24:03 +0000 Subject: [PATCH 090/143] fix(covenantsigner): bound submit context with timeout and shutdown cancellation Replace unbounded context.WithoutCancel in submitHandler with a service-level context that is cancelled on process shutdown and has a 5-minute timeout. This prevents threshold signing goroutines from hanging indefinitely and ensures clean cancellation on container stop. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/covenantsigner/server.go | 43 ++++++++++++++++++++++--------- pkg/covenantsigner/server_test.go | 18 ++++++------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 4c79dfee64..96ad083120 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -21,8 +21,9 @@ import ( var logger = log.Logger("keep-covenant-signer") type Server struct { - service *Service - httpServer *http.Server + service *Service + cancelService context.CancelFunc + httpServer *http.Server } const maxRequestBodyBytes = 2 << 20 @@ -102,11 +103,17 @@ func Initialize( ) } + // Create a service-level context that outlives individual HTTP requests + // but is cancelled on process shutdown, so in-flight threshold signing + // operations are cancelled cleanly rather than killed mid-operation. + serviceCtx, cancelService := context.WithCancel(context.WithoutCancel(ctx)) + server := &Server{ - service: service, + service: service, + cancelService: cancelService, httpServer: &http.Server{ Addr: net.JoinHostPort(listenAddress, strconv.Itoa(config.Port)), - Handler: newHandler(service, config.AuthToken, config.EnableSelfV1), + Handler: newHandler(service, serviceCtx, config.AuthToken, config.EnableSelfV1), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, @@ -122,8 +129,13 @@ func Initialize( go func() { <-ctx.Done() + + // Cancel the service context so in-flight threshold signing + // operations observe shutdown and terminate promptly. + cancelService() + shutdownCtx, cancelShutdown := context.WithTimeout( - context.WithoutCancel(ctx), + context.Background(), 5*time.Second, ) defer cancelShutdown() @@ -219,7 +231,7 @@ func hasCustodianTrustRootForRoute( return false } -func newHandler(service *Service, authToken string, enableSelfV1 bool) http.Handler { +func newHandler(service *Service, serviceCtx context.Context, authToken string, enableSelfV1 bool) http.Handler { mux := http.NewServeMux() protectedHandler := withBearerAuth(mux, authToken) @@ -229,11 +241,11 @@ func newHandler(service *Service, authToken string, enableSelfV1 bool) http.Hand _, _ = w.Write([]byte(`{"status":"ok"}`)) }) - mux.HandleFunc("POST /v1/qc_v1/signer/requests", submitHandler(service, TemplateQcV1)) + mux.HandleFunc("POST /v1/qc_v1/signer/requests", submitHandler(service, serviceCtx, TemplateQcV1)) mux.HandleFunc("POST /v1/qc_v1/signer/requests:poll", pollBodyHandler(service, TemplateQcV1)) mux.HandleFunc("/v1/qc_v1/signer/requests/", pollPathHandler(service, TemplateQcV1)) if enableSelfV1 { - mux.HandleFunc("POST /v1/self_v1/signer/requests", submitHandler(service, TemplateSelfV1)) + mux.HandleFunc("POST /v1/self_v1/signer/requests", submitHandler(service, serviceCtx, TemplateSelfV1)) mux.HandleFunc("POST /v1/self_v1/signer/requests:poll", pollBodyHandler(service, TemplateSelfV1)) mux.HandleFunc("/v1/self_v1/signer/requests/", pollPathHandler(service, TemplateSelfV1)) } @@ -328,16 +340,23 @@ func handleError(w http.ResponseWriter, err error) { http.Error(w, "internal server error", http.StatusInternalServerError) } -func submitHandler(service *Service, route TemplateID) http.HandlerFunc { +const submitTimeout = 5 * time.Minute + +func submitHandler(service *Service, serviceCtx context.Context, route TemplateID) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { input := SignerSubmitInput{} if !decodeJSON(w, r, &input) { return } - // Detach from the HTTP request lifetime so that threshold signing - // survives write-timeout and client disconnects. - result, err := service.Submit(context.WithoutCancel(r.Context()), route, input) + // Use the service-level context (cancelled on process shutdown) + // with a bounded timeout so threshold signing cannot hang + // indefinitely. This detaches from the HTTP request lifetime so + // signing survives write-timeout and client disconnects. + submitCtx, cancelSubmit := context.WithTimeout(serviceCtx, submitTimeout) + defer cancelSubmit() + + result, err := service.Submit(submitCtx, route, input) if err != nil { handleError(w, err) return diff --git a/pkg/covenantsigner/server_test.go b/pkg/covenantsigner/server_test.go index 97e210691c..4b4f651e65 100644 --- a/pkg/covenantsigner/server_test.go +++ b/pkg/covenantsigner/server_test.go @@ -34,7 +34,7 @@ func TestServerHandlesSubmitAndPathPoll(t *testing.T) { t.Fatal(err) } - server := httptest.NewServer(newHandler(service, "", true)) + server := httptest.NewServer(newHandler(service, context.Background(), "", true)) defer server.Close() submitPayload := mustJSON(t, SignerSubmitInput{ @@ -88,7 +88,7 @@ func TestServerRejectsUnknownFieldsOnSubmit(t *testing.T) { t.Fatal(err) } - server := httptest.NewServer(newHandler(service, "", true)) + server := httptest.NewServer(newHandler(service, context.Background(), "", true)) defer server.Close() base := baseRequest(TemplateSelfV1) @@ -193,7 +193,7 @@ func TestServerRejectsTrailingJSONOnSubmit(t *testing.T) { t.Fatal(err) } - server := httptest.NewServer(newHandler(service, "", true)) + server := httptest.NewServer(newHandler(service, context.Background(), "", true)) defer server.Close() validPayload := mustJSON(t, SignerSubmitInput{ @@ -432,7 +432,7 @@ func TestServerRequiresBearerTokenWhenConfigured(t *testing.T) { t.Fatal(err) } - server := httptest.NewServer(newHandler(service, "test-token", true)) + server := httptest.NewServer(newHandler(service, context.Background(), "test-token", true)) defer server.Close() submitPayload := mustJSON(t, SignerSubmitInput{ @@ -505,7 +505,7 @@ func TestServerCanKeepSelfV1RoutesDark(t *testing.T) { t.Fatal(err) } - server := httptest.NewServer(newHandler(service, "", false)) + server := httptest.NewServer(newHandler(service, context.Background(), "", false)) defer server.Close() response, err := http.Post( @@ -561,7 +561,7 @@ func TestServerBoundaryErrorMatrix(t *testing.T) { t.Fatal(err) } - server := httptest.NewServer(newHandler(service, "test-token", true)) + server := httptest.NewServer(newHandler(service, context.Background(), "test-token", true)) defer server.Close() submitPayload := mustJSON(t, SignerSubmitInput{ @@ -728,7 +728,7 @@ func TestSubmitHandlerDetachesContextFromHTTPLifecycle(t *testing.T) { t.Fatal(err) } - handler := newHandler(service, "", true) + handler := newHandler(service, context.Background(), "", true) submitPayload := mustJSON(t, SignerSubmitInput{ RouteRequestID: "ors_detach_cancel", @@ -818,7 +818,7 @@ func TestSubmitHandlerPreCancelledContextStillSucceeds(t *testing.T) { // Test through the handler directly using httptest.ResponseRecorder // because an HTTP client would fail to send a request with a // pre-cancelled context. - handler := newHandler(service, "", true) + handler := newHandler(service, context.Background(), "", true) submitPayload := mustJSON(t, SignerSubmitInput{ RouteRequestID: "ors_detach_precancel", @@ -891,7 +891,7 @@ func TestSubmitHandlerPreservesContextValues(t *testing.T) { // Wrap the handler with middleware that injects a value into the request // context. The detached context should preserve this value. - innerHandler := newHandler(service, "", true) + innerHandler := newHandler(service, context.Background(), "", true) wrappedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { enrichedCtx := context.WithValue(r.Context(), testKey, testValue) innerHandler.ServeHTTP(w, r.WithContext(enrichedCtx)) From 3a36c1ba8c56249e5fbda7247792b131438c7057 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 10:24:37 +0000 Subject: [PATCH 091/143] fix(tbtc): correct keyStorePersistance typo to keyStorePersistence Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/start.go | 2 +- pkg/tbtc/node.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index 45a5b97101..1b9267b913 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -89,7 +89,7 @@ type startDeps struct { chain tbtc.Chain, btcChain bitcoin.Chain, netProvider net.Provider, - keyStorePersistance persistence.ProtectedHandle, + keyStorePersistence persistence.ProtectedHandle, workPersistence persistence.BasicHandle, scheduler *generator.Scheduler, proposalGenerator tbtc.CoordinationProposalGenerator, diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index b6b0dc15bf..9a15e97096 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -130,14 +130,14 @@ func newNode( chain Chain, btcChain bitcoin.Chain, netProvider net.Provider, - keyStorePersistance persistence.ProtectedHandle, + keyStorePersistence persistence.ProtectedHandle, workPersistence persistence.BasicHandle, scheduler *generator.Scheduler, proposalGenerator CoordinationProposalGenerator, config Config, ) (*node, error) { walletRegistry, err := newWalletRegistry( - keyStorePersistance, + keyStorePersistence, chain.CalculateWalletID, ) if err != nil { From 2e3d0c04b3176d8115fbee7aabfefa0b888f6904 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 10:24:57 +0000 Subject: [PATCH 092/143] docs(covenantsigner): clarify store deduplication strategy in load Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/covenantsigner/store.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index 7f2a8f60e1..3684edb73e 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -189,6 +189,9 @@ func (s *Store) load() error { key := routeKey(job.Route, job.RouteRequestID) + // Deduplication: when multiple files share the same route key, + // keep the job with the newest UpdatedAt timestamp. If timestamps + // cannot be compared, prefer whichever has a valid timestamp. if existingID, ok := s.byRouteKey[key]; ok { if existing := s.byRequestID[existingID]; existing != nil { existingIsNewerOrSame, err := isNewerOrSameJobRevision(existing, job) From c3b4e335ca792dada8d61c156064fa4073b48a0e Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 10:25:14 +0000 Subject: [PATCH 093/143] fix(ci): detect when race detector -run filter matches no tests Add a guard that fails the CI step if the -run regex silently matches zero tests, which would otherwise pass without actually testing anything. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/client.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 6aea9444a0..08f3ba9caf 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -334,9 +334,14 @@ jobs: - name: Race detector (high-risk packages) run: | go test -race -timeout 20m ./pkg/covenantsigner - go test -race -timeout 20m ./pkg/tbtc \ + go test -race -timeout 20m -v ./pkg/tbtc \ -run '^(TestSignerApprovalCertificateSignerSetHashBindsOnChainWalletIdentityAndThreshold|TestValidateMigrationOutputValues_RejectsValuesExceedingInt64)$' \ - -count=1 + -count=1 2>&1 | tee /tmp/race-output.txt + # Fail if the -run filter matched no tests (silent pass). + if ! grep -q '=== RUN' /tmp/race-output.txt; then + echo "ERROR: race detector -run filter matched no tests" >&2 + exit 1 + fi client-integration-test: needs: [electrum-integration-detect-changes, client-build-test-publish] From aa40eb630ed383b102de5dd8826703b320d424e1 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 15:42:49 +0000 Subject: [PATCH 094/143] fix(covenantsigner): rename misleading test after resilient loading change TestStoreLoadFailsOnInvalidUpdatedAtForDuplicateRouteKeys now asserts success (resilient loading), not failure. Rename to reflect actual behavior. --- pkg/covenantsigner/store_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/covenantsigner/store_test.go b/pkg/covenantsigner/store_test.go index fd271530ed..5c1e1589e8 100644 --- a/pkg/covenantsigner/store_test.go +++ b/pkg/covenantsigner/store_test.go @@ -205,7 +205,7 @@ func TestStoreLoadSelectsNewestJobForDuplicateRouteKeys(t *testing.T) { } } -func TestStoreLoadFailsOnInvalidUpdatedAtForDuplicateRouteKeys(t *testing.T) { +func TestStoreLoadResolvesInvalidUpdatedAtForDuplicateRouteKeys(t *testing.T) { handle := newMemoryHandle() first := &Job{ From 4236175754fd3a35e6fa01a0baa90f045511a570 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 15:43:53 +0000 Subject: [PATCH 095/143] fix(covenantsigner): use errors.Is for errJobNotFound comparison in Poll Direct == comparison is correct today since errJobNotFound is never wrapped, but errors.Is is more resilient to future wrapping changes. --- pkg/covenantsigner/service.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 3d112f9e3f..1114b1382b 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/hex" + "errors" "fmt" "reflect" "sync" @@ -378,7 +379,7 @@ func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollIn transition, pollErr := s.engine.OnPoll(ctx, job) if pollErr != nil { - if pollErr != errJobNotFound { + if !errors.Is(pollErr, errJobNotFound) { return StepResult{}, pollErr } } @@ -398,7 +399,7 @@ func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollIn return mapJobResult(currentJob), nil } - if pollErr == errJobNotFound { + if errors.Is(pollErr, errJobNotFound) { applyTransition(currentJob, &Transition{ State: JobStateFailed, Reason: ReasonJobNotFound, From 421c638c48ca21001fa21a8b12b00a93e602ed9c Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 15:44:04 +0000 Subject: [PATCH 096/143] fix(covenantsigner): restrict healthz auth bypass to GET method The auth bypass checked only the path, allowing any HTTP method to skip bearer auth on /healthz. Restrict to GET to match the registered handler and prevent unintended bypass on other methods. --- pkg/covenantsigner/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 96ad083120..0bf38bf3fb 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -251,7 +251,7 @@ func newHandler(service *Service, serviceCtx context.Context, authToken string, } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/healthz" { + if r.Method == http.MethodGet && r.URL.Path == "/healthz" { mux.ServeHTTP(w, r) return } From 713ee9ff51a0bf6f2a551e5fa0fa3a12b6c7f678 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 15:44:24 +0000 Subject: [PATCH 097/143] docs(covenantsigner): warn against CLI flag for AuthToken The auth token is visible in /proc/PID/cmdline when passed as a CLI flag. Add documentation recommending environment variables or config files for non-loopback deployments. --- pkg/covenantsigner/config.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/covenantsigner/config.go b/pkg/covenantsigner/config.go index 16ede9a4f9..e11f7fec0a 100644 --- a/pkg/covenantsigner/config.go +++ b/pkg/covenantsigner/config.go @@ -10,7 +10,9 @@ type Config struct { // binds to. Empty defaults to loopback-only. ListenAddress string // AuthToken enables static Bearer authentication for signer endpoints. - // Non-loopback binds must set this. + // Non-loopback binds must set this. Prefer environment variables or + // config files over CLI flags to avoid exposing the token in + // /proc/PID/cmdline. AuthToken string // EnableSelfV1 exposes the self_v1 signer HTTP routes. Keep this disabled // for a qc_v1-first launch unless self_v1 has cleared its own go-live gate. From c57a689ada45b9f2e6a2a179de2fa3f2205614bf Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 15:45:14 +0000 Subject: [PATCH 098/143] fix(covenantsigner): add aggregate load summary with skip count Operators previously saw only individual warnings per corrupt file but had no summary of total loaded vs skipped. Add a summary log line at the end of load() for operational visibility. --- pkg/covenantsigner/store.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index 3684edb73e..e5432bdbf8 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -155,6 +155,8 @@ func (s *Store) load() error { dataChan, errorChan := s.handle.ReadAll() + var loaded, skipped int + for dataChan != nil || errorChan != nil { select { case descriptor, ok := <-dataChan: @@ -174,6 +176,7 @@ func (s *Store) load() error { descriptor.Name(), err, ) + skipped++ continue } @@ -184,6 +187,7 @@ func (s *Store) load() error { descriptor.Name(), err, ) + skipped++ continue } @@ -226,6 +230,7 @@ func (s *Store) load() error { s.byRequestID[job.RequestID] = job s.byRouteKey[key] = job.RequestID + loaded++ case err, ok := <-errorChan: if !ok { errorChan = nil @@ -237,6 +242,16 @@ func (s *Store) load() error { } } + if skipped > 0 { + logger.Warnf( + "store load complete: loaded [%d] jobs, skipped [%d] unreadable or malformed files", + loaded, + skipped, + ) + } else if loaded > 0 { + logger.Infof("store load complete: loaded [%d] jobs", loaded) + } + return nil } From 0d1907669d82906defaaad332ae0e073fccfdcac Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 15:45:39 +0000 Subject: [PATCH 099/143] fix(covenantsigner): remove superseded job from byRequestID on dedup When load replaces a job during route-key deduplication, the superseded job's entry remained in byRequestID, leaking stale data in the secondary index. --- pkg/covenantsigner/store.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index e5432bdbf8..c5ac36b699 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -225,6 +225,10 @@ func (s *Store) load() error { } else if existingIsNewerOrSame { continue } + + // Remove the superseded job from the primary index + // so stale entries do not leak in byRequestID. + delete(s.byRequestID, existingID) } } From 5e77537d44fccd59c57d7da33b07a02d409c56d2 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 15:46:30 +0000 Subject: [PATCH 100/143] fix(covenantsigner): use deterministic tiebreaker when both timestamps unparseable When two duplicate-route-key jobs both have unparseable timestamps, the winner previously depended on non-deterministic file iteration order. Use lexicographic RequestID comparison as a stable tiebreaker. --- pkg/covenantsigner/store.go | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index c5ac36b699..d3922f4350 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -206,22 +206,37 @@ func (s *Store) load() error { // the existing job -- replace it. Otherwise skip the // candidate. if _, parseErr := time.Parse(time.RFC3339Nano, job.UpdatedAt); parseErr != nil { + // Both timestamps are unparseable. Use + // lexicographic RequestID as a deterministic + // tiebreaker so the outcome does not depend on + // file iteration order. + if existing.RequestID <= job.RequestID { + logger.Warnf( + "skipping job [%s] with invalid timestamp on duplicate route key [%s/%s] (keeping [%s]): [%v]", + job.RequestID, + job.Route, + job.RouteRequestID, + existing.RequestID, + err, + ) + continue + } logger.Warnf( - "skipping job [%s] with invalid timestamp on duplicate route key [%s/%s]: [%v]", - job.RequestID, + "replacing job [%s] with invalid timestamp on duplicate route key [%s/%s] (both unparseable, lexicographic tiebreak): [%v]", + existing.RequestID, + job.Route, + job.RouteRequestID, + err, + ) + } else { + logger.Warnf( + "replacing job [%s] with invalid timestamp on duplicate route key [%s/%s]: [%v]", + existing.RequestID, job.Route, job.RouteRequestID, err, ) - continue } - logger.Warnf( - "replacing job [%s] with invalid timestamp on duplicate route key [%s/%s]: [%v]", - existing.RequestID, - job.Route, - job.RouteRequestID, - err, - ) } else if existingIsNewerOrSame { continue } From 074f484dce88c0a95ab3e50f6e5e0d17943dd00a Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 16:00:53 +0000 Subject: [PATCH 101/143] fix(covenantsigner): poison route keys from skipped jobs to preserve dedupe When load() skips a malformed job file, GetByRouteRequest can no longer find that job. A retry then silently creates a duplicate signing job, breaking node-local idempotency. Fix by partially parsing skipped files to extract route keys and marking them as poisoned. GetByRouteRequest returns an error for poisoned keys, forcing the caller to investigate rather than creating a duplicate. --- pkg/covenantsigner/store.go | 73 ++++++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 9 deletions(-) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index d3922f4350..9cedaad287 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -16,11 +16,13 @@ const jobsDirectory = "covenant-signer/jobs" const lockFileName = ".lock" type Store struct { - handle persistence.BasicHandle - mutex sync.Mutex - lockFile *os.File - byRequestID map[string]*Job - byRouteKey map[string]string + handle persistence.BasicHandle + mutex sync.Mutex + lockFile *os.File + byRequestID map[string]*Job + byRouteKey map[string]string + poisonedRoutes map[string]bool + skippedJobFiles []string } // NewStore creates a new Store backed by the given persistence handle. When @@ -30,9 +32,10 @@ type Store struct { // error. When dataDir is empty (in-memory handles), file locking is skipped. func NewStore(handle persistence.BasicHandle, dataDir string) (*Store, error) { store := &Store{ - handle: handle, - byRequestID: make(map[string]*Job), - byRouteKey: make(map[string]string), + handle: handle, + byRequestID: make(map[string]*Job), + byRouteKey: make(map[string]string), + poisonedRoutes: make(map[string]bool), } if dataDir != "" { @@ -107,10 +110,50 @@ func (s *Store) Close() error { return err } +var errPoisonedRouteKey = fmt.Errorf( + "route key belongs to a job that could not be loaded; " + + "manual recovery of the corrupt job file is required", +) + func routeKey(route TemplateID, routeRequestID string) string { return fmt.Sprintf("%s:%s", route, routeRequestID) } +// poisonRouteFromPartialJob attempts a lenient parse of content to extract +// Route and RouteRequestID. If successful, the route key is marked as +// poisoned so that future submissions are rejected rather than silently +// creating a duplicate job. +func (s *Store) poisonRouteFromPartialJob(content []byte, fileName string) { + var partial struct { + Route TemplateID `json:"Route"` + RouteRequestID string `json:"RouteRequestID"` + } + if err := json.Unmarshal(content, &partial); err != nil { + return + } + if partial.Route == "" || partial.RouteRequestID == "" { + return + } + key := routeKey(partial.Route, partial.RouteRequestID) + s.poisonedRoutes[key] = true + logger.Warnf( + "poisoned route key [%s] from skipped job file [%s]", + key, + fileName, + ) +} + +// SkippedJobFiles returns the file names of job files that could not be +// loaded during startup. Operators should investigate and repair or remove +// these files. +func (s *Store) SkippedJobFiles() []string { + s.mutex.Lock() + defer s.mutex.Unlock() + result := make([]string, len(s.skippedJobFiles)) + copy(result, s.skippedJobFiles) + return result +} + func cloneJob(job *Job) (*Job, error) { payload, err := json.Marshal(job) if err != nil { @@ -176,6 +219,7 @@ func (s *Store) load() error { descriptor.Name(), err, ) + s.skippedJobFiles = append(s.skippedJobFiles, descriptor.Name()) skipped++ continue } @@ -187,6 +231,11 @@ func (s *Store) load() error { descriptor.Name(), err, ) + // Attempt partial parse to extract route info for + // poisoning. If the route key is recoverable, block + // future submissions for this route to preserve dedupe. + s.poisonRouteFromPartialJob(content, descriptor.Name()) + s.skippedJobFiles = append(s.skippedJobFiles, descriptor.Name()) skipped++ continue } @@ -295,7 +344,13 @@ func (s *Store) GetByRouteRequest(route TemplateID, routeRequestID string) (*Job s.mutex.Lock() defer s.mutex.Unlock() - requestID, ok := s.byRouteKey[routeKey(route, routeRequestID)] + key := routeKey(route, routeRequestID) + + if s.poisonedRoutes[key] { + return nil, false, errPoisonedRouteKey + } + + requestID, ok := s.byRouteKey[key] if !ok { return nil, false, nil } From b11050338629f1c2fbc00e1d682370fb96eba9da Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 16:02:53 +0000 Subject: [PATCH 102/143] fix(covenantsigner): extract Submit critical section into createOrDedup The Submit method had 5 separate mutex.Unlock() call sites, making the locking pattern fragile for a security-critical signing path. Extract the dedup-check-and-create logic into a helper that uses defer Unlock, reducing the main Submit method to two clean lock scopes. --- pkg/covenantsigner/service.go | 104 ++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 44 deletions(-) diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 1114b1382b..fa339844d8 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -230,50 +230,28 @@ func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, er return job, nil } -func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubmitInput) (StepResult, error) { - submitValidationOptions := validationOptions{ - migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, - depositorTrustRoots: s.depositorTrustRoots, - custodianTrustRoots: s.custodianTrustRoots, - requireFreshMigrationPlanQuote: true, - migrationPlanQuoteVerificationNow: s.now(), - signerApprovalVerifier: s.signerApprovalVerifier, - } - if err := validateSubmitInput(route, input, submitValidationOptions); err != nil { - return StepResult{}, err - } - - normalizedRequest, err := normalizeRouteSubmitRequest( - input.Request, - validationOptions{ - migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, - depositorTrustRoots: s.depositorTrustRoots, - custodianTrustRoots: s.custodianTrustRoots, - signerApprovalVerifier: s.signerApprovalVerifier, - }, - ) - if err != nil { - return StepResult{}, err - } - - requestDigest, err := requestDigestFromNormalized(normalizedRequest) - if err != nil { - return StepResult{}, err - } - +// createOrDedup creates a new job under the service mutex, or returns the +// existing job result if the route request is already known. Returns +// (job, nil, nil) for a new job, or (nil, result, nil) for a dedup hit. +func (s *Service) createOrDedup( + route TemplateID, + input SignerSubmitInput, + normalizedRequest RouteSubmitRequest, + requestDigest string, +) (*Job, *StepResult, error) { s.mutex.Lock() + defer s.mutex.Unlock() + if existing, ok, err := s.store.GetByRouteRequest(route, input.RouteRequestID); err != nil { - s.mutex.Unlock() - return StepResult{}, err + return nil, nil, err } else if ok { if existing.RequestDigest != requestDigest { - s.mutex.Unlock() - return StepResult{}, &inputError{ + return nil, nil, &inputError{ "routeRequestId already exists with a different request payload", } } - s.mutex.Unlock() - return mapJobResult(existing), nil + result := mapJobResult(existing) + return nil, &result, nil } requestIDPrefix := "" @@ -283,14 +261,12 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm case TemplateSelfV1: requestIDPrefix = "kcs_self" default: - s.mutex.Unlock() - return StepResult{}, fmt.Errorf("unsupported route: %s", route) + return nil, nil, fmt.Errorf("unsupported route: %s", route) } requestID, err := newRequestID(requestIDPrefix) if err != nil { - s.mutex.Unlock() - return StepResult{}, err + return nil, nil, err } now := s.now() @@ -310,10 +286,50 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm } if err := s.store.Put(job); err != nil { - s.mutex.Unlock() + return nil, nil, err + } + + return job, nil, nil +} + +func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubmitInput) (StepResult, error) { + submitValidationOptions := validationOptions{ + migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + depositorTrustRoots: s.depositorTrustRoots, + custodianTrustRoots: s.custodianTrustRoots, + requireFreshMigrationPlanQuote: true, + migrationPlanQuoteVerificationNow: s.now(), + signerApprovalVerifier: s.signerApprovalVerifier, + } + if err := validateSubmitInput(route, input, submitValidationOptions); err != nil { return StepResult{}, err } - s.mutex.Unlock() + + normalizedRequest, err := normalizeRouteSubmitRequest( + input.Request, + validationOptions{ + migrationPlanQuoteTrustRoots: s.migrationPlanQuoteTrustRoots, + depositorTrustRoots: s.depositorTrustRoots, + custodianTrustRoots: s.custodianTrustRoots, + signerApprovalVerifier: s.signerApprovalVerifier, + }, + ) + if err != nil { + return StepResult{}, err + } + + requestDigest, err := requestDigestFromNormalized(normalizedRequest) + if err != nil { + return StepResult{}, err + } + + job, existingResult, err := s.createOrDedup(route, input, normalizedRequest, requestDigest) + if err != nil { + return StepResult{}, err + } + if existingResult != nil { + return *existingResult, nil + } transition, err := s.engine.OnSubmit(ctx, job) if err != nil { @@ -330,7 +346,7 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm s.mutex.Lock() defer s.mutex.Unlock() - currentJob, ok, err := s.store.GetByRequestID(requestID) + currentJob, ok, err := s.store.GetByRequestID(job.RequestID) if err != nil { return StepResult{}, err } From ef45c15056715017ee3c1b0ce7b4fb3c38e8eb1e Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 16:03:35 +0000 Subject: [PATCH 103/143] fix(covenantsigner): cancel service context on init failure and OS signals Two fixes: - Call cancelService() when net.Listen fails after context creation to prevent a context leak on initialization error. - Add SIGINT/SIGTERM signal handling so in-flight signing operations are cancelled promptly on any shutdown path, not only when the parent context is cancelled. --- pkg/covenantsigner/server.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 0bf38bf3fb..4679c8876a 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -10,8 +10,10 @@ import ( "net" "net/http" "net/url" + "os/signal" "strconv" "strings" + "syscall" "time" "github.com/ipfs/go-log/v2" @@ -124,11 +126,25 @@ func Initialize( listener, err := net.Listen("tcp", server.httpServer.Addr) if err != nil { + cancelService() return nil, false, fmt.Errorf("failed to bind covenant signer port [%d]: %w", config.Port, err) } + // Listen for both the parent context cancellation and OS signals so + // that in-flight signing operations are cancelled promptly on any + // shutdown path, including SIGINT/SIGTERM. + signalCtx, stopSignal := signal.NotifyContext( + context.Background(), + syscall.SIGINT, + syscall.SIGTERM, + ) + go func() { - <-ctx.Done() + select { + case <-ctx.Done(): + case <-signalCtx.Done(): + } + stopSignal() // Cancel the service context so in-flight threshold signing // operations observe shutdown and terminate promptly. From 2e0da12b0b7781ca92d8262985bd49a20b502cca Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 16:03:50 +0000 Subject: [PATCH 104/143] docs(covenantsigner): document advisory flock limitations and storage requirements POSIX flock is advisory and Linux-specific. Document that the data directory must use local or block-level storage with single-writer access, not network filesystems. --- pkg/covenantsigner/store.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index 9cedaad287..526d6b7256 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -58,6 +58,12 @@ func NewStore(handle persistence.BasicHandle, dataDir string) (*Store, error) { // acquireFileLock creates and acquires an exclusive non-blocking advisory lock // on a lock file inside the jobs directory. The returned file handle must be // kept open for the lifetime of the lock; closing it releases the lock. +// +// IMPORTANT: This uses POSIX flock(2), which is advisory and Linux-specific. +// It protects against concurrent processes on the same host but does NOT +// protect against concurrent access over network filesystems (NFS, EFS, +// CIFS). The data directory MUST reside on local or block-level storage +// with single-writer access (e.g., Kubernetes ReadWriteOnce PV). func acquireFileLock(dataDir string) (*os.File, error) { lockPath := filepath.Join(dataDir, jobsDirectory, lockFileName) From 1de5838344d2212b4282aefcd758785b7c8e437a Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 16:04:30 +0000 Subject: [PATCH 105/143] fix(tbtc): improve error messages and docs for degraded wallet registry Three improvements for operator visibility during registry outages: - Sentinel errors now mention that the wallet registry may be unavailable, helping operators distinguish registry failures from genuinely missing data. - GetWallet log elevated from Warn to Error with actionable message explaining that signer approval operations will fail. - WalletChainData godoc documents zero-value semantics for registry- sourced fields. --- pkg/chain/ethereum/tbtc.go | 6 ++++-- pkg/tbtc/chain.go | 6 ++++++ pkg/tbtc/signer_approval_certificate.go | 10 ++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index d2d9efaf63..5b359c2cae 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1478,8 +1478,10 @@ func (tc *TbtcChain) GetWallet( walletRegistryWallet, err := tc.walletRegistry.GetWallet(wallet.EcdsaWalletID) if err != nil { - logger.Warnf( - "cannot get wallet registry data for wallet [0x%x]: [%v]", + logger.Errorf( + "wallet registry unavailable for wallet [0x%x]; "+ + "MembersIDsHash will be zero -- signer approval "+ + "operations will fail until the registry recovers: [%v]", wallet.EcdsaWalletID, err, ) diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index c70e4b73c0..391906c91a 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -415,6 +415,12 @@ type DepositChainRequest struct { } // WalletChainData represents wallet data stored on-chain. +// +// EcdsaWalletID and MembersIDsHash are sourced from the wallet registry. +// When the registry is unavailable during a fault-isolated GetWallet call, +// these fields contain their zero values. Consumers that require registry +// data (e.g. signer approval certificate computation) must guard against +// zero values -- see ErrMissingWalletID and ErrMissingMembersIDsHash. type WalletChainData struct { EcdsaWalletID [32]byte MembersIDsHash [32]byte diff --git a/pkg/tbtc/signer_approval_certificate.go b/pkg/tbtc/signer_approval_certificate.go index 72a5dfd5c8..442e995ccd 100644 --- a/pkg/tbtc/signer_approval_certificate.go +++ b/pkg/tbtc/signer_approval_certificate.go @@ -22,12 +22,18 @@ var ( // ErrMissingWalletID is returned when wallet chain data does not // include a wallet ID, typically because the wallet registry was // unavailable during a fault-isolated GetWallet call. - ErrMissingWalletID = fmt.Errorf("wallet chain data must include wallet ID") + ErrMissingWalletID = fmt.Errorf( + "wallet chain data must include wallet ID; " + + "the wallet registry may be unavailable", + ) // ErrMissingMembersIDsHash is returned when wallet chain data does // not include a members IDs hash, typically because the wallet // registry was unavailable during a fault-isolated GetWallet call. - ErrMissingMembersIDsHash = fmt.Errorf("wallet chain data must include members IDs hash") + ErrMissingMembersIDsHash = fmt.Errorf( + "wallet chain data must include members IDs hash; " + + "the wallet registry may be unavailable", + ) ) const ( From 0c4f0be7746edc64a0de5b967ad2c690b6e31ee1 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 16:05:42 +0000 Subject: [PATCH 106/143] fix(tbtc): use canonicaljson.Marshal for handoff payload hash Switch from encoding/json.Marshal to canonicaljson.Marshal for the content-addressed handoff bundle ID. Both produce identical output for current payloads (alphabetical key ordering, no HTML content), but canonicaljson explicitly disables HTML escaping, making the serialization contract clearer for non-Go consumers. --- pkg/tbtc/covenant_signer.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index ba1ed8b7e2..9858c9cf77 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -16,6 +16,7 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/covenantsigner" + "github.com/keep-network/keep-core/pkg/internal/canonicaljson" "github.com/keep-network/keep-core/pkg/tecdsa" ) @@ -870,10 +871,14 @@ func buildWitnessSignatureBytes(signature *tecdsa.Signature) ([]byte, error) { } func computeQcV1SignerHandoffPayloadHash(payload map[string]any) (string, error) { - // The handoff bundle ID is content-addressed using Go's stable JSON map-key - // ordering. Future non-Go custodian consumers that want to recompute this - // hash must preserve the same canonical field set and serialization rules. - rawPayload, err := json.Marshal(payload) + // The handoff bundle ID is content-addressed using canonical JSON + // (alphabetical key ordering, no HTML escaping, no trailing newline). + // Go's encoding/json.Marshal already sorts map keys alphabetically + // (since Go 1.12), so using canonicaljson.Marshal produces identical + // output for non-HTML content while also disabling HTML escaping for + // safety. Non-Go custodian consumers that recompute this hash must + // use the same canonical serialization rules. + rawPayload, err := canonicaljson.Marshal(payload) if err != nil { return "", err } From 9229203deddf9fd02772691f826bc5362cf292f5 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 16:07:06 +0000 Subject: [PATCH 107/143] fix(covenantsigner): correctly distinguish single vs both unparseable timestamps The earlier tiebreaker fix incorrectly entered the "both unparseable" branch when only the candidate had an invalid timestamp. Parse both timestamps explicitly to distinguish three cases: only candidate bad (keep existing), only existing bad (replace), both bad (lexicographic tiebreak). --- pkg/covenantsigner/store.go | 42 +++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index 526d6b7256..d0407824bb 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -260,14 +260,38 @@ func (s *Store) load() error { // candidate's timestamp is valid, the failure is on // the existing job -- replace it. Otherwise skip the // candidate. - if _, parseErr := time.Parse(time.RFC3339Nano, job.UpdatedAt); parseErr != nil { + _, existingParseErr := time.Parse(time.RFC3339Nano, existing.UpdatedAt) + _, candidateParseErr := time.Parse(time.RFC3339Nano, job.UpdatedAt) + + switch { + case candidateParseErr != nil && existingParseErr == nil: + // Only the candidate is unparseable; keep existing. + logger.Warnf( + "skipping job [%s] with invalid timestamp on duplicate route key [%s/%s] (keeping [%s]): [%v]", + job.RequestID, + job.Route, + job.RouteRequestID, + existing.RequestID, + err, + ) + continue + case candidateParseErr == nil && existingParseErr != nil: + // Only the existing is unparseable; replace with candidate. + logger.Warnf( + "replacing job [%s] with invalid timestamp on duplicate route key [%s/%s]: [%v]", + existing.RequestID, + job.Route, + job.RouteRequestID, + err, + ) + default: // Both timestamps are unparseable. Use // lexicographic RequestID as a deterministic - // tiebreaker so the outcome does not depend on - // file iteration order. + // tiebreaker so the outcome does not depend + // on file iteration order. if existing.RequestID <= job.RequestID { logger.Warnf( - "skipping job [%s] with invalid timestamp on duplicate route key [%s/%s] (keeping [%s]): [%v]", + "skipping job [%s] on duplicate route key [%s/%s] (keeping [%s], lexicographic tiebreak): [%v]", job.RequestID, job.Route, job.RouteRequestID, @@ -277,15 +301,7 @@ func (s *Store) load() error { continue } logger.Warnf( - "replacing job [%s] with invalid timestamp on duplicate route key [%s/%s] (both unparseable, lexicographic tiebreak): [%v]", - existing.RequestID, - job.Route, - job.RouteRequestID, - err, - ) - } else { - logger.Warnf( - "replacing job [%s] with invalid timestamp on duplicate route key [%s/%s]: [%v]", + "replacing job [%s] on duplicate route key [%s/%s] (lexicographic tiebreak): [%v]", existing.RequestID, job.Route, job.RouteRequestID, From 8f800e98d6bbc8194c5c1f43cd835c93504226c2 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 16:17:24 +0000 Subject: [PATCH 108/143] fix(covenantsigner): correct context preservation test to use service context The test injected a value into the HTTP request context and expected it to be visible in the engine, but the submit handler deliberately derives its context from the service context (not the request context) to survive HTTP disconnects. Fix the test to inject the value into the service context, which matches the actual design contract. --- pkg/covenantsigner/server_test.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/pkg/covenantsigner/server_test.go b/pkg/covenantsigner/server_test.go index 4b4f651e65..a81d4c853d 100644 --- a/pkg/covenantsigner/server_test.go +++ b/pkg/covenantsigner/server_test.go @@ -867,7 +867,7 @@ func TestSubmitHandlerPreCancelledContextStillSucceeds(t *testing.T) { type contextKey string -func TestSubmitHandlerPreservesContextValues(t *testing.T) { +func TestSubmitHandlerPreservesServiceContextValues(t *testing.T) { const testKey contextKey = "test-trace-id" const testValue = "trace-abc-123" @@ -889,15 +889,13 @@ func TestSubmitHandlerPreservesContextValues(t *testing.T) { t.Fatal(err) } - // Wrap the handler with middleware that injects a value into the request - // context. The detached context should preserve this value. - innerHandler := newHandler(service, context.Background(), "", true) - wrappedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - enrichedCtx := context.WithValue(r.Context(), testKey, testValue) - innerHandler.ServeHTTP(w, r.WithContext(enrichedCtx)) - }) + // Inject a value into the service context. The submit handler derives + // its timeout context from serviceCtx (not from the HTTP request), so + // values on the service context must be visible to the engine. + serviceCtx := context.WithValue(context.Background(), testKey, testValue) + handler := newHandler(service, serviceCtx, "", true) - server := httptest.NewServer(wrappedHandler) + server := httptest.NewServer(handler) defer server.Close() submitPayload := mustJSON(t, SignerSubmitInput{ @@ -925,7 +923,7 @@ func TestSubmitHandlerPreservesContextValues(t *testing.T) { defer mu.Unlock() if capturedValue != testValue { t.Fatalf( - "expected context value %q to be preserved through detachment, "+ + "expected service context value %q to be visible in engine, "+ "got %v", testValue, capturedValue, From 73c886352a88a4bd55084d6cb2b916a2e05cec7e Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 17:46:01 +0000 Subject: [PATCH 109/143] fix(covenantsigner): remove subsystem signal handler that steals process signals The signal.NotifyContext for SIGINT/SIGTERM inside Initialize consumed the first signal, cancelling only the signer's service context while the rest of the node kept running. The process root context in cmd/start.go is context.Background() and relies on the OS default signal handler to terminate. Revert to the original parent-context-only shutdown path. --- pkg/covenantsigner/server.go | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 4679c8876a..917a9a5651 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -10,10 +10,8 @@ import ( "net" "net/http" "net/url" - "os/signal" "strconv" "strings" - "syscall" "time" "github.com/ipfs/go-log/v2" @@ -130,21 +128,8 @@ func Initialize( return nil, false, fmt.Errorf("failed to bind covenant signer port [%d]: %w", config.Port, err) } - // Listen for both the parent context cancellation and OS signals so - // that in-flight signing operations are cancelled promptly on any - // shutdown path, including SIGINT/SIGTERM. - signalCtx, stopSignal := signal.NotifyContext( - context.Background(), - syscall.SIGINT, - syscall.SIGTERM, - ) - go func() { - select { - case <-ctx.Done(): - case <-signalCtx.Done(): - } - stopSignal() + <-ctx.Done() // Cancel the service context so in-flight threshold signing // operations observe shutdown and terminate promptly. From 7cf01ab47fffc5bac04540438e8b5dd8ab85f2ca Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 9 Apr 2026 17:46:06 +0000 Subject: [PATCH 110/143] fix(covenantsigner): clear route poison when a valid job loads for same key A malformed file processed before its valid sibling would poison the route key, then the valid job would load into byRouteKey but remain inaccessible via GetByRouteRequest because the poison check ran first. Clear the poison entry when a valid job is successfully indexed for that route key. --- pkg/covenantsigner/store.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index d0407824bb..1e70bb2586 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -320,6 +320,9 @@ func (s *Store) load() error { s.byRequestID[job.RequestID] = job s.byRouteKey[key] = job.RequestID + // A valid job for this route supersedes any earlier poison + // from a malformed sibling file for the same route key. + delete(s.poisonedRoutes, key) loaded++ case err, ok := <-errorChan: if !ok { From c908172655bf95c8e4910d3e91ffc44f24dd22fa Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Mon, 13 Apr 2026 01:19:22 -0300 Subject: [PATCH 111/143] fix(covenantsigner): replace poison-route with hard-fail on corrupt persisted job files The poison-route mechanism (A22) attempted graceful degradation by skipping corrupt files and marking their route keys as poisoned. This had two fundamental issues: (1) an ordering-dependent bug where a valid job iterated before its malformed sibling left the route permanently poisoned despite a valid job being loaded, and (2) when the corrupt file's route key was unrecoverable (binary garbage), no poison was set and dedupe protection was silently lost. For a pre-mainnet Bitcoin signing service, fail-closed is the safer default: ambiguous persisted state is a safety fault, not a recoverable nuisance. A single corrupt file should block startup rather than risk duplicate signing jobs. Changes: - Store.load() now returns a hard error on unreadable or malformed persisted job files instead of skipping them - Removed poisonedRoutes map, errPoisonedRouteKey sentinel, poisonRouteFromPartialJob, SkippedJobFiles, and poison check in GetByRouteRequest - Updated tests to expect hard failure on corrupt files - Added superseded-job-removal assertions to dedup tests --- pkg/covenantsigner/store.go | 94 +++++--------------------------- pkg/covenantsigner/store_test.go | 58 ++++++++++---------- 2 files changed, 44 insertions(+), 108 deletions(-) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index 1e70bb2586..b8bde3ddde 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -16,13 +16,11 @@ const jobsDirectory = "covenant-signer/jobs" const lockFileName = ".lock" type Store struct { - handle persistence.BasicHandle - mutex sync.Mutex - lockFile *os.File - byRequestID map[string]*Job - byRouteKey map[string]string - poisonedRoutes map[string]bool - skippedJobFiles []string + handle persistence.BasicHandle + mutex sync.Mutex + lockFile *os.File + byRequestID map[string]*Job + byRouteKey map[string]string } // NewStore creates a new Store backed by the given persistence handle. When @@ -32,10 +30,9 @@ type Store struct { // error. When dataDir is empty (in-memory handles), file locking is skipped. func NewStore(handle persistence.BasicHandle, dataDir string) (*Store, error) { store := &Store{ - handle: handle, - byRequestID: make(map[string]*Job), - byRouteKey: make(map[string]string), - poisonedRoutes: make(map[string]bool), + handle: handle, + byRequestID: make(map[string]*Job), + byRouteKey: make(map[string]string), } if dataDir != "" { @@ -116,50 +113,10 @@ func (s *Store) Close() error { return err } -var errPoisonedRouteKey = fmt.Errorf( - "route key belongs to a job that could not be loaded; " + - "manual recovery of the corrupt job file is required", -) - func routeKey(route TemplateID, routeRequestID string) string { return fmt.Sprintf("%s:%s", route, routeRequestID) } -// poisonRouteFromPartialJob attempts a lenient parse of content to extract -// Route and RouteRequestID. If successful, the route key is marked as -// poisoned so that future submissions are rejected rather than silently -// creating a duplicate job. -func (s *Store) poisonRouteFromPartialJob(content []byte, fileName string) { - var partial struct { - Route TemplateID `json:"Route"` - RouteRequestID string `json:"RouteRequestID"` - } - if err := json.Unmarshal(content, &partial); err != nil { - return - } - if partial.Route == "" || partial.RouteRequestID == "" { - return - } - key := routeKey(partial.Route, partial.RouteRequestID) - s.poisonedRoutes[key] = true - logger.Warnf( - "poisoned route key [%s] from skipped job file [%s]", - key, - fileName, - ) -} - -// SkippedJobFiles returns the file names of job files that could not be -// loaded during startup. Operators should investigate and repair or remove -// these files. -func (s *Store) SkippedJobFiles() []string { - s.mutex.Lock() - defer s.mutex.Unlock() - result := make([]string, len(s.skippedJobFiles)) - copy(result, s.skippedJobFiles) - return result -} - func cloneJob(job *Job) (*Job, error) { payload, err := json.Marshal(job) if err != nil { @@ -204,7 +161,7 @@ func (s *Store) load() error { dataChan, errorChan := s.handle.ReadAll() - var loaded, skipped int + var loaded int for dataChan != nil || errorChan != nil { select { @@ -220,30 +177,20 @@ func (s *Store) load() error { content, err := descriptor.Content() if err != nil { - logger.Warnf( - "skipping unreadable job file [%s]: [%v]", + return fmt.Errorf( + "cannot read persisted covenant signer job file [%s]: %w", descriptor.Name(), err, ) - s.skippedJobFiles = append(s.skippedJobFiles, descriptor.Name()) - skipped++ - continue } job := &Job{} if err := json.Unmarshal(content, job); err != nil { - logger.Warnf( - "skipping malformed job file [%s]: [%v]", + return fmt.Errorf( + "cannot parse persisted covenant signer job file [%s]: %w", descriptor.Name(), err, ) - // Attempt partial parse to extract route info for - // poisoning. If the route key is recoverable, block - // future submissions for this route to preserve dedupe. - s.poisonRouteFromPartialJob(content, descriptor.Name()) - s.skippedJobFiles = append(s.skippedJobFiles, descriptor.Name()) - skipped++ - continue } key := routeKey(job.Route, job.RouteRequestID) @@ -320,9 +267,6 @@ func (s *Store) load() error { s.byRequestID[job.RequestID] = job s.byRouteKey[key] = job.RequestID - // A valid job for this route supersedes any earlier poison - // from a malformed sibling file for the same route key. - delete(s.poisonedRoutes, key) loaded++ case err, ok := <-errorChan: if !ok { @@ -335,13 +279,7 @@ func (s *Store) load() error { } } - if skipped > 0 { - logger.Warnf( - "store load complete: loaded [%d] jobs, skipped [%d] unreadable or malformed files", - loaded, - skipped, - ) - } else if loaded > 0 { + if loaded > 0 { logger.Infof("store load complete: loaded [%d] jobs", loaded) } @@ -371,10 +309,6 @@ func (s *Store) GetByRouteRequest(route TemplateID, routeRequestID string) (*Job key := routeKey(route, routeRequestID) - if s.poisonedRoutes[key] { - return nil, false, errPoisonedRouteKey - } - requestID, ok := s.byRouteKey[key] if !ok { return nil, false, nil diff --git a/pkg/covenantsigner/store_test.go b/pkg/covenantsigner/store_test.go index 5c1e1589e8..96b47c8aa8 100644 --- a/pkg/covenantsigner/store_test.go +++ b/pkg/covenantsigner/store_test.go @@ -203,6 +203,12 @@ func TestStoreLoadSelectsNewestJobForDuplicateRouteKeys(t *testing.T) { if loaded.RequestID != newJob.RequestID { t.Fatalf("expected newest request ID %s, got %s", newJob.RequestID, loaded.RequestID) } + + if _, ok, err := store.GetByRequestID(oldJob.RequestID); err != nil { + t.Fatal(err) + } else if ok { + t.Fatalf("expected superseded request ID %s to be removed", oldJob.RequestID) + } } func TestStoreLoadResolvesInvalidUpdatedAtForDuplicateRouteKeys(t *testing.T) { @@ -269,9 +275,15 @@ func TestStoreLoadResolvesInvalidUpdatedAtForDuplicateRouteKeys(t *testing.T) { if loaded.RequestID != first.RequestID { t.Fatalf("expected request ID %s, got %s", first.RequestID, loaded.RequestID) } + + if _, ok, err := store.GetByRequestID(second.RequestID); err != nil { + t.Fatal(err) + } else if ok { + t.Fatalf("expected invalid duplicate request ID %s to be removed", second.RequestID) + } } -func TestStoreLoadSkipsUnreadableFile(t *testing.T) { +func TestStoreLoadRejectsUnreadablePersistedJobFile(t *testing.T) { handle := newContentFaultingHandle() validJob := &Job{ @@ -302,24 +314,16 @@ func TestStoreLoadSkipsUnreadableFile(t *testing.T) { errors.New("simulated disk read error"), ) - store, err := NewStore(handle, "") - if err != nil { - t.Fatalf("expected store to load despite unreadable file, got error: %v", err) + _, err = NewStore(handle, "") + if err == nil { + t.Fatal("expected unreadable persisted job file to fail store load") } - - loaded, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_readable") - if err != nil { - t.Fatal(err) - } - if !ok { - t.Fatal("expected valid job to be loaded despite corrupted sibling") - } - if loaded.RequestID != validJob.RequestID { - t.Fatalf("expected request ID %s, got %s", validJob.RequestID, loaded.RequestID) + if !strings.Contains(err.Error(), "cannot read persisted covenant signer job file [corrupted_file.json]") { + t.Fatalf("unexpected error: %v", err) } } -func TestStoreLoadSkipsMalformedJSON(t *testing.T) { +func TestStoreLoadRejectsMalformedPersistedJobFile(t *testing.T) { handle := newMemoryHandle() validJob := &Job{ @@ -348,20 +352,12 @@ func TestStoreLoadSkipsMalformedJSON(t *testing.T) { t.Fatal(err) } - store, err := NewStore(handle, "") - if err != nil { - t.Fatalf("expected store to load despite malformed JSON file, got error: %v", err) - } - - loaded, ok, err := store.GetByRouteRequest(TemplateSelfV1, "ors_valid_json") - if err != nil { - t.Fatal(err) + _, err = NewStore(handle, "") + if err == nil { + t.Fatal("expected malformed persisted job file to fail store load") } - if !ok { - t.Fatal("expected valid job to be loaded despite malformed sibling") - } - if loaded.RequestID != validJob.RequestID { - t.Fatalf("expected request ID %s, got %s", validJob.RequestID, loaded.RequestID) + if !strings.Contains(err.Error(), "cannot parse persisted covenant signer job file [malformed.json]") { + t.Fatalf("unexpected error: %v", err) } } @@ -427,4 +423,10 @@ func TestStoreLoadSkipsInvalidTimestampOnDuplicateRouteKey(t *testing.T) { if loaded.RequestID != validJob.RequestID { t.Fatalf("expected request ID %s, got %s", validJob.RequestID, loaded.RequestID) } + + if _, ok, err := store.GetByRequestID(badTimestampJob.RequestID); err != nil { + t.Fatal(err) + } else if ok { + t.Fatalf("expected invalid duplicate request ID %s to be removed", badTimestampJob.RequestID) + } } From da317eda4927eacb1a6b2fa79887813bac38de8a Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Mon, 13 Apr 2026 14:56:48 -0300 Subject: [PATCH 112/143] fix(covenantsigner): suppress gosec false positives on store lock file operations Add #nosec annotations for three findings in store.go: - G304 on os.OpenFile: lockPath is built from operator config (dataDir) plus constants (jobsDirectory, lockFileName), not user input - G104 on lockFile.Close(): best-effort cleanup after failed flock; the lock error is what gets returned - G104 on store.Close(): best-effort lock release after failed load; the load error is what gets returned --- pkg/covenantsigner/server.go | 2 +- pkg/covenantsigner/store.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 917a9a5651..01be600272 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -136,7 +136,7 @@ func Initialize( cancelService() shutdownCtx, cancelShutdown := context.WithTimeout( - context.Background(), + context.Background(), // #nosec G118 -- parent ctx is already cancelled; shutdown needs a fresh deadline 5*time.Second, ) defer cancelShutdown() diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index b8bde3ddde..a0666a1617 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -45,7 +45,7 @@ func NewStore(handle persistence.BasicHandle, dataDir string) (*Store, error) { if err := store.load(); err != nil { // Release the lock if loading fails after successful acquisition. - store.Close() + store.Close() // #nosec G104 -- best-effort cleanup; original err is returned return nil, err } @@ -72,7 +72,7 @@ func acquireFileLock(dataDir string) (*os.File, error) { ) } - lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) // #nosec G304 -- lockPath is built from operator config + constants if err != nil { return nil, fmt.Errorf( "cannot open lock file [%s]: %w", @@ -85,7 +85,7 @@ func acquireFileLock(dataDir string) (*os.File, error) { int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB, ); err != nil { - lockFile.Close() + lockFile.Close() // #nosec G104 -- best-effort cleanup; lock err is returned return nil, fmt.Errorf( "cannot acquire exclusive lock on [%s]: "+ "another process may already own the store: %w", From 499accb940f3680ae38cb9fc3ba2c496f44ceba7 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 14 Apr 2026 08:21:53 +0000 Subject: [PATCH 113/143] fix(covenantsigner): move gosec G118 nosec annotation to goroutine declaration gosec flags the `go func()` declaration line, not the `context.Background()` call inside the body. Move the annotation to the correct line so the client-scan CI job passes. --- pkg/covenantsigner/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 01be600272..376bc27e3e 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -128,7 +128,7 @@ func Initialize( return nil, false, fmt.Errorf("failed to bind covenant signer port [%d]: %w", config.Port, err) } - go func() { + go func() { // #nosec G118 -- parent ctx is already cancelled; shutdown needs a fresh deadline <-ctx.Done() // Cancel the service context so in-flight threshold signing @@ -136,7 +136,7 @@ func Initialize( cancelService() shutdownCtx, cancelShutdown := context.WithTimeout( - context.Background(), // #nosec G118 -- parent ctx is already cancelled; shutdown needs a fresh deadline + context.Background(), 5*time.Second, ) defer cancelShutdown() From 1ca21770f052272bb729658cdfc002ef79ef69c1 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 09:28:05 +0000 Subject: [PATCH 114/143] fix(cmd): auto-derive DataDir from storage dir when signer port is set --- cmd/start.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/start.go b/cmd/start.go index 1b9267b913..50e8a967d0 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "path/filepath" "time" commonEthereum "github.com/keep-network/keep-common/pkg/chain/ethereum" @@ -255,9 +256,16 @@ func startWithDeps(cmd *cobra.Command, deps startDeps) error { return fmt.Errorf("error initializing TBTC: [%v]", err) } + signerConfig := clientConfig.CovenantSigner + if signerConfig.DataDir == "" && signerConfig.Port != 0 { + signerConfig.DataDir = filepath.Join( + filepath.Clean(clientConfig.Storage.Dir), "work", "tbtc", + ) + } + _, _, err = deps.initializeSigner( ctx, - clientConfig.CovenantSigner, + signerConfig, tbtcDataPersistence, covenantSignerEngine, ) From 43bb5f63e89deec2b7bb0e521e2bb887aceb1173 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 09:28:18 +0000 Subject: [PATCH 115/143] fix(cmd): handle SIGINT/SIGTERM for graceful shutdown --- cmd/start.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/start.go b/cmd/start.go index 50e8a967d0..71d8d2fbdf 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -3,7 +3,9 @@ package cmd import ( "context" "fmt" + "os/signal" "path/filepath" + "syscall" "time" commonEthereum "github.com/keep-network/keep-common/pkg/chain/ethereum" @@ -143,7 +145,8 @@ func start(cmd *cobra.Command) error { } func startWithDeps(cmd *cobra.Command, deps startDeps) error { - ctx := context.Background() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() beaconChain, tbtcChain, blockCounter, signing, operatorPrivateKey, err := deps.connectEthereum(ctx, clientConfig.Ethereum) From e16b9e1aba7324ce009b875826c0cf9eff860b41 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 09:28:39 +0000 Subject: [PATCH 116/143] fix(covenantsigner): evict stale byRequestID entry unconditionally on route key replace --- pkg/covenantsigner/service.go | 9 +++++++++ pkg/covenantsigner/store.go | 3 +-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index fa339844d8..096770781c 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -214,6 +214,15 @@ func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, er return nil, &inputError{"routeRequestId does not match stored job"} } + // Verify this job is still the current holder of its route key. A Put() + // for a newer job may have evicted the in-memory entry while the file + // delete failed, leaving a stale byRequestID entry. If the route key + // now points to a different request, treat this job as not found. + holder, holderOk, holderErr := s.store.GetByRouteRequest(route, job.RouteRequestID) + if holderErr != nil || !holderOk || holder.RequestID != job.RequestID { + return nil, errJobNotFound + } + digest, err := requestDigest( input.Request, validationOptions{ diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index a0666a1617..ef356742b1 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -357,9 +357,8 @@ func (s *Store) Put(job *Job) error { existingRequestID+".json", err, ) - } else { - delete(s.byRequestID, existingRequestID) } + delete(s.byRequestID, existingRequestID) } return nil From bd2798c248914fa127b6ca8ce5bf0b9894b0c66a Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 09:28:50 +0000 Subject: [PATCH 117/143] fix(covenantsigner): clear poisoned route key on successful Put --- pkg/covenantsigner/store.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index ef356742b1..a15d26e317 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -349,6 +349,7 @@ func (s *Store) Put(job *Job) error { s.byRequestID[job.RequestID] = cloned s.byRouteKey[key] = job.RequestID + delete(s.poisonedRoutes, key) if hasExisting && existingRequestID != job.RequestID { if err := s.handle.Delete(jobsDirectory, existingRequestID+".json"); err != nil { From 3b0cc933e96266be42c408f514b859e619aa799d Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 09:29:04 +0000 Subject: [PATCH 118/143] fix(covenantsigner): trim whitespace from ReservationID in normalizeMigrationDestination --- pkg/covenantsigner/validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 11e1c653e7..67bb88f621 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -1563,7 +1563,7 @@ func normalizeMigrationDestination( } return &MigrationDestinationReservation{ - ReservationID: destination.ReservationID, + ReservationID: strings.TrimSpace(destination.ReservationID), Reserve: normalizeLowerHex(destination.Reserve), Epoch: destination.Epoch, Route: destination.Route, From f57dc1555356094b7758fbdcadb841d2ec42443c Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 09:29:25 +0000 Subject: [PATCH 119/143] fix(covenantsigner): normalize and validate migrationPlanQuoteTrustRoots KeyID whitespace --- pkg/covenantsigner/service.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 096770781c..45299a4bd4 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "reflect" + "strings" "sync" "time" @@ -116,6 +117,14 @@ func NewService( } service.custodianTrustRoots = normalizedCustodianTrustRoots + for i := range service.migrationPlanQuoteTrustRoots { + trimmed := strings.TrimSpace(service.migrationPlanQuoteTrustRoots[i].KeyID) + if trimmed == "" { + return nil, fmt.Errorf("migration plan quote trust root KeyID at index %d is empty after trimming", i) + } + service.migrationPlanQuoteTrustRoots[i].KeyID = trimmed + } + return service, nil } From ab7ff58446e227b70b21f13e92f0f457aae2efeb Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 09:29:38 +0000 Subject: [PATCH 120/143] fix(tbtc): enforce low-S normalization on signer approval certificate signature --- pkg/tbtc/signer_approval_certificate.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/tbtc/signer_approval_certificate.go b/pkg/tbtc/signer_approval_certificate.go index 442e995ccd..12e6368d7d 100644 --- a/pkg/tbtc/signer_approval_certificate.go +++ b/pkg/tbtc/signer_approval_certificate.go @@ -253,6 +253,11 @@ func verifySignerApprovalCertificate( return fmt.Errorf("cannot parse threshold signature: %w", err) } + halfOrder := new(big.Int).Rsh(btcec.S256().N, 1) + if parsedSignature.S.Cmp(halfOrder) > 0 { + return fmt.Errorf("threshold signature S value is not low-S normalized") + } + if !ecdsa.Verify(walletPublicKey, approvalDigest, parsedSignature.R, parsedSignature.S) { return fmt.Errorf("threshold signature does not verify against wallet public key") } From ed0b026045026513f71c89f2f41ec062979cabcb Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 09:29:58 +0000 Subject: [PATCH 121/143] fix(covenantsigner): release file lock on NewService initialization failure --- pkg/covenantsigner/service.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 45299a4bd4..aaedf3974a 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -79,7 +79,7 @@ func NewService( handle persistence.BasicHandle, engine Engine, options ...ServiceOption, -) (*Service, error) { +) (_ *Service, retErr error) { if engine == nil { engine = NewPassiveEngine() } @@ -100,6 +100,12 @@ func NewService( return nil, err } service.store = store + // Release the file lock if any subsequent initialization step fails. + defer func() { + if retErr != nil { + service.store.Close() + } + }() normalizedDepositorTrustRoots, err := normalizeDepositorTrustRoots( service.depositorTrustRoots, From 314e586c8b9e94a3e2b33f390c1b365e3feee7c3 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 10:12:06 +0000 Subject: [PATCH 122/143] docs(tbtc): document qcV1SignerHandoff as downstream handoff API schema --- pkg/tbtc/covenant_signer.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index 9858c9cf77..ddbd6c88e0 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -33,6 +33,11 @@ const defaultMinActiveOutpointConfirmations uint = 6 const qcV1SignerHandoffKind = "qc_v1_signer_handoff_v1" +// qcV1SignerHandoff is the downstream handoff artifact produced for qc_v1 +// signing jobs. Its field names and the qcV1SignerHandoffKind constant form +// part of the external handoff API contract consumed by the handoff +// processor. Do not rename fields or change the Kind value without a +// corresponding schema version bump. type qcV1SignerHandoff struct { Kind string SignerRequestID string From 6db12e336904a7707e1ef6fa1dc19b33c16a4cbf Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 10:12:21 +0000 Subject: [PATCH 123/143] docs(covenantsigner): document Engine interface nil-transition contract --- pkg/covenantsigner/engine.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/covenantsigner/engine.go b/pkg/covenantsigner/engine.go index b36ea06ee2..3e3713eb0c 100644 --- a/pkg/covenantsigner/engine.go +++ b/pkg/covenantsigner/engine.go @@ -16,6 +16,22 @@ type Transition struct { Handoff map[string]any } +// Engine drives the signing lifecycle for a job. Both methods receive the +// job's current state and return an optional Transition describing the next +// state. Returning (nil, nil) is valid and has method-specific semantics: +// +// - OnSubmit: nil Transition causes the service to apply a default Pending +// transition, moving the job to JobStatePending with a generic detail +// message. The engine should return a non-nil Transition when it has +// already initiated work synchronously or wants to override the detail. +// +// - OnPoll: nil Transition means no state change; the job stays at its +// current state. The engine should return a non-nil Transition only when +// it has observable progress to report (or when the job has failed). +// +// Returning errJobNotFound signals that the engine can no longer locate the +// underlying signing job. The service treats this as a terminal failure and +// transitions the job to JobStateFailed. type Engine interface { OnSubmit(ctx context.Context, job *Job) (*Transition, error) OnPoll(ctx context.Context, job *Job) (*Transition, error) From 7da4f74a21367e36170c6749fbf9cb1739bb9941 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 10:12:42 +0000 Subject: [PATCH 124/143] fix(covenantsigner): reject trailing JSON tokens in strictUnmarshal --- pkg/covenantsigner/validation.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 67bb88f621..1430f4b627 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -71,7 +71,13 @@ func NewInputError(message string) error { func strictUnmarshal(data []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() - return decoder.Decode(target) + if err := decoder.Decode(target); err != nil { + return err + } + if decoder.More() { + return fmt.Errorf("unexpected trailing content after JSON object") + } + return nil } type validationOptions struct { From 042519d19fa728980df458627478b735945aae67 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 10:13:09 +0000 Subject: [PATCH 125/143] refactor(covenantsigner): unify depositor and custodian trust root normalization via generic helper --- pkg/covenantsigner/validation.go | 109 ++++++++++++++----------------- 1 file changed, 48 insertions(+), 61 deletions(-) diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 1430f4b627..4e57c80d4d 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -631,24 +631,29 @@ func normalizeScopedApprovalTrustRoot( nil } -func normalizeDepositorTrustRoots( - trustRoots []DepositorTrustRoot, -) ([]DepositorTrustRoot, error) { +// normalizeScopedTrustRoots validates and normalizes a slice of trust roots +// of any scoped-approval type. getFields extracts (Route, Reserve, Network, +// PublicKey) from each element; build constructs a normalized element from +// the validated fields. Duplicates within the same route/reserve/network +// scope are rejected. +func normalizeScopedTrustRoots[T any]( + typeName string, + trustRoots []T, + getFields func(T) (TemplateID, string, string, string), + build func(route TemplateID, reserve, network, publicKey string) T, +) ([]T, error) { if len(trustRoots) == 0 { return nil, nil } - normalized := make([]DepositorTrustRoot, len(trustRoots)) + normalized := make([]T, len(trustRoots)) seen := make(map[string]int, len(trustRoots)) for i, trustRoot := range trustRoots { - name := fmt.Sprintf("depositorTrustRoots[%d]", i) + name := fmt.Sprintf("%s[%d]", typeName, i) + r, res, net, pk := getFields(trustRoot) route, reserve, network, publicKey, err := normalizeScopedApprovalTrustRoot( - name, - trustRoot.Route, - trustRoot.Reserve, - trustRoot.Network, - trustRoot.PublicKey, + name, r, res, net, pk, ) if err != nil { return nil, err @@ -658,8 +663,9 @@ func normalizeDepositorTrustRoots( if previousIndex, ok := seen[scopeKey]; ok { return nil, &inputError{ fmt.Sprintf( - "%s duplicates depositorTrustRoots[%d] for route %s reserve %s network %s", + "%s duplicates %s[%d] for route %s reserve %s network %s", name, + typeName, previousIndex, route, reserve, @@ -668,65 +674,46 @@ func normalizeDepositorTrustRoots( } } seen[scopeKey] = i - - normalized[i] = DepositorTrustRoot{ - Route: route, - Reserve: reserve, - Network: network, - PublicKey: publicKey, - } + normalized[i] = build(route, reserve, network, publicKey) } return normalized, nil } +func normalizeDepositorTrustRoots( + trustRoots []DepositorTrustRoot, +) ([]DepositorTrustRoot, error) { + return normalizeScopedTrustRoots( + "depositorTrustRoots", + trustRoots, + func(t DepositorTrustRoot) (TemplateID, string, string, string) { + return t.Route, t.Reserve, t.Network, t.PublicKey + }, + func(route TemplateID, reserve, network, publicKey string) DepositorTrustRoot { + return DepositorTrustRoot{ + Route: route, Reserve: reserve, + Network: network, PublicKey: publicKey, + } + }, + ) +} + func normalizeCustodianTrustRoots( trustRoots []CustodianTrustRoot, ) ([]CustodianTrustRoot, error) { - if len(trustRoots) == 0 { - return nil, nil - } - - normalized := make([]CustodianTrustRoot, len(trustRoots)) - seen := make(map[string]int, len(trustRoots)) - - for i, trustRoot := range trustRoots { - name := fmt.Sprintf("custodianTrustRoots[%d]", i) - route, reserve, network, publicKey, err := normalizeScopedApprovalTrustRoot( - name, - trustRoot.Route, - trustRoot.Reserve, - trustRoot.Network, - trustRoot.PublicKey, - ) - if err != nil { - return nil, err - } - - scopeKey := string(route) + "|" + reserve + "|" + network - if previousIndex, ok := seen[scopeKey]; ok { - return nil, &inputError{ - fmt.Sprintf( - "%s duplicates custodianTrustRoots[%d] for route %s reserve %s network %s", - name, - previousIndex, - route, - reserve, - network, - ), + return normalizeScopedTrustRoots( + "custodianTrustRoots", + trustRoots, + func(t CustodianTrustRoot) (TemplateID, string, string, string) { + return t.Route, t.Reserve, t.Network, t.PublicKey + }, + func(route TemplateID, reserve, network, publicKey string) CustodianTrustRoot { + return CustodianTrustRoot{ + Route: route, Reserve: reserve, + Network: network, PublicKey: publicKey, } - } - seen[scopeKey] = i - - normalized[i] = CustodianTrustRoot{ - Route: route, - Reserve: reserve, - Network: network, - PublicKey: publicKey, - } - } - - return normalized, nil + }, + ) } func trustRootLookupScope(request RouteSubmitRequest) (TemplateID, string, string) { From 090e8fa332cdaf21b4e0510fb6281323d3b45d3c Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 10:13:45 +0000 Subject: [PATCH 126/143] feat(tbtc): add HasRegistryData field to WalletChainData for type-level registry availability --- pkg/chain/ethereum/tbtc.go | 9 ++++++--- pkg/tbtc/chain.go | 11 ++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 5b359c2cae..7bc0c2b484 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1471,10 +1471,11 @@ func (tc *TbtcChain) GetWallet( // Fetch wallet registry data on a best-effort basis. Legacy callers // only use Bridge-sourced fields and never access MembersIDsHash, so a - // registry outage must not block them. The zero value signals that - // registry data is unavailable; downstream consumers that need it - // (e.g. signer_approval_certificate) already guard against this. + // registry outage must not block them. HasRegistryData signals whether + // EcdsaWalletID and MembersIDsHash were populated; downstream consumers + // that need them (e.g. signer_approval_certificate) check this flag. var membersIDsHash [32]byte + hasRegistryData := false walletRegistryWallet, err := tc.walletRegistry.GetWallet(wallet.EcdsaWalletID) if err != nil { @@ -1487,6 +1488,7 @@ func (tc *TbtcChain) GetWallet( ) } else { membersIDsHash = walletRegistryWallet.MembersIdsHash + hasRegistryData = true } walletState, err := parseWalletState(wallet.State) @@ -1495,6 +1497,7 @@ func (tc *TbtcChain) GetWallet( } return &tbtc.WalletChainData{ + HasRegistryData: hasRegistryData, EcdsaWalletID: wallet.EcdsaWalletID, MembersIDsHash: membersIDsHash, MainUtxoHash: wallet.MainUtxoHash, diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 391906c91a..a9cf0aee4d 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -418,10 +418,15 @@ type DepositChainRequest struct { // // EcdsaWalletID and MembersIDsHash are sourced from the wallet registry. // When the registry is unavailable during a fault-isolated GetWallet call, -// these fields contain their zero values. Consumers that require registry -// data (e.g. signer approval certificate computation) must guard against -// zero values -- see ErrMissingWalletID and ErrMissingMembersIDsHash. +// these fields contain their zero values and HasRegistryData is false. +// Consumers that require registry data (e.g. signer approval certificate +// computation) should check HasRegistryData before relying on EcdsaWalletID +// or MembersIDsHash -- see also ErrMissingWalletID and ErrMissingMembersIDsHash. type WalletChainData struct { + // HasRegistryData is true when EcdsaWalletID and MembersIDsHash were + // successfully fetched from the wallet registry. When false, those fields + // hold their zero values due to a registry outage during GetWallet. + HasRegistryData bool EcdsaWalletID [32]byte MembersIDsHash [32]byte MainUtxoHash [32]byte From 5d7ed6d90fb27ad3c11b1d255f1a298fddb1301f Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 10:14:02 +0000 Subject: [PATCH 127/143] fix(covenantsigner): fail startup when trust roots configured without a signer approval verifier --- pkg/covenantsigner/server.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 376bc27e3e..37a36c4d5b 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -68,10 +68,22 @@ func Initialize( return nil, false, err } if service.signerApprovalVerifier == nil { + hasTrustRoots := len(service.depositorTrustRoots) > 0 || + len(service.custodianTrustRoots) > 0 || + len(service.migrationPlanQuoteTrustRoots) > 0 + if hasTrustRoots { + return nil, false, fmt.Errorf( + "trust roots are configured but the engine does not implement " + + "SignerApprovalVerifier; signer approval certificates cannot " + + "be verified -- remove trust root configuration or use an " + + "engine that supports approval verification", + ) + } logger.Warn( "covenant signer started without a signer approval verifier; " + "structured signerApproval certificates will not be verified and " + - "requests without signerApproval will be accepted", + "requests without signerApproval will be accepted; " + + "set covenantSigner.requireApprovalTrustRoots=true to enforce approval verification", ) } if config.EnableSelfV1 && From ebdebebabd8ae95b0988349e381591991a1a764c Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 10:19:26 +0000 Subject: [PATCH 128/143] refactor(tbtc): extract shared resolveActiveUtxo helper from self_v1 and qc_v1 UTXO resolution --- pkg/tbtc/covenant_signer.go | 83 ++++++++++++------------------------- 1 file changed, 26 insertions(+), 57 deletions(-) diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index ddbd6c88e0..45dfa6b3ed 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -515,9 +515,13 @@ func buildQcV1WitnessScript( Script() } -func (cse *covenantSignerEngine) resolveSelfV1ActiveUtxo( +// resolveActiveUtxo fetches and validates the active covenant UTXO against +// the given witness script and request. templateName is used in error messages +// to identify which template path triggered the validation failure. +func (cse *covenantSignerEngine) resolveActiveUtxo( request covenantsigner.RouteSubmitRequest, witnessScript bitcoin.Script, + templateName string, ) (*bitcoin.UnspentTransactionOutput, error) { activeTxHash, err := bitcoin.NewHashFromString( strings.TrimPrefix(request.ActiveOutpoint.TxID, "0x"), @@ -541,12 +545,19 @@ func (cse *covenantSignerEngine) resolveSelfV1ActiveUtxo( expectedWitnessScriptHash := bitcoin.WitnessScriptHash(witnessScript) expectedScriptPubKey, err := bitcoin.PayToWitnessScriptHash(expectedWitnessScriptHash) if err != nil { - return nil, fmt.Errorf("cannot build expected self_v1 locking script: %v", err) + return nil, fmt.Errorf( + "cannot build expected %s locking script: %v", + templateName, + err, + ) } actualOutput := transaction.Outputs[request.ActiveOutpoint.Vout] if !bytes.Equal(actualOutput.PublicKeyScript, expectedScriptPubKey) { - return nil, fmt.Errorf("active outpoint script does not match self_v1 template") + return nil, fmt.Errorf( + "active outpoint script does not match %s template", + templateName, + ) } if actualOutput.Value <= 0 { return nil, fmt.Errorf("active outpoint value must be greater than zero") @@ -561,7 +572,10 @@ func (cse *covenantSignerEngine) resolveSelfV1ActiveUtxo( scriptHash := sha256.Sum256(expectedScriptPubKey) expectedScriptHash := "0x" + hex.EncodeToString(scriptHash[:]) if strings.ToLower(request.ActiveOutpoint.ScriptHash) != expectedScriptHash { - return nil, fmt.Errorf("active outpoint script hash does not match self_v1 template") + return nil, fmt.Errorf( + "active outpoint script hash does not match %s template", + templateName, + ) } } @@ -574,63 +588,18 @@ func (cse *covenantSignerEngine) resolveSelfV1ActiveUtxo( }, nil } -func (cse *covenantSignerEngine) resolveQcV1ActiveUtxo( +func (cse *covenantSignerEngine) resolveSelfV1ActiveUtxo( request covenantsigner.RouteSubmitRequest, witnessScript bitcoin.Script, ) (*bitcoin.UnspentTransactionOutput, error) { - activeTxHash, err := bitcoin.NewHashFromString( - strings.TrimPrefix(request.ActiveOutpoint.TxID, "0x"), - bitcoin.ReversedByteOrder, - ) - if err != nil { - return nil, fmt.Errorf("active outpoint txid is invalid") - } - - transaction, err := cse.node.btcChain.GetTransaction(activeTxHash) - if err != nil { - return nil, fmt.Errorf("active outpoint transaction not found") - } - if err := cse.ensureActiveOutpointFinality(activeTxHash); err != nil { - return nil, err - } - if int(request.ActiveOutpoint.Vout) >= len(transaction.Outputs) { - return nil, fmt.Errorf("active outpoint output index is out of range") - } - - expectedWitnessScriptHash := bitcoin.WitnessScriptHash(witnessScript) - expectedScriptPubKey, err := bitcoin.PayToWitnessScriptHash(expectedWitnessScriptHash) - if err != nil { - return nil, fmt.Errorf("cannot build expected qc_v1 locking script: %v", err) - } - - actualOutput := transaction.Outputs[request.ActiveOutpoint.Vout] - if !bytes.Equal(actualOutput.PublicKeyScript, expectedScriptPubKey) { - return nil, fmt.Errorf("active outpoint script does not match qc_v1 template") - } - if actualOutput.Value <= 0 { - return nil, fmt.Errorf("active outpoint value must be greater than zero") - } - if uint64(actualOutput.Value) != request.MigrationTransactionPlan.InputValueSats { - return nil, fmt.Errorf("active outpoint value does not match migration transaction plan") - } - - if request.ActiveOutpoint.ScriptHash != "" { - // The optional scriptHash convention follows the tBTC-side request - // contract: sha256(scriptPubKey) for the active covenant output. - scriptHash := sha256.Sum256(expectedScriptPubKey) - expectedScriptHash := "0x" + hex.EncodeToString(scriptHash[:]) - if strings.ToLower(request.ActiveOutpoint.ScriptHash) != expectedScriptHash { - return nil, fmt.Errorf("active outpoint script hash does not match qc_v1 template") - } - } + return cse.resolveActiveUtxo(request, witnessScript, "self_v1") +} - return &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: activeTxHash, - OutputIndex: request.ActiveOutpoint.Vout, - }, - Value: actualOutput.Value, - }, nil +func (cse *covenantSignerEngine) resolveQcV1ActiveUtxo( + request covenantsigner.RouteSubmitRequest, + witnessScript bitcoin.Script, +) (*bitcoin.UnspentTransactionOutput, error) { + return cse.resolveActiveUtxo(request, witnessScript, "qc_v1") } func (cse *covenantSignerEngine) ensureActiveOutpointFinality( From 5d65164110a4ab38cafd556f4ed675cd254018f0 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 10:20:18 +0000 Subject: [PATCH 129/143] feat(covenantsigner): add token-bucket rate limiting to submit endpoints --- pkg/covenantsigner/server.go | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 37a36c4d5b..c34d9424df 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -16,6 +16,7 @@ import ( "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-common/pkg/persistence" + "golang.org/x/time/rate" ) var logger = log.Logger("keep-covenant-signer") @@ -248,17 +249,24 @@ func newHandler(service *Service, serviceCtx context.Context, authToken string, mux := http.NewServeMux() protectedHandler := withBearerAuth(mux, authToken) + // Single rate limiter shared across all submit routes. The server uses one + // static auth token, so this is equivalent to per-token limiting. + submitLimiter := rate.NewLimiter( + rate.Every(time.Minute/submitRateLimitPerMinute), + submitRateLimitPerMinute, + ) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"ok"}`)) }) - mux.HandleFunc("POST /v1/qc_v1/signer/requests", submitHandler(service, serviceCtx, TemplateQcV1)) + mux.HandleFunc("POST /v1/qc_v1/signer/requests", submitHandler(service, serviceCtx, TemplateQcV1, submitLimiter)) mux.HandleFunc("POST /v1/qc_v1/signer/requests:poll", pollBodyHandler(service, TemplateQcV1)) mux.HandleFunc("/v1/qc_v1/signer/requests/", pollPathHandler(service, TemplateQcV1)) if enableSelfV1 { - mux.HandleFunc("POST /v1/self_v1/signer/requests", submitHandler(service, serviceCtx, TemplateSelfV1)) + mux.HandleFunc("POST /v1/self_v1/signer/requests", submitHandler(service, serviceCtx, TemplateSelfV1, submitLimiter)) mux.HandleFunc("POST /v1/self_v1/signer/requests:poll", pollBodyHandler(service, TemplateSelfV1)) mux.HandleFunc("/v1/self_v1/signer/requests/", pollPathHandler(service, TemplateSelfV1)) } @@ -355,8 +363,19 @@ func handleError(w http.ResponseWriter, err error) { const submitTimeout = 5 * time.Minute -func submitHandler(service *Service, serviceCtx context.Context, route TemplateID) http.HandlerFunc { +// submitRateLimitPerMinute is the maximum number of new submit requests +// accepted per minute. Each submit may initiate a 5-minute threshold signing +// operation, so even a small burst can saturate the engine. Idempotent polls +// and dedup hits are not affected by this limit. +const submitRateLimitPerMinute = 5 + +func submitHandler(service *Service, serviceCtx context.Context, route TemplateID, limiter *rate.Limiter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + if !limiter.Allow() { + http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) + return + } + input := SignerSubmitInput{} if !decodeJSON(w, r, &input) { return From dd2cf4d11975a7f62ef8c42fe97ca5a5bc6907a4 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 10:26:04 +0000 Subject: [PATCH 130/143] refactor(covenantsigner): split monolithic validation.go into focused domain files --- pkg/covenantsigner/validation.go | 1459 +-------------------- pkg/covenantsigner/validation_approval.go | 649 +++++++++ pkg/covenantsigner/validation_quote.go | 476 +++++++ pkg/covenantsigner/validation_template.go | 317 +++++ 4 files changed, 1476 insertions(+), 1425 deletions(-) create mode 100644 pkg/covenantsigner/validation_approval.go create mode 100644 pkg/covenantsigner/validation_quote.go create mode 100644 pkg/covenantsigner/validation_template.go diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 4e57c80d4d..027b44ffbf 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -2,24 +2,15 @@ package covenantsigner import ( "bytes" - "crypto/ecdsa" - "crypto/ed25519" "crypto/sha256" - "crypto/x509" - "encoding/binary" "encoding/hex" "encoding/json" - "encoding/pem" "fmt" "math" - "math/big" - "reflect" "regexp" - "sort" "strings" "time" - "github.com/btcsuite/btcd/btcec" "github.com/ethereum/go-ethereum/crypto" "github.com/keep-network/keep-core/pkg/internal/canonicaljson" ) @@ -190,1427 +181,24 @@ func decodeBytes32HexString(name string, value string) ([32]byte, error) { return decoded, nil } -func normalizeSignerApprovalMemberIndexes( - name string, - values []uint32, -) ([]uint32, error) { - if len(values) == 0 { - return nil, nil - } - - normalized := append([]uint32{}, values...) - seen := make(map[uint32]struct{}, len(normalized)) - for i, value := range normalized { - if value == 0 { - return nil, &inputError{ - fmt.Sprintf("%s[%d] must be greater than zero", name, i), - } - } - if err := validateUint32Range(name, uint64(value)); err != nil { - return nil, err - } - if _, ok := seen[value]; ok { - return nil, &inputError{ - fmt.Sprintf("%s[%d] duplicates member %d", name, i, value), - } - } - seen[value] = struct{}{} - } - - sort.Slice(normalized, func(i, j int) bool { - return normalized[i] < normalized[j] - }) - - return normalized, nil -} - -func normalizeRequestType( - route TemplateID, - requestType RequestType, -) (RequestType, error) { - switch requestType { - case RequestTypeReconstruct: - return requestType, nil - case RequestTypePresignSelfV1: - if route != TemplateSelfV1 { - return "", &inputError{"request.requestType must be reconstruct for qc_v1"} - } - return requestType, nil - default: - return "", &inputError{"request.requestType must be reconstruct or presign_self_v1"} - } -} - -func normalizeSignerApprovalCertificate( - request RouteSubmitRequest, -) (*SignerApprovalCertificate, error) { - if request.SignerApproval == nil { - return nil, nil - } - if request.ArtifactApprovals == nil { - return nil, &inputError{ - "request.artifactApprovals is required when request.signerApproval is present", - } - } - - signerApproval := request.SignerApproval - if signerApproval.CertificateVersion != signerApprovalCertificateVersion { - return nil, &inputError{ - fmt.Sprintf( - "request.signerApproval.certificateVersion must equal %d", - signerApprovalCertificateVersion, - ), - } - } - if signerApproval.SignatureAlgorithm != signerApprovalSignatureAlgorithm { - return nil, &inputError{ - fmt.Sprintf( - "request.signerApproval.signatureAlgorithm must equal %s", - signerApprovalSignatureAlgorithm, - ), - } - } - if err := validateBytes32HexString( - "request.signerApproval.approvalDigest", - signerApproval.ApprovalDigest, - ); err != nil { - return nil, err - } - if err := validateHexString( - "request.signerApproval.walletPublicKey", - signerApproval.WalletPublicKey, - ); err != nil { - return nil, err - } - if len(signerApproval.WalletPublicKey) != 132 { - // This must match tbtc marshalPublicKey/unmarshalPublicKey: - // uncompressed SEC1 public key (0x04 + 64-byte coordinates). - return nil, &inputError{ - "request.signerApproval.walletPublicKey must be a 65-byte uncompressed secp256k1 public key", - } - } - normalizedWalletPublicKey := normalizeLowerHex(signerApproval.WalletPublicKey) - if !strings.HasPrefix(normalizedWalletPublicKey, "0x04") { - return nil, &inputError{ - "request.signerApproval.walletPublicKey must be a 65-byte uncompressed secp256k1 public key", - } - } - if err := validateBytes32HexString( - "request.signerApproval.signerSetHash", - signerApproval.SignerSetHash, - ); err != nil { - return nil, err - } - if err := validateHexString( - "request.signerApproval.signature", - signerApproval.Signature, - ); err != nil { - return nil, err - } - - expectedApprovalDigest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) - if err != nil { - return nil, err - } - - normalizedApprovalDigest := normalizeLowerHex(signerApproval.ApprovalDigest) - if normalizedApprovalDigest != "0x"+hex.EncodeToString(expectedApprovalDigest) { - return nil, &inputError{ - "request.signerApproval.approvalDigest must match the canonical artifactApprovals payload digest", - } - } - - normalizedSignerApproval := &SignerApprovalCertificate{ - CertificateVersion: signerApprovalCertificateVersion, - SignatureAlgorithm: signerApprovalSignatureAlgorithm, - ApprovalDigest: normalizedApprovalDigest, - WalletPublicKey: normalizedWalletPublicKey, - SignerSetHash: normalizeLowerHex(signerApproval.SignerSetHash), - Signature: normalizeLowerHex(signerApproval.Signature), - } - - activeMembers, err := normalizeSignerApprovalMemberIndexes( - "request.signerApproval.activeMembers", - signerApproval.ActiveMembers, - ) - if err != nil { - return nil, err - } - if len(activeMembers) > 0 { - normalizedSignerApproval.ActiveMembers = activeMembers - } - - inactiveMembers, err := normalizeSignerApprovalMemberIndexes( - "request.signerApproval.inactiveMembers", - signerApproval.InactiveMembers, - ) - if err != nil { - return nil, err - } - if len(inactiveMembers) > 0 { - normalizedSignerApproval.InactiveMembers = inactiveMembers - } - - if len(activeMembers) > 0 && len(inactiveMembers) > 0 { - activeSet := make(map[uint32]struct{}, len(activeMembers)) - for _, value := range activeMembers { - activeSet[value] = struct{}{} - } - for _, value := range inactiveMembers { - if _, ok := activeSet[value]; ok { - return nil, &inputError{ - "request.signerApproval.activeMembers and request.signerApproval.inactiveMembers must not overlap", - } - } - } - } - - if signerApproval.EndBlock != nil { - if err := validateUint32Range( - "request.signerApproval.endBlock", - *signerApproval.EndBlock, - ); err != nil { - return nil, err - } - endBlock := *signerApproval.EndBlock - normalizedSignerApproval.EndBlock = &endBlock - } - - return normalizedSignerApproval, nil -} - -func normalizeLowerHex(value string) string { - return strings.ToLower(value) -} - -func abiEncodeUint32Word(value uint32) [32]byte { - var encoded [32]byte - binary.BigEndian.PutUint32(encoded[28:], value) - return encoded -} - -func keccakTemplateIdentifier(id TemplateID) [32]byte { - hash := crypto.Keccak256Hash([]byte(id)) - - var encoded [32]byte - copy(encoded[:], hash.Bytes()) - - return encoded -} - -// artifactApprovalDigest pins the current phase-1 approval payload contract to -// a deterministic EIP-712-compatible struct hash, without yet committing to a -// chain-specific domain separator. -func artifactApprovalDigest(payload ArtifactApprovalPayload) ([]byte, error) { - destinationCommitmentHash, err := decodeBytes32HexString( - "request.artifactApprovals.payload.destinationCommitmentHash", - payload.DestinationCommitmentHash, - ) - if err != nil { - return nil, err - } - - planCommitmentHash, err := decodeBytes32HexString( - "request.artifactApprovals.payload.planCommitmentHash", - payload.PlanCommitmentHash, - ) - if err != nil { - return nil, err - } - - encoded := make([]byte, 32*6) - approvalVersionWord := abiEncodeUint32Word(payload.ApprovalVersion) - routeIdentifier := keccakTemplateIdentifier(payload.Route) - scriptTemplateIdentifier := keccakTemplateIdentifier(payload.ScriptTemplateID) - - copy(encoded[0:32], artifactApprovalTypeHash.Bytes()) - copy(encoded[32:64], approvalVersionWord[:]) - copy(encoded[64:96], routeIdentifier[:]) - copy(encoded[96:128], scriptTemplateIdentifier[:]) - copy(encoded[128:160], destinationCommitmentHash[:]) - copy(encoded[160:192], planCommitmentHash[:]) - - digest := crypto.Keccak256Hash(encoded) - return digest.Bytes(), nil -} - -// ComputeArtifactApprovalDigest exposes the current phase-1 approval payload -// digest contract to cross-package verifiers that need to bind -// signerApproval.approvalDigest to request.artifactApprovals.payload. -func ComputeArtifactApprovalDigest(payload ArtifactApprovalPayload) ([]byte, error) { - return artifactApprovalDigest(payload) -} - -func parseCompressedSecp256k1PublicKey( - name string, - value string, -) (*btcec.PublicKey, error) { - if err := validateHexString(name, value); err != nil { - return nil, err - } - - rawValue, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) - if err != nil { - return nil, &inputError{fmt.Sprintf("%s must be valid hex", name)} - } - - if len(rawValue) != 33 || (rawValue[0] != 0x02 && rawValue[0] != 0x03) { - return nil, &inputError{fmt.Sprintf("%s must be a compressed secp256k1 public key", name)} - } - - publicKey, err := btcec.ParsePubKey(rawValue, btcec.S256()) - if err != nil { - return nil, &inputError{fmt.Sprintf("%s must be a compressed secp256k1 public key", name)} - } - - return publicKey, nil -} - -func verifyCompactSecp256k1Signature( - publicKey *btcec.PublicKey, - digest []byte, - signature []byte, -) bool { - return ecdsa.Verify( - publicKey.ToECDSA(), - digest, - new(big.Int).SetBytes(signature[:32]), - new(big.Int).SetBytes(signature[32:]), - ) -} - -func isLowSSecp256k1(s *big.Int) bool { - halfOrder := new(big.Int).Rsh(new(big.Int).Set(btcec.S256().N), 1) - return s.Cmp(halfOrder) <= 0 -} - -func verifySecp256k1Signature( - name string, - publicKey *btcec.PublicKey, - digest []byte, - signature string, -) error { - rawSignature, err := hex.DecodeString(strings.TrimPrefix(signature, "0x")) - if err != nil { - return &inputError{fmt.Sprintf("%s must be valid hex", name)} - } - - switch { - case len(rawSignature) == 64: - if !isLowSSecp256k1(new(big.Int).SetBytes(rawSignature[32:])) { - return &inputError{fmt.Sprintf("%s must be a low-S secp256k1 signature", name)} - } - if verifyCompactSecp256k1Signature(publicKey, digest, rawSignature) { - return nil - } - case len(rawSignature) == 65 && - (rawSignature[64] == 0 || rawSignature[64] == 1 || rawSignature[64] == 27 || rawSignature[64] == 28): - if !isLowSSecp256k1(new(big.Int).SetBytes(rawSignature[32:64])) { - return &inputError{fmt.Sprintf("%s must be a low-S secp256k1 signature", name)} - } - if verifyCompactSecp256k1Signature(publicKey, digest, rawSignature[:64]) { - return nil - } - default: - parsedSignature, err := btcec.ParseDERSignature(rawSignature, btcec.S256()) - if err != nil { - return &inputError{ - fmt.Sprintf( - "%s must be a DER or 64/65-byte secp256k1 signature", - name, - ), - } - } - if !isLowSSecp256k1(parsedSignature.S) { - return &inputError{fmt.Sprintf("%s must be a low-S secp256k1 signature", name)} - } - if parsedSignature.Verify(digest, publicKey) { - return nil - } - } - - return &inputError{fmt.Sprintf("%s does not verify against the required public key", name)} -} - -type migrationPlanQuoteSigningPayload struct { - QuoteVersion uint32 `json:"quoteVersion"` - QuoteID string `json:"quoteId"` - ReservationID string `json:"reservationId"` - Reserve string `json:"reserve"` - Epoch uint64 `json:"epoch"` - Route string `json:"route"` - Revealer string `json:"revealer"` - Vault string `json:"vault"` - Network string `json:"network"` - DestinationCommitmentHash string `json:"destinationCommitmentHash"` - ActiveOutpointTxID string `json:"activeOutpointTxid"` - ActiveOutpointVout uint32 `json:"activeOutpointVout"` - PlanCommitmentHash string `json:"planCommitmentHash"` - IssuedAt string `json:"issuedAt"` - ExpiresAt string `json:"expiresAt"` - ExpiresInSeconds uint64 `json:"expiresInSeconds"` -} - -func normalizeCanonicalTimestamp(name string, value string) (string, error) { - if !canonicalTimestampPattern.MatchString(value) { - return "", &inputError{ - fmt.Sprintf( - "%s must be a UTC ISO-8601 timestamp from Date.toISOString()", - name, - ), - } - } - - return value, nil -} - -func normalizeMigrationPlanQuotePublicKeyPEM(value string) string { - return strings.TrimSpace(strings.ReplaceAll(value, "\\n", "\n")) -} - -func parseMigrationPlanQuoteTrustRoot( - name string, - trustRoot MigrationPlanQuoteTrustRoot, -) (ed25519.PublicKey, error) { - block, _ := pem.Decode([]byte(normalizeMigrationPlanQuotePublicKeyPEM(trustRoot.PublicKeyPEM))) - if block == nil { - return nil, &inputError{fmt.Sprintf("%s.publicKeyPem must be a PEM-encoded public key", name)} - } - - publicKeyValue, err := x509.ParsePKIXPublicKey(block.Bytes) - if err != nil { - return nil, &inputError{fmt.Sprintf("%s.publicKeyPem must be a PEM-encoded Ed25519 public key", name)} - } - - publicKey, ok := publicKeyValue.(ed25519.PublicKey) - if !ok { - return nil, &inputError{fmt.Sprintf("%s.publicKeyPem must be a PEM-encoded Ed25519 public key", name)} - } - - return publicKey, nil -} - -func normalizeScopedApprovalTrustRoot( - name string, - route TemplateID, - reserve string, - network string, - publicKey string, -) (TemplateID, string, string, string, error) { - switch route { - case TemplateSelfV1, TemplateQcV1: - default: - return "", "", "", "", &inputError{ - fmt.Sprintf("%s.route must be self_v1 or qc_v1", name), - } - } - - if err := validateHexString(name+".reserve", reserve); err != nil { - return "", "", "", "", err - } - - trimmedNetwork := strings.TrimSpace(network) - if trimmedNetwork == "" { - return "", "", "", "", &inputError{ - fmt.Sprintf("%s.network is required", name), - } - } - - normalizedPublicKey := normalizeLowerHex(publicKey) - if _, err := parseCompressedSecp256k1PublicKey( - name+".publicKey", - normalizedPublicKey, - ); err != nil { - return "", "", "", "", err - } - - return route, - normalizeLowerHex(reserve), - strings.ToLower(trimmedNetwork), - normalizedPublicKey, - nil -} - -// normalizeScopedTrustRoots validates and normalizes a slice of trust roots -// of any scoped-approval type. getFields extracts (Route, Reserve, Network, -// PublicKey) from each element; build constructs a normalized element from -// the validated fields. Duplicates within the same route/reserve/network -// scope are rejected. -func normalizeScopedTrustRoots[T any]( - typeName string, - trustRoots []T, - getFields func(T) (TemplateID, string, string, string), - build func(route TemplateID, reserve, network, publicKey string) T, -) ([]T, error) { - if len(trustRoots) == 0 { - return nil, nil - } - - normalized := make([]T, len(trustRoots)) - seen := make(map[string]int, len(trustRoots)) - - for i, trustRoot := range trustRoots { - name := fmt.Sprintf("%s[%d]", typeName, i) - r, res, net, pk := getFields(trustRoot) - route, reserve, network, publicKey, err := normalizeScopedApprovalTrustRoot( - name, r, res, net, pk, - ) - if err != nil { - return nil, err - } - - scopeKey := string(route) + "|" + reserve + "|" + network - if previousIndex, ok := seen[scopeKey]; ok { - return nil, &inputError{ - fmt.Sprintf( - "%s duplicates %s[%d] for route %s reserve %s network %s", - name, - typeName, - previousIndex, - route, - reserve, - network, - ), - } - } - seen[scopeKey] = i - normalized[i] = build(route, reserve, network, publicKey) - } - - return normalized, nil -} - -func normalizeDepositorTrustRoots( - trustRoots []DepositorTrustRoot, -) ([]DepositorTrustRoot, error) { - return normalizeScopedTrustRoots( - "depositorTrustRoots", - trustRoots, - func(t DepositorTrustRoot) (TemplateID, string, string, string) { - return t.Route, t.Reserve, t.Network, t.PublicKey - }, - func(route TemplateID, reserve, network, publicKey string) DepositorTrustRoot { - return DepositorTrustRoot{ - Route: route, Reserve: reserve, - Network: network, PublicKey: publicKey, - } - }, - ) -} - -func normalizeCustodianTrustRoots( - trustRoots []CustodianTrustRoot, -) ([]CustodianTrustRoot, error) { - return normalizeScopedTrustRoots( - "custodianTrustRoots", - trustRoots, - func(t CustodianTrustRoot) (TemplateID, string, string, string) { - return t.Route, t.Reserve, t.Network, t.PublicKey - }, - func(route TemplateID, reserve, network, publicKey string) CustodianTrustRoot { - return CustodianTrustRoot{ - Route: route, Reserve: reserve, - Network: network, PublicKey: publicKey, - } - }, - ) -} - -func trustRootLookupScope(request RouteSubmitRequest) (TemplateID, string, string) { - network := "" - if request.MigrationDestination != nil { - network = strings.ToLower(strings.TrimSpace(request.MigrationDestination.Network)) - } - - return request.Route, normalizeLowerHex(request.Reserve), network -} - -func resolveExpectedDepositorPublicKey( - request RouteSubmitRequest, - trustRoots []DepositorTrustRoot, -) (string, bool) { - route, reserve, network := trustRootLookupScope(request) - for _, trustRoot := range trustRoots { - if trustRoot.Route == route && - trustRoot.Reserve == reserve && - trustRoot.Network == network { - return trustRoot.PublicKey, true - } - } - - return "", false -} - -func resolveExpectedCustodianPublicKey( - request RouteSubmitRequest, - trustRoots []CustodianTrustRoot, -) (string, bool) { - route, reserve, network := trustRootLookupScope(request) - for _, trustRoot := range trustRoots { - if trustRoot.Route == route && - trustRoot.Reserve == reserve && - trustRoot.Network == network { - return trustRoot.PublicKey, true - } - } - - return "", false -} - -func migrationPlanQuoteSigningPayloadBytes( - quote *MigrationDestinationPlanQuote, -) ([]byte, error) { - return canonicaljson.Marshal(migrationPlanQuoteSigningPayload{ - QuoteVersion: quote.QuoteVersion, - QuoteID: quote.QuoteID, - ReservationID: quote.ReservationID, - Reserve: normalizeLowerHex(quote.Reserve), - Epoch: quote.Epoch, - Route: string(quote.Route), - Revealer: normalizeLowerHex(quote.Revealer), - Vault: normalizeLowerHex(quote.Vault), - Network: quote.Network, - DestinationCommitmentHash: normalizeLowerHex(quote.DestinationCommitmentHash), - ActiveOutpointTxID: normalizeLowerHex(quote.ActiveOutpointTxID), - ActiveOutpointVout: quote.ActiveOutpointVout, - PlanCommitmentHash: normalizeLowerHex(quote.PlanCommitmentHash), - IssuedAt: quote.IssuedAt, - ExpiresAt: quote.ExpiresAt, - ExpiresInSeconds: quote.ExpiresInSeconds, - }) -} - -func migrationPlanQuoteSigningPreimage( - quote *MigrationDestinationPlanQuote, -) ([]byte, error) { - payload, err := migrationPlanQuoteSigningPayloadBytes(quote) - if err != nil { - return nil, err - } - - return []byte(migrationPlanQuoteSigningDomain + string(payload)), nil -} - -func migrationPlanQuoteSigningHash( - quote *MigrationDestinationPlanQuote, -) ([]byte, error) { - preimage, err := migrationPlanQuoteSigningPreimage(quote) - if err != nil { - return nil, err - } - - sum := sha256.Sum256(preimage) - return sum[:], nil -} - -func normalizeMigrationPlanQuote( - request RouteSubmitRequest, - options validationOptions, -) (*MigrationDestinationPlanQuote, error) { - quote := request.MigrationPlanQuote - if quote == nil { - if len(options.migrationPlanQuoteTrustRoots) > 0 && !options.policyIndependentDigest { - return nil, &inputError{ - "request.migrationPlanQuote is required when migrationPlanQuoteTrustRoots are configured", - } - } - - return nil, nil - } - if len(options.migrationPlanQuoteTrustRoots) == 0 && !options.policyIndependentDigest { - return nil, &inputError{"request.migrationPlanQuote verification requires configured trust roots"} - } - if request.MigrationDestination == nil { - return nil, &inputError{"request.migrationDestination is required when request.migrationPlanQuote is present"} - } - if request.MigrationTransactionPlan == nil { - return nil, &inputError{"request.migrationTransactionPlan is required when request.migrationPlanQuote is present"} - } - if quote.QuoteVersion != migrationPlanQuoteVersion { - return nil, &inputError{"request.migrationPlanQuote.quoteVersion must equal 1"} - } - if strings.TrimSpace(quote.QuoteID) == "" { - return nil, &inputError{"request.migrationPlanQuote.quoteId is required"} - } - if strings.TrimSpace(quote.ReservationID) == "" { - return nil, &inputError{"request.migrationPlanQuote.reservationId is required"} - } - if strings.TrimSpace(quote.IdempotencyKey) == "" { - return nil, &inputError{"request.migrationPlanQuote.idempotencyKey is required"} - } - if quote.Route != ReservationRouteMigration { - return nil, &inputError{"request.migrationPlanQuote.route must be MIGRATION"} - } - if err := validateAddressString("request.migrationPlanQuote.reserve", quote.Reserve); err != nil { - return nil, err - } - if err := validateAddressString("request.migrationPlanQuote.revealer", quote.Revealer); err != nil { - return nil, err - } - if err := validateAddressString("request.migrationPlanQuote.vault", quote.Vault); err != nil { - return nil, err - } - if strings.TrimSpace(quote.Network) == "" { - return nil, &inputError{"request.migrationPlanQuote.network is required"} - } - if err := validateBytes32HexString( - "request.migrationPlanQuote.destinationCommitmentHash", - quote.DestinationCommitmentHash, - ); err != nil { - return nil, err - } - if err := validateBytes32HexString( - "request.migrationPlanQuote.activeOutpointTxid", - quote.ActiveOutpointTxID, - ); err != nil { - return nil, err - } - if err := validateBytes32HexString( - "request.migrationPlanQuote.planCommitmentHash", - quote.PlanCommitmentHash, - ); err != nil { - return nil, err - } - if quote.ExpiresInSeconds == 0 { - return nil, &inputError{"request.migrationPlanQuote.expiresInSeconds must be greater than zero"} - } - if quote.Signature.SignatureVersion != migrationPlanQuoteSignatureVersion { - return nil, &inputError{"request.migrationPlanQuote.signature.signatureVersion must equal 1"} - } - if quote.Signature.Algorithm != migrationPlanQuoteSignatureAlgorithm { - return nil, &inputError{"request.migrationPlanQuote.signature.algorithm must equal ed25519"} - } - if strings.TrimSpace(quote.Signature.KeyID) == "" { - return nil, &inputError{"request.migrationPlanQuote.signature.keyId is required"} - } - if err := validateHexString("request.migrationPlanQuote.signature.signature", quote.Signature.Signature); err != nil { - return nil, err - } - - normalizedIssuedAt, err := normalizeCanonicalTimestamp( - "request.migrationPlanQuote.issuedAt", - quote.IssuedAt, - ) - if err != nil { - return nil, err - } - issuedAt, err := time.Parse(time.RFC3339Nano, normalizedIssuedAt) - if err != nil { - return nil, &inputError{ - "request.migrationPlanQuote.issuedAt must be a parseable UTC ISO-8601 timestamp", - } - } - normalizedExpiresAt, err := normalizeCanonicalTimestamp( - "request.migrationPlanQuote.expiresAt", - quote.ExpiresAt, - ) - if err != nil { - return nil, err - } - expiresAt, err := time.Parse(time.RFC3339Nano, normalizedExpiresAt) - if err != nil { - return nil, &inputError{ - "request.migrationPlanQuote.expiresAt must be a parseable UTC ISO-8601 timestamp", - } - } - if !expiresAt.After(issuedAt) { - return nil, &inputError{"request.migrationPlanQuote.expiresAt must be after request.migrationPlanQuote.issuedAt"} - } - if expiresAt.Sub(issuedAt) != time.Duration(quote.ExpiresInSeconds)*time.Second { - return nil, &inputError{"request.migrationPlanQuote.expiresAt must equal request.migrationPlanQuote.issuedAt + expiresInSeconds"} - } - if quote.Epoch != request.Epoch { - return nil, &inputError{"request.migrationPlanQuote.epoch must match request.epoch"} - } - if normalizeLowerHex(quote.Reserve) != normalizeLowerHex(request.Reserve) { - return nil, &inputError{"request.migrationPlanQuote.reserve must match request.reserve"} - } - if quote.ReservationID != request.MigrationDestination.ReservationID { - return nil, &inputError{"request.migrationPlanQuote.reservationId must match request.migrationDestination.reservationId"} - } - if normalizeLowerHex(quote.Revealer) != normalizeLowerHex(request.MigrationDestination.Revealer) { - return nil, &inputError{"request.migrationPlanQuote.revealer must match request.migrationDestination.revealer"} - } - if normalizeLowerHex(quote.Vault) != normalizeLowerHex(request.MigrationDestination.Vault) { - return nil, &inputError{"request.migrationPlanQuote.vault must match request.migrationDestination.vault"} - } - if strings.TrimSpace(quote.Network) != strings.TrimSpace(request.MigrationDestination.Network) { - return nil, &inputError{"request.migrationPlanQuote.network must match request.migrationDestination.network"} - } - if normalizeLowerHex(quote.DestinationCommitmentHash) != normalizeLowerHex(request.DestinationCommitmentHash) { - return nil, &inputError{"request.migrationPlanQuote.destinationCommitmentHash must match request.destinationCommitmentHash"} - } - if normalizeLowerHex(quote.DestinationCommitmentHash) != normalizeLowerHex(request.MigrationDestination.DestinationCommitmentHash) { - return nil, &inputError{"request.migrationPlanQuote.destinationCommitmentHash must match request.migrationDestination.destinationCommitmentHash"} - } - if normalizeLowerHex(quote.ActiveOutpointTxID) != normalizeLowerHex(request.ActiveOutpoint.TxID) { - return nil, &inputError{"request.migrationPlanQuote.activeOutpointTxid must match request.activeOutpoint.txid"} - } - if quote.ActiveOutpointVout != request.ActiveOutpoint.Vout { - return nil, &inputError{"request.migrationPlanQuote.activeOutpointVout must match request.activeOutpoint.vout"} - } - if normalizeLowerHex(quote.PlanCommitmentHash) != normalizeLowerHex(request.MigrationTransactionPlan.PlanCommitmentHash) { - return nil, &inputError{"request.migrationPlanQuote.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash"} - } - - normalizedQuotePlan := normalizeMigrationTransactionPlan(quote.MigrationTransactionPlan) - if normalizedQuotePlan == nil { - return nil, &inputError{"request.migrationPlanQuote.migrationTransactionPlan is required"} - } - if err := validateMigrationTransactionPlan(request, quote.MigrationTransactionPlan); err != nil { - return nil, err - } - if !reflect.DeepEqual(normalizedQuotePlan, normalizeMigrationTransactionPlan(request.MigrationTransactionPlan)) { - return nil, &inputError{"request.migrationPlanQuote.migrationTransactionPlan must match request.migrationTransactionPlan"} - } - - normalizedQuote := &MigrationDestinationPlanQuote{ - QuoteID: strings.TrimSpace(quote.QuoteID), - QuoteVersion: migrationPlanQuoteVersion, - ReservationID: strings.TrimSpace(quote.ReservationID), - Reserve: normalizeLowerHex(quote.Reserve), - Epoch: quote.Epoch, - Route: ReservationRouteMigration, - Revealer: normalizeLowerHex(quote.Revealer), - Vault: normalizeLowerHex(quote.Vault), - Network: strings.TrimSpace(quote.Network), - DestinationCommitmentHash: normalizeLowerHex(quote.DestinationCommitmentHash), - ActiveOutpointTxID: normalizeLowerHex(quote.ActiveOutpointTxID), - ActiveOutpointVout: quote.ActiveOutpointVout, - PlanCommitmentHash: normalizeLowerHex(quote.PlanCommitmentHash), - MigrationTransactionPlan: normalizedQuotePlan, - IdempotencyKey: strings.TrimSpace(quote.IdempotencyKey), - ExpiresInSeconds: quote.ExpiresInSeconds, - IssuedAt: normalizedIssuedAt, - ExpiresAt: normalizedExpiresAt, - Signature: MigrationDestinationPlanQuoteSignature{ - SignatureVersion: migrationPlanQuoteSignatureVersion, - Algorithm: migrationPlanQuoteSignatureAlgorithm, - KeyID: strings.TrimSpace(quote.Signature.KeyID), - Signature: normalizeLowerHex(quote.Signature.Signature), - }, - } - if options.policyIndependentDigest { - return normalizedQuote, nil - } - - var publicKey ed25519.PublicKey - foundTrustRoot := false - for i, trustRoot := range options.migrationPlanQuoteTrustRoots { - if trustRoot.KeyID != quote.Signature.KeyID { - continue - } - - publicKey, err = parseMigrationPlanQuoteTrustRoot( - fmt.Sprintf("migrationPlanQuoteTrustRoots[%d]", i), - trustRoot, - ) - if err != nil { - return nil, err - } - foundTrustRoot = true - break - } - if !foundTrustRoot { - return nil, &inputError{"request.migrationPlanQuote.signature.keyId does not match a configured trust root"} - } - - signingHash, err := migrationPlanQuoteSigningHash(normalizedQuote) - if err != nil { - return nil, err - } - - rawSignature, err := hex.DecodeString(strings.TrimPrefix(normalizedQuote.Signature.Signature, "0x")) - if err != nil { - return nil, &inputError{"request.migrationPlanQuote.signature.signature must be valid hex"} - } - if !ed25519.Verify(publicKey, signingHash, rawSignature) { - return nil, &inputError{"request.migrationPlanQuote.signature does not verify against the configured trust root"} - } - - if options.requireFreshMigrationPlanQuote { - verificationNow := options.migrationPlanQuoteVerificationNow - if verificationNow.IsZero() { - verificationNow = time.Now().UTC() - } - // Submit freshness is intentionally strict. Poll omits this check so - // already-accepted jobs remain addressable after quote expiry; operators - // must keep the destination service and keep-core on synchronized UTC - // time when enforcing quote freshness. - if expiresAt.Before(verificationNow) { - return nil, &inputError{"request.migrationPlanQuote is expired"} - } - } - - return normalizedQuote, nil -} - -func computeMigrationExtraData(revealer string) string { - return "0x" + hex.EncodeToString([]byte("AC_MIGRATEV1")) + strings.TrimPrefix(normalizeLowerHex(revealer), "0x") -} - -func computeDepositScriptHash(depositScript string) (string, error) { - rawScript, err := hex.DecodeString(strings.TrimPrefix(depositScript, "0x")) - if err != nil { - return "", err - } - - sum := sha256.Sum256(rawScript) - return "0x" + hex.EncodeToString(sum[:]), nil -} - -type destinationCommitmentPayload struct { - // Field order is hash-significant and must stay aligned with the TypeScript - // reservation-service object literal used to compute the same commitment. - Reserve string `json:"reserve"` - Epoch uint64 `json:"epoch"` - Route string `json:"route"` - Revealer string `json:"revealer"` - Vault string `json:"vault"` - Network string `json:"network"` - DepositScriptHash string `json:"depositScriptHash"` - MigrationExtraData string `json:"migrationExtraData"` -} - -type migrationPlanCommitmentPayload struct { - // Field order is hash-significant and must stay aligned with the TypeScript - // migration transaction-plan commitment payload. planCommitmentHash is - // intentionally omitted because it is the output of this computation. - PlanVersion uint32 `json:"planVersion"` - Reserve string `json:"reserve"` - Epoch uint64 `json:"epoch"` - ActiveOutpointTxID string `json:"activeOutpointTxid"` - ActiveOutpointVout uint32 `json:"activeOutpointVout"` - DestinationCommitmentHash string `json:"destinationCommitmentHash"` - InputValueSats uint64 `json:"inputValueSats"` - DestinationValueSats uint64 `json:"destinationValueSats"` - AnchorValueSats uint64 `json:"anchorValueSats"` - FeeSats uint64 `json:"feeSats"` - InputSequence uint32 `json:"inputSequence"` - LockTime uint32 `json:"lockTime"` -} - -func computeDestinationCommitmentHash( - reservation *MigrationDestinationReservation, -) (string, error) { - payload, err := canonicaljson.Marshal(destinationCommitmentPayload{ - Reserve: normalizeLowerHex(reservation.Reserve), - Epoch: reservation.Epoch, - Route: string(reservation.Route), - Revealer: normalizeLowerHex(reservation.Revealer), - Vault: normalizeLowerHex(reservation.Vault), - Network: strings.TrimSpace(reservation.Network), - DepositScriptHash: normalizeLowerHex(reservation.DepositScriptHash), - MigrationExtraData: normalizeLowerHex(reservation.MigrationExtraData), - }) - if err != nil { - return "", err - } - - sum := sha256.Sum256(payload) - return "0x" + hex.EncodeToString(sum[:]), nil -} - -func computeMigrationTransactionPlanCommitmentHash( - request RouteSubmitRequest, - plan *MigrationTransactionPlan, -) (string, error) { - payload, err := canonicaljson.Marshal(migrationPlanCommitmentPayload{ - PlanVersion: plan.PlanVersion, - Reserve: normalizeLowerHex(request.Reserve), - Epoch: request.Epoch, - ActiveOutpointTxID: normalizeLowerHex(request.ActiveOutpoint.TxID), - ActiveOutpointVout: request.ActiveOutpoint.Vout, - DestinationCommitmentHash: normalizeLowerHex(request.DestinationCommitmentHash), - InputValueSats: plan.InputValueSats, - DestinationValueSats: plan.DestinationValueSats, - AnchorValueSats: plan.AnchorValueSats, - FeeSats: plan.FeeSats, - InputSequence: plan.InputSequence, - LockTime: plan.LockTime, - }) - if err != nil { - return "", err - } - - sum := sha256.Sum256(payload) - return "0x" + hex.EncodeToString(sum[:]), nil -} - -func validateMigrationDestination( - request RouteSubmitRequest, - reservation *MigrationDestinationReservation, -) error { - if reservation == nil { - return &inputError{"request.migrationDestination is required"} - } - if reservation.Route != ReservationRouteMigration { - return &inputError{"request.migrationDestination.route must be MIGRATION"} - } - if reservation.Status != ReservationStatusReserved && - reservation.Status != ReservationStatusCommittedToEpoch { - return &inputError{"request.migrationDestination.status must be RESERVED or COMMITTED_TO_EPOCH"} - } - if err := validateAddressString("request.migrationDestination.reserve", reservation.Reserve); err != nil { - return err - } - if err := validateAddressString("request.migrationDestination.revealer", reservation.Revealer); err != nil { - return err - } - if err := validateAddressString("request.migrationDestination.vault", reservation.Vault); err != nil { - return err - } - if strings.TrimSpace(reservation.Network) == "" { - return &inputError{"request.migrationDestination.network is required"} - } - if err := validateHexString("request.migrationDestination.depositScript", reservation.DepositScript); err != nil { - return err - } - if err := validateHexString("request.migrationDestination.depositScriptHash", reservation.DepositScriptHash); err != nil { - return err - } - if err := validateHexString("request.migrationDestination.migrationExtraData", reservation.MigrationExtraData); err != nil { - return err - } - if err := validateHexString("request.migrationDestination.destinationCommitmentHash", reservation.DestinationCommitmentHash); err != nil { - return err - } - if request.Epoch != reservation.Epoch { - return &inputError{"request.migrationDestination.epoch does not match request.epoch"} - } - if normalizeLowerHex(request.Reserve) != normalizeLowerHex(reservation.Reserve) { - return &inputError{"request.migrationDestination.reserve does not match request.reserve"} - } - if normalizeLowerHex(request.DestinationCommitmentHash) != normalizeLowerHex(reservation.DestinationCommitmentHash) { - return &inputError{"request.migrationDestination.destinationCommitmentHash does not match request.destinationCommitmentHash"} - } - - expectedExtraData := computeMigrationExtraData(reservation.Revealer) - if normalizeLowerHex(reservation.MigrationExtraData) != expectedExtraData { - return &inputError{"request.migrationDestination.migrationExtraData does not match migration revealer encoding"} - } - - depositScriptHash, err := computeDepositScriptHash(reservation.DepositScript) - if err != nil { - return &inputError{"request.migrationDestination.depositScript is not valid hex"} - } - if normalizeLowerHex(reservation.DepositScriptHash) != depositScriptHash { - return &inputError{"request.migrationDestination.depositScriptHash does not match depositScript"} - } - - commitmentHash, err := computeDestinationCommitmentHash(reservation) - if err != nil { - return err - } - if normalizeLowerHex(reservation.DestinationCommitmentHash) != commitmentHash { - return &inputError{"request.migrationDestination.destinationCommitmentHash does not match canonical reservation artifact"} - } - - return nil -} - -func validateMigrationTransactionPlan( - request RouteSubmitRequest, - plan *MigrationTransactionPlan, -) error { - if plan == nil { - return &inputError{"request.migrationTransactionPlan is required"} - } - if plan.PlanVersion != migrationTransactionPlanVersion { - return &inputError{"request.migrationTransactionPlan.planVersion must equal 1"} - } - if err := validateHexString("request.migrationTransactionPlan.planCommitmentHash", plan.PlanCommitmentHash); err != nil { - return err - } - if plan.InputValueSats == 0 { - return &inputError{"request.migrationTransactionPlan.inputValueSats must be greater than zero"} - } - if plan.DestinationValueSats == 0 { - return &inputError{"request.migrationTransactionPlan.destinationValueSats must be greater than zero"} - } - if plan.FeeSats == 0 { - return &inputError{"request.migrationTransactionPlan.feeSats must be greater than zero"} - } - if plan.AnchorValueSats != canonicalAnchorValueSats { - return &inputError{"request.migrationTransactionPlan.anchorValueSats must equal the canonical 330 sat anchor"} - } - if plan.InputSequence != canonicalCovenantInputSequence { - return &inputError{"request.migrationTransactionPlan.inputSequence must equal 0xFFFFFFFD"} - } - if request.MaturityHeight > math.MaxUint32 { - return &inputError{"request.maturityHeight must fit in uint32"} - } - if uint64(plan.LockTime) != request.MaturityHeight { - return &inputError{"request.migrationTransactionPlan.lockTime must match request.maturityHeight"} - } - if plan.InputValueSats < plan.DestinationValueSats { - return &inputError{"request.migrationTransactionPlan.inputValueSats must cover destinationValueSats"} - } - remainingAfterDestination := plan.InputValueSats - plan.DestinationValueSats - if remainingAfterDestination < plan.AnchorValueSats { - return &inputError{"request.migrationTransactionPlan.inputValueSats must cover anchorValueSats"} - } - remainingAfterAnchor := remainingAfterDestination - plan.AnchorValueSats - if remainingAfterAnchor != plan.FeeSats { - return &inputError{"request.migrationTransactionPlan values must satisfy inputValueSats = destinationValueSats + anchorValueSats + feeSats"} - } - - expectedCommitmentHash, err := computeMigrationTransactionPlanCommitmentHash(request, plan) - if err != nil { - return err - } - if normalizeLowerHex(plan.PlanCommitmentHash) != expectedCommitmentHash { - return &inputError{"request.migrationTransactionPlan.planCommitmentHash does not match canonical migration transaction plan"} - } - - return nil -} - -func validateArtifactSignatures(signatures []string) ([]string, error) { - if len(signatures) == 0 { - return nil, &inputError{"request.artifactSignatures must not be empty"} - } - - normalizedSignatures := make([]string, len(signatures)) - for i, signature := range signatures { - if err := validateHexString( - fmt.Sprintf("request.artifactSignatures[%d]", i), - signature, - ); err != nil { - return nil, err - } - - normalizedSignatures[i] = normalizeLowerHex(signature) - } - - return normalizedSignatures, nil -} - -func requiredStructuredArtifactApprovalRoles(route TemplateID) ([]ArtifactApprovalRole, error) { - switch route { - case TemplateQcV1: - return []ArtifactApprovalRole{ - ArtifactApprovalRoleDepositor, - ArtifactApprovalRoleCustodian, - }, nil - case TemplateSelfV1: - return []ArtifactApprovalRole{ - ArtifactApprovalRoleDepositor, - }, nil - default: - return nil, &inputError{"unsupported request.route"} - } -} - -func validateArtifactApprovals(route TemplateID, request RouteSubmitRequest) error { - _, _, _, err := normalizeArtifactApprovals(route, request) - return err +func normalizeLowerHex(value string) string { + return strings.ToLower(value) } -func normalizeArtifactApprovals( +func normalizeRequestType( route TemplateID, - request RouteSubmitRequest, -) (*ArtifactApprovalEnvelope, *SignerApprovalCertificate, []string, error) { - normalizedSignerApproval, err := normalizeSignerApprovalCertificate(request) - if err != nil { - return nil, nil, nil, err - } - - normalizedLegacySignatures, err := validateArtifactSignatures(request.ArtifactSignatures) - if err != nil { - return nil, nil, nil, err - } - - if request.ArtifactApprovals == nil { - return nil, normalizedSignerApproval, normalizedLegacySignatures, nil - } - if request.MigrationTransactionPlan == nil { - return nil, nil, nil, &inputError{"request.migrationTransactionPlan is required when request.artifactApprovals is present"} - } - - if request.ArtifactApprovals.Payload.ApprovalVersion != artifactApprovalVersion { - return nil, nil, nil, &inputError{"request.artifactApprovals.payload.approvalVersion must equal 1"} - } - if request.ArtifactApprovals.Payload.Route != route { - return nil, nil, nil, &inputError{"request.artifactApprovals.payload.route must match request.route"} - } - if request.ArtifactApprovals.Payload.ScriptTemplateID != route { - return nil, nil, nil, &inputError{"request.artifactApprovals.payload.scriptTemplateId must match request.route"} - } - if err := validateBytes32HexString( - "request.artifactApprovals.payload.destinationCommitmentHash", - request.ArtifactApprovals.Payload.DestinationCommitmentHash, - ); err != nil { - return nil, nil, nil, err - } - if err := validateBytes32HexString( - "request.artifactApprovals.payload.planCommitmentHash", - request.ArtifactApprovals.Payload.PlanCommitmentHash, - ); err != nil { - return nil, nil, nil, err - } - - normalizedDestinationCommitmentHash := normalizeLowerHex( - request.ArtifactApprovals.Payload.DestinationCommitmentHash, - ) - if normalizedDestinationCommitmentHash != normalizeLowerHex(request.DestinationCommitmentHash) { - return nil, nil, nil, &inputError{"request.artifactApprovals.payload.destinationCommitmentHash must match request.destinationCommitmentHash"} - } - - normalizedPlanCommitmentHash := normalizeLowerHex( - request.ArtifactApprovals.Payload.PlanCommitmentHash, - ) - if normalizedPlanCommitmentHash != normalizeLowerHex(request.MigrationTransactionPlan.PlanCommitmentHash) { - return nil, nil, nil, &inputError{"request.artifactApprovals.payload.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash"} - } - if len(request.ArtifactApprovals.Approvals) == 0 { - return nil, nil, nil, &inputError{"request.artifactApprovals.approvals must not be empty"} - } - - requiredRoles, err := requiredStructuredArtifactApprovalRoles(route) - if err != nil { - return nil, nil, nil, err - } - - allowedRoles := make(map[ArtifactApprovalRole]struct{}, len(requiredRoles)) - for _, role := range requiredRoles { - allowedRoles[role] = struct{}{} - } - - approvalsByRole := make(map[ArtifactApprovalRole]string, len(requiredRoles)) - for i, approval := range request.ArtifactApprovals.Approvals { - if _, ok := allowedRoles[approval.Role]; !ok { - return nil, nil, nil, &inputError{fmt.Sprintf( - "request.artifactApprovals.approvals[%d].role is not allowed for %s", - i, - route, - )} - } - if _, ok := approvalsByRole[approval.Role]; ok { - return nil, nil, nil, &inputError{fmt.Sprintf( - "request.artifactApprovals.approvals[%d].role duplicates role %s", - i, - approval.Role, - )} - } - if err := validateHexString( - fmt.Sprintf("request.artifactApprovals.approvals[%d].signature", i), - approval.Signature, - ); err != nil { - return nil, nil, nil, err - } - - approvalsByRole[approval.Role] = normalizeLowerHex(approval.Signature) - } - - derivedLegacySignatures := make([]string, 0, len(requiredRoles)+1) - normalizedApprovals := &ArtifactApprovalEnvelope{ - Payload: ArtifactApprovalPayload{ - ApprovalVersion: artifactApprovalVersion, - Route: route, - ScriptTemplateID: route, - DestinationCommitmentHash: normalizedDestinationCommitmentHash, - PlanCommitmentHash: normalizedPlanCommitmentHash, - }, - Approvals: make([]ArtifactRoleApproval, len(requiredRoles)), - } - for i, role := range requiredRoles { - signature, ok := approvalsByRole[role] - if !ok { - return nil, nil, nil, &inputError{fmt.Sprintf( - "request.artifactApprovals.approvals must include role %s for %s", - role, - route, - )} - } - - derivedLegacySignatures = append(derivedLegacySignatures, signature) - normalizedApprovals.Approvals[i] = ArtifactRoleApproval{ - Role: role, - Signature: signature, - } - } - - if normalizedSignerApproval != nil { - derivedLegacySignatures = append( - derivedLegacySignatures, - normalizedSignerApproval.Signature, - ) - } - - canonicalSignatureError := "request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals" - if normalizedSignerApproval != nil { - canonicalSignatureError = "request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals and request.signerApproval" - } - - if len(normalizedLegacySignatures) != len(derivedLegacySignatures) { - return nil, nil, nil, &inputError{canonicalSignatureError} - } - for i := range derivedLegacySignatures { - if normalizedLegacySignatures[i] != derivedLegacySignatures[i] { - return nil, nil, nil, &inputError{canonicalSignatureError} - } - } - - return normalizedApprovals, normalizedSignerApproval, derivedLegacySignatures, nil -} - -func validateArtifactApprovalAuthenticity( - request RouteSubmitRequest, - depositorPublicKey string, - custodianPublicKey string, -) error { - payloadDigest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) - if err != nil { - return err - } - - depositorKey, err := parseCompressedSecp256k1PublicKey( - "request.scriptTemplate.depositorPublicKey", - depositorPublicKey, - ) - if err != nil { - return err - } - - var custodianKey *btcec.PublicKey - if custodianPublicKey != "" { - custodianKey, err = parseCompressedSecp256k1PublicKey( - "request.scriptTemplate.custodianPublicKey", - custodianPublicKey, - ) - if err != nil { - return err - } - } - - for i, approval := range request.ArtifactApprovals.Approvals { - signaturePath := fmt.Sprintf( - "request.artifactApprovals.approvals[%d].signature", - i, - ) - - switch approval.Role { - case ArtifactApprovalRoleDepositor: - if err := verifySecp256k1Signature( - signaturePath, - depositorKey, - payloadDigest, - approval.Signature, - ); err != nil { - return err - } - case ArtifactApprovalRoleCustodian: - if custodianKey == nil { - return &inputError{ - "request.artifactApprovals.approvals includes unexpected custodian role", - } - } - if err := verifySecp256k1Signature( - signaturePath, - custodianKey, - payloadDigest, - approval.Signature, - ); err != nil { - return err - } - } - } - - return nil -} - -func normalizeArtifactRecord(record ArtifactRecord) ArtifactRecord { - normalized := ArtifactRecord{ - PSBTHash: normalizeLowerHex(record.PSBTHash), - DestinationCommitmentHash: normalizeLowerHex(record.DestinationCommitmentHash), - } - if record.TransactionHex != "" { - normalized.TransactionHex = normalizeLowerHex(record.TransactionHex) - } - if record.TransactionID != "" { - normalized.TransactionID = normalizeLowerHex(record.TransactionID) - } - - return normalized -} - -func normalizeArtifacts(artifacts map[RecoveryPathID]ArtifactRecord) map[RecoveryPathID]ArtifactRecord { - if artifacts == nil { - return nil - } - - normalized := make(map[RecoveryPathID]ArtifactRecord, len(artifacts)) - for pathID, artifact := range artifacts { - normalized[pathID] = normalizeArtifactRecord(artifact) - } - - return normalized -} - -func normalizeMigrationDestination( - destination *MigrationDestinationReservation, -) *MigrationDestinationReservation { - if destination == nil { - return nil - } - - return &MigrationDestinationReservation{ - ReservationID: strings.TrimSpace(destination.ReservationID), - Reserve: normalizeLowerHex(destination.Reserve), - Epoch: destination.Epoch, - Route: destination.Route, - Revealer: normalizeLowerHex(destination.Revealer), - Vault: normalizeLowerHex(destination.Vault), - Network: strings.TrimSpace(destination.Network), - Status: destination.Status, - DepositScript: normalizeLowerHex(destination.DepositScript), - DepositScriptHash: normalizeLowerHex(destination.DepositScriptHash), - MigrationExtraData: normalizeLowerHex(destination.MigrationExtraData), - DestinationCommitmentHash: normalizeLowerHex(destination.DestinationCommitmentHash), - } -} - -func normalizeMigrationTransactionPlan( - plan *MigrationTransactionPlan, -) *MigrationTransactionPlan { - if plan == nil { - return nil - } - - return &MigrationTransactionPlan{ - PlanVersion: plan.PlanVersion, - PlanCommitmentHash: normalizeLowerHex(plan.PlanCommitmentHash), - InputValueSats: plan.InputValueSats, - DestinationValueSats: plan.DestinationValueSats, - AnchorValueSats: plan.AnchorValueSats, - FeeSats: plan.FeeSats, - InputSequence: plan.InputSequence, - LockTime: plan.LockTime, - } -} - -func normalizeScriptTemplate(route TemplateID, rawTemplate json.RawMessage) (json.RawMessage, error) { - switch route { - case TemplateSelfV1: - template := &SelfV1Template{} - if err := strictUnmarshal(rawTemplate, template); err != nil { - return nil, err - } - template.DepositorPublicKey = normalizeLowerHex(template.DepositorPublicKey) - template.SignerPublicKey = normalizeLowerHex(template.SignerPublicKey) - return json.Marshal(template) - case TemplateQcV1: - template := &QcV1Template{} - if err := strictUnmarshal(rawTemplate, template); err != nil { - return nil, err + requestType RequestType, +) (RequestType, error) { + switch requestType { + case RequestTypeReconstruct: + return requestType, nil + case RequestTypePresignSelfV1: + if route != TemplateSelfV1 { + return "", &inputError{"request.requestType must be reconstruct for qc_v1"} } - template.DepositorPublicKey = normalizeLowerHex(template.DepositorPublicKey) - template.CustodianPublicKey = normalizeLowerHex(template.CustodianPublicKey) - template.SignerPublicKey = normalizeLowerHex(template.SignerPublicKey) - return json.Marshal(template) + return requestType, nil default: - return nil, &inputError{"unsupported request.route"} + return "", &inputError{"request.requestType must be reconstruct or presign_self_v1"} } } @@ -1913,3 +501,24 @@ func validatePollInput( } return nil } + +// migrationPlanQuoteSigningPayload is a private struct used only within the +// quote signing functions in validation_quote.go. +type migrationPlanQuoteSigningPayload struct { + QuoteVersion uint32 `json:"quoteVersion"` + QuoteID string `json:"quoteId"` + ReservationID string `json:"reservationId"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route string `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + ActiveOutpointTxID string `json:"activeOutpointTxid"` + ActiveOutpointVout uint32 `json:"activeOutpointVout"` + PlanCommitmentHash string `json:"planCommitmentHash"` + IssuedAt string `json:"issuedAt"` + ExpiresAt string `json:"expiresAt"` + ExpiresInSeconds uint64 `json:"expiresInSeconds"` +} diff --git a/pkg/covenantsigner/validation_approval.go b/pkg/covenantsigner/validation_approval.go new file mode 100644 index 0000000000..fd5e78cbc8 --- /dev/null +++ b/pkg/covenantsigner/validation_approval.go @@ -0,0 +1,649 @@ +package covenantsigner + +import ( + "crypto/ecdsa" + "encoding/binary" + "encoding/hex" + "fmt" + "math/big" + "sort" + "strings" + + "github.com/btcsuite/btcd/btcec" + "github.com/ethereum/go-ethereum/crypto" +) + +func normalizeSignerApprovalMemberIndexes( + name string, + values []uint32, +) ([]uint32, error) { + if len(values) == 0 { + return nil, nil + } + + normalized := append([]uint32{}, values...) + seen := make(map[uint32]struct{}, len(normalized)) + for i, value := range normalized { + if value == 0 { + return nil, &inputError{ + fmt.Sprintf("%s[%d] must be greater than zero", name, i), + } + } + if err := validateUint32Range(name, uint64(value)); err != nil { + return nil, err + } + if _, ok := seen[value]; ok { + return nil, &inputError{ + fmt.Sprintf("%s[%d] duplicates member %d", name, i, value), + } + } + seen[value] = struct{}{} + } + + sort.Slice(normalized, func(i, j int) bool { + return normalized[i] < normalized[j] + }) + + return normalized, nil +} + +func normalizeSignerApprovalCertificate( + request RouteSubmitRequest, +) (*SignerApprovalCertificate, error) { + if request.SignerApproval == nil { + return nil, nil + } + if request.ArtifactApprovals == nil { + return nil, &inputError{ + "request.artifactApprovals is required when request.signerApproval is present", + } + } + + signerApproval := request.SignerApproval + if signerApproval.CertificateVersion != signerApprovalCertificateVersion { + return nil, &inputError{ + fmt.Sprintf( + "request.signerApproval.certificateVersion must equal %d", + signerApprovalCertificateVersion, + ), + } + } + if signerApproval.SignatureAlgorithm != signerApprovalSignatureAlgorithm { + return nil, &inputError{ + fmt.Sprintf( + "request.signerApproval.signatureAlgorithm must equal %s", + signerApprovalSignatureAlgorithm, + ), + } + } + if err := validateBytes32HexString( + "request.signerApproval.approvalDigest", + signerApproval.ApprovalDigest, + ); err != nil { + return nil, err + } + if err := validateHexString( + "request.signerApproval.walletPublicKey", + signerApproval.WalletPublicKey, + ); err != nil { + return nil, err + } + if len(signerApproval.WalletPublicKey) != 132 { + // This must match tbtc marshalPublicKey/unmarshalPublicKey: + // uncompressed SEC1 public key (0x04 + 64-byte coordinates). + return nil, &inputError{ + "request.signerApproval.walletPublicKey must be a 65-byte uncompressed secp256k1 public key", + } + } + normalizedWalletPublicKey := normalizeLowerHex(signerApproval.WalletPublicKey) + if !strings.HasPrefix(normalizedWalletPublicKey, "0x04") { + return nil, &inputError{ + "request.signerApproval.walletPublicKey must be a 65-byte uncompressed secp256k1 public key", + } + } + if err := validateBytes32HexString( + "request.signerApproval.signerSetHash", + signerApproval.SignerSetHash, + ); err != nil { + return nil, err + } + if err := validateHexString( + "request.signerApproval.signature", + signerApproval.Signature, + ); err != nil { + return nil, err + } + + expectedApprovalDigest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + if err != nil { + return nil, err + } + + normalizedApprovalDigest := normalizeLowerHex(signerApproval.ApprovalDigest) + if normalizedApprovalDigest != "0x"+hex.EncodeToString(expectedApprovalDigest) { + return nil, &inputError{ + "request.signerApproval.approvalDigest must match the canonical artifactApprovals payload digest", + } + } + + normalizedSignerApproval := &SignerApprovalCertificate{ + CertificateVersion: signerApprovalCertificateVersion, + SignatureAlgorithm: signerApprovalSignatureAlgorithm, + ApprovalDigest: normalizedApprovalDigest, + WalletPublicKey: normalizedWalletPublicKey, + SignerSetHash: normalizeLowerHex(signerApproval.SignerSetHash), + Signature: normalizeLowerHex(signerApproval.Signature), + } + + activeMembers, err := normalizeSignerApprovalMemberIndexes( + "request.signerApproval.activeMembers", + signerApproval.ActiveMembers, + ) + if err != nil { + return nil, err + } + if len(activeMembers) > 0 { + normalizedSignerApproval.ActiveMembers = activeMembers + } + + inactiveMembers, err := normalizeSignerApprovalMemberIndexes( + "request.signerApproval.inactiveMembers", + signerApproval.InactiveMembers, + ) + if err != nil { + return nil, err + } + if len(inactiveMembers) > 0 { + normalizedSignerApproval.InactiveMembers = inactiveMembers + } + + if len(activeMembers) > 0 && len(inactiveMembers) > 0 { + activeSet := make(map[uint32]struct{}, len(activeMembers)) + for _, value := range activeMembers { + activeSet[value] = struct{}{} + } + for _, value := range inactiveMembers { + if _, ok := activeSet[value]; ok { + return nil, &inputError{ + "request.signerApproval.activeMembers and request.signerApproval.inactiveMembers must not overlap", + } + } + } + } + + if signerApproval.EndBlock != nil { + if err := validateUint32Range( + "request.signerApproval.endBlock", + *signerApproval.EndBlock, + ); err != nil { + return nil, err + } + endBlock := *signerApproval.EndBlock + normalizedSignerApproval.EndBlock = &endBlock + } + + return normalizedSignerApproval, nil +} + +func abiEncodeUint32Word(value uint32) [32]byte { + var encoded [32]byte + binary.BigEndian.PutUint32(encoded[28:], value) + return encoded +} + +func keccakTemplateIdentifier(id TemplateID) [32]byte { + hash := crypto.Keccak256Hash([]byte(id)) + + var encoded [32]byte + copy(encoded[:], hash.Bytes()) + + return encoded +} + +// artifactApprovalDigest pins the current phase-1 approval payload contract to +// a deterministic EIP-712-compatible struct hash, without yet committing to a +// chain-specific domain separator. +func artifactApprovalDigest(payload ArtifactApprovalPayload) ([]byte, error) { + destinationCommitmentHash, err := decodeBytes32HexString( + "request.artifactApprovals.payload.destinationCommitmentHash", + payload.DestinationCommitmentHash, + ) + if err != nil { + return nil, err + } + + planCommitmentHash, err := decodeBytes32HexString( + "request.artifactApprovals.payload.planCommitmentHash", + payload.PlanCommitmentHash, + ) + if err != nil { + return nil, err + } + + encoded := make([]byte, 32*6) + approvalVersionWord := abiEncodeUint32Word(payload.ApprovalVersion) + routeIdentifier := keccakTemplateIdentifier(payload.Route) + scriptTemplateIdentifier := keccakTemplateIdentifier(payload.ScriptTemplateID) + + copy(encoded[0:32], artifactApprovalTypeHash.Bytes()) + copy(encoded[32:64], approvalVersionWord[:]) + copy(encoded[64:96], routeIdentifier[:]) + copy(encoded[96:128], scriptTemplateIdentifier[:]) + copy(encoded[128:160], destinationCommitmentHash[:]) + copy(encoded[160:192], planCommitmentHash[:]) + + digest := crypto.Keccak256Hash(encoded) + return digest.Bytes(), nil +} + +// ComputeArtifactApprovalDigest exposes the current phase-1 approval payload +// digest contract to cross-package verifiers that need to bind +// signerApproval.approvalDigest to request.artifactApprovals.payload. +func ComputeArtifactApprovalDigest(payload ArtifactApprovalPayload) ([]byte, error) { + return artifactApprovalDigest(payload) +} + +func parseCompressedSecp256k1PublicKey( + name string, + value string, +) (*btcec.PublicKey, error) { + if err := validateHexString(name, value); err != nil { + return nil, err + } + + rawValue, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + if err != nil { + return nil, &inputError{fmt.Sprintf("%s must be valid hex", name)} + } + + if len(rawValue) != 33 || (rawValue[0] != 0x02 && rawValue[0] != 0x03) { + return nil, &inputError{fmt.Sprintf("%s must be a compressed secp256k1 public key", name)} + } + + publicKey, err := btcec.ParsePubKey(rawValue, btcec.S256()) + if err != nil { + return nil, &inputError{fmt.Sprintf("%s must be a compressed secp256k1 public key", name)} + } + + return publicKey, nil +} + +func verifyCompactSecp256k1Signature( + publicKey *btcec.PublicKey, + digest []byte, + signature []byte, +) bool { + return ecdsa.Verify( + publicKey.ToECDSA(), + digest, + new(big.Int).SetBytes(signature[:32]), + new(big.Int).SetBytes(signature[32:]), + ) +} + +func isLowSSecp256k1(s *big.Int) bool { + halfOrder := new(big.Int).Rsh(new(big.Int).Set(btcec.S256().N), 1) + return s.Cmp(halfOrder) <= 0 +} + +func verifySecp256k1Signature( + name string, + publicKey *btcec.PublicKey, + digest []byte, + signature string, +) error { + rawSignature, err := hex.DecodeString(strings.TrimPrefix(signature, "0x")) + if err != nil { + return &inputError{fmt.Sprintf("%s must be valid hex", name)} + } + + switch { + case len(rawSignature) == 64: + if !isLowSSecp256k1(new(big.Int).SetBytes(rawSignature[32:])) { + return &inputError{fmt.Sprintf("%s must be a low-S secp256k1 signature", name)} + } + if verifyCompactSecp256k1Signature(publicKey, digest, rawSignature) { + return nil + } + case len(rawSignature) == 65 && + (rawSignature[64] == 0 || rawSignature[64] == 1 || rawSignature[64] == 27 || rawSignature[64] == 28): + if !isLowSSecp256k1(new(big.Int).SetBytes(rawSignature[32:64])) { + return &inputError{fmt.Sprintf("%s must be a low-S secp256k1 signature", name)} + } + if verifyCompactSecp256k1Signature(publicKey, digest, rawSignature[:64]) { + return nil + } + default: + parsedSignature, err := btcec.ParseDERSignature(rawSignature, btcec.S256()) + if err != nil { + return &inputError{ + fmt.Sprintf( + "%s must be a DER or 64/65-byte secp256k1 signature", + name, + ), + } + } + if !isLowSSecp256k1(parsedSignature.S) { + return &inputError{fmt.Sprintf("%s must be a low-S secp256k1 signature", name)} + } + if parsedSignature.Verify(digest, publicKey) { + return nil + } + } + + return &inputError{fmt.Sprintf("%s does not verify against the required public key", name)} +} + +func validateArtifactSignatures(signatures []string) ([]string, error) { + if len(signatures) == 0 { + return nil, &inputError{"request.artifactSignatures must not be empty"} + } + + normalizedSignatures := make([]string, len(signatures)) + for i, signature := range signatures { + if err := validateHexString( + fmt.Sprintf("request.artifactSignatures[%d]", i), + signature, + ); err != nil { + return nil, err + } + + normalizedSignatures[i] = normalizeLowerHex(signature) + } + + return normalizedSignatures, nil +} + +func requiredStructuredArtifactApprovalRoles(route TemplateID) ([]ArtifactApprovalRole, error) { + switch route { + case TemplateQcV1: + return []ArtifactApprovalRole{ + ArtifactApprovalRoleDepositor, + ArtifactApprovalRoleCustodian, + }, nil + case TemplateSelfV1: + return []ArtifactApprovalRole{ + ArtifactApprovalRoleDepositor, + }, nil + default: + return nil, &inputError{"unsupported request.route"} + } +} + +func validateArtifactApprovals(route TemplateID, request RouteSubmitRequest) error { + _, _, _, err := normalizeArtifactApprovals(route, request) + return err +} + +func normalizeArtifactApprovals( + route TemplateID, + request RouteSubmitRequest, +) (*ArtifactApprovalEnvelope, *SignerApprovalCertificate, []string, error) { + normalizedSignerApproval, err := normalizeSignerApprovalCertificate(request) + if err != nil { + return nil, nil, nil, err + } + + normalizedLegacySignatures, err := validateArtifactSignatures(request.ArtifactSignatures) + if err != nil { + return nil, nil, nil, err + } + + if request.ArtifactApprovals == nil { + return nil, normalizedSignerApproval, normalizedLegacySignatures, nil + } + if request.MigrationTransactionPlan == nil { + return nil, nil, nil, &inputError{"request.migrationTransactionPlan is required when request.artifactApprovals is present"} + } + + if request.ArtifactApprovals.Payload.ApprovalVersion != artifactApprovalVersion { + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.approvalVersion must equal 1"} + } + if request.ArtifactApprovals.Payload.Route != route { + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.route must match request.route"} + } + if request.ArtifactApprovals.Payload.ScriptTemplateID != route { + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.scriptTemplateId must match request.route"} + } + if err := validateBytes32HexString( + "request.artifactApprovals.payload.destinationCommitmentHash", + request.ArtifactApprovals.Payload.DestinationCommitmentHash, + ); err != nil { + return nil, nil, nil, err + } + if err := validateBytes32HexString( + "request.artifactApprovals.payload.planCommitmentHash", + request.ArtifactApprovals.Payload.PlanCommitmentHash, + ); err != nil { + return nil, nil, nil, err + } + + normalizedDestinationCommitmentHash := normalizeLowerHex( + request.ArtifactApprovals.Payload.DestinationCommitmentHash, + ) + if normalizedDestinationCommitmentHash != normalizeLowerHex(request.DestinationCommitmentHash) { + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.destinationCommitmentHash must match request.destinationCommitmentHash"} + } + + normalizedPlanCommitmentHash := normalizeLowerHex( + request.ArtifactApprovals.Payload.PlanCommitmentHash, + ) + if normalizedPlanCommitmentHash != normalizeLowerHex(request.MigrationTransactionPlan.PlanCommitmentHash) { + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash"} + } + if len(request.ArtifactApprovals.Approvals) == 0 { + return nil, nil, nil, &inputError{"request.artifactApprovals.approvals must not be empty"} + } + + requiredRoles, err := requiredStructuredArtifactApprovalRoles(route) + if err != nil { + return nil, nil, nil, err + } + + allowedRoles := make(map[ArtifactApprovalRole]struct{}, len(requiredRoles)) + for _, role := range requiredRoles { + allowedRoles[role] = struct{}{} + } + + approvalsByRole := make(map[ArtifactApprovalRole]string, len(requiredRoles)) + for i, approval := range request.ArtifactApprovals.Approvals { + if _, ok := allowedRoles[approval.Role]; !ok { + return nil, nil, nil, &inputError{fmt.Sprintf( + "request.artifactApprovals.approvals[%d].role is not allowed for %s", + i, + route, + )} + } + if _, ok := approvalsByRole[approval.Role]; ok { + return nil, nil, nil, &inputError{fmt.Sprintf( + "request.artifactApprovals.approvals[%d].role duplicates role %s", + i, + approval.Role, + )} + } + if err := validateHexString( + fmt.Sprintf("request.artifactApprovals.approvals[%d].signature", i), + approval.Signature, + ); err != nil { + return nil, nil, nil, err + } + + approvalsByRole[approval.Role] = normalizeLowerHex(approval.Signature) + } + + derivedLegacySignatures := make([]string, 0, len(requiredRoles)+1) + normalizedApprovals := &ArtifactApprovalEnvelope{ + Payload: ArtifactApprovalPayload{ + ApprovalVersion: artifactApprovalVersion, + Route: route, + ScriptTemplateID: route, + DestinationCommitmentHash: normalizedDestinationCommitmentHash, + PlanCommitmentHash: normalizedPlanCommitmentHash, + }, + Approvals: make([]ArtifactRoleApproval, len(requiredRoles)), + } + for i, role := range requiredRoles { + signature, ok := approvalsByRole[role] + if !ok { + return nil, nil, nil, &inputError{fmt.Sprintf( + "request.artifactApprovals.approvals must include role %s for %s", + role, + route, + )} + } + + derivedLegacySignatures = append(derivedLegacySignatures, signature) + normalizedApprovals.Approvals[i] = ArtifactRoleApproval{ + Role: role, + Signature: signature, + } + } + + if normalizedSignerApproval != nil { + derivedLegacySignatures = append( + derivedLegacySignatures, + normalizedSignerApproval.Signature, + ) + } + + canonicalSignatureError := "request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals" + if normalizedSignerApproval != nil { + canonicalSignatureError = "request.artifactSignatures must match canonical approval role order derived from request.artifactApprovals and request.signerApproval" + } + + if len(normalizedLegacySignatures) != len(derivedLegacySignatures) { + return nil, nil, nil, &inputError{canonicalSignatureError} + } + for i := range derivedLegacySignatures { + if normalizedLegacySignatures[i] != derivedLegacySignatures[i] { + return nil, nil, nil, &inputError{canonicalSignatureError} + } + } + + return normalizedApprovals, normalizedSignerApproval, derivedLegacySignatures, nil +} + +func validateArtifactApprovalAuthenticity( + request RouteSubmitRequest, + depositorPublicKey string, + custodianPublicKey string, +) error { + payloadDigest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + if err != nil { + return err + } + + depositorKey, err := parseCompressedSecp256k1PublicKey( + "request.scriptTemplate.depositorPublicKey", + depositorPublicKey, + ) + if err != nil { + return err + } + + var custodianKey *btcec.PublicKey + if custodianPublicKey != "" { + custodianKey, err = parseCompressedSecp256k1PublicKey( + "request.scriptTemplate.custodianPublicKey", + custodianPublicKey, + ) + if err != nil { + return err + } + } + + for i, approval := range request.ArtifactApprovals.Approvals { + signaturePath := fmt.Sprintf( + "request.artifactApprovals.approvals[%d].signature", + i, + ) + + switch approval.Role { + case ArtifactApprovalRoleDepositor: + if err := verifySecp256k1Signature( + signaturePath, + depositorKey, + payloadDigest, + approval.Signature, + ); err != nil { + return err + } + case ArtifactApprovalRoleCustodian: + if custodianKey == nil { + return &inputError{ + "request.artifactApprovals.approvals includes unexpected custodian role", + } + } + if err := verifySecp256k1Signature( + signaturePath, + custodianKey, + payloadDigest, + approval.Signature, + ); err != nil { + return err + } + } + } + + return nil +} + +func normalizeArtifactRecord(record ArtifactRecord) ArtifactRecord { + normalized := ArtifactRecord{ + PSBTHash: normalizeLowerHex(record.PSBTHash), + DestinationCommitmentHash: normalizeLowerHex(record.DestinationCommitmentHash), + } + if record.TransactionHex != "" { + normalized.TransactionHex = normalizeLowerHex(record.TransactionHex) + } + if record.TransactionID != "" { + normalized.TransactionID = normalizeLowerHex(record.TransactionID) + } + + return normalized +} + +func normalizeArtifacts(artifacts map[RecoveryPathID]ArtifactRecord) map[RecoveryPathID]ArtifactRecord { + if artifacts == nil { + return nil + } + + normalized := make(map[RecoveryPathID]ArtifactRecord, len(artifacts)) + for pathID, artifact := range artifacts { + normalized[pathID] = normalizeArtifactRecord(artifact) + } + + return normalized +} + +func resolveExpectedDepositorPublicKey( + request RouteSubmitRequest, + trustRoots []DepositorTrustRoot, +) (string, bool) { + route, reserve, network := trustRootLookupScope(request) + for _, trustRoot := range trustRoots { + if trustRoot.Route == route && + trustRoot.Reserve == reserve && + trustRoot.Network == network { + return trustRoot.PublicKey, true + } + } + + return "", false +} + +func resolveExpectedCustodianPublicKey( + request RouteSubmitRequest, + trustRoots []CustodianTrustRoot, +) (string, bool) { + route, reserve, network := trustRootLookupScope(request) + for _, trustRoot := range trustRoots { + if trustRoot.Route == route && + trustRoot.Reserve == reserve && + trustRoot.Network == network { + return trustRoot.PublicKey, true + } + } + + return "", false +} diff --git a/pkg/covenantsigner/validation_quote.go b/pkg/covenantsigner/validation_quote.go new file mode 100644 index 0000000000..df94658063 --- /dev/null +++ b/pkg/covenantsigner/validation_quote.go @@ -0,0 +1,476 @@ +package covenantsigner + +import ( + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "strings" + "time" + + "github.com/keep-network/keep-core/pkg/internal/canonicaljson" +) + +func normalizeCanonicalTimestamp(name string, value string) (string, error) { + if !canonicalTimestampPattern.MatchString(value) { + return "", &inputError{ + fmt.Sprintf( + "%s must be a UTC ISO-8601 timestamp from Date.toISOString()", + name, + ), + } + } + + return value, nil +} + +func normalizeMigrationPlanQuotePublicKeyPEM(value string) string { + return strings.TrimSpace(strings.ReplaceAll(value, "\\n", "\n")) +} + +func parseMigrationPlanQuoteTrustRoot( + name string, + trustRoot MigrationPlanQuoteTrustRoot, +) (ed25519.PublicKey, error) { + block, _ := pem.Decode([]byte(normalizeMigrationPlanQuotePublicKeyPEM(trustRoot.PublicKeyPEM))) + if block == nil { + return nil, &inputError{fmt.Sprintf("%s.publicKeyPem must be a PEM-encoded public key", name)} + } + + publicKeyValue, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, &inputError{fmt.Sprintf("%s.publicKeyPem must be a PEM-encoded Ed25519 public key", name)} + } + + publicKey, ok := publicKeyValue.(ed25519.PublicKey) + if !ok { + return nil, &inputError{fmt.Sprintf("%s.publicKeyPem must be a PEM-encoded Ed25519 public key", name)} + } + + return publicKey, nil +} + +func normalizeScopedApprovalTrustRoot( + name string, + route TemplateID, + reserve string, + network string, + publicKey string, +) (TemplateID, string, string, string, error) { + switch route { + case TemplateSelfV1, TemplateQcV1: + default: + return "", "", "", "", &inputError{ + fmt.Sprintf("%s.route must be self_v1 or qc_v1", name), + } + } + + if err := validateHexString(name+".reserve", reserve); err != nil { + return "", "", "", "", err + } + + trimmedNetwork := strings.TrimSpace(network) + if trimmedNetwork == "" { + return "", "", "", "", &inputError{ + fmt.Sprintf("%s.network is required", name), + } + } + + normalizedPublicKey := normalizeLowerHex(publicKey) + if _, err := parseCompressedSecp256k1PublicKey( + name+".publicKey", + normalizedPublicKey, + ); err != nil { + return "", "", "", "", err + } + + return route, + normalizeLowerHex(reserve), + strings.ToLower(trimmedNetwork), + normalizedPublicKey, + nil +} + +// normalizeScopedTrustRoots validates and normalizes a slice of trust roots +// of any scoped-approval type. getFields extracts (Route, Reserve, Network, +// PublicKey) from each element; build constructs a normalized element from +// the validated fields. Duplicates within the same route/reserve/network +// scope are rejected. +func normalizeScopedTrustRoots[T any]( + typeName string, + trustRoots []T, + getFields func(T) (TemplateID, string, string, string), + build func(route TemplateID, reserve, network, publicKey string) T, +) ([]T, error) { + if len(trustRoots) == 0 { + return nil, nil + } + + normalized := make([]T, len(trustRoots)) + seen := make(map[string]int, len(trustRoots)) + + for i, trustRoot := range trustRoots { + name := fmt.Sprintf("%s[%d]", typeName, i) + r, res, net, pk := getFields(trustRoot) + route, reserve, network, publicKey, err := normalizeScopedApprovalTrustRoot( + name, r, res, net, pk, + ) + if err != nil { + return nil, err + } + + scopeKey := string(route) + "|" + reserve + "|" + network + if previousIndex, ok := seen[scopeKey]; ok { + return nil, &inputError{ + fmt.Sprintf( + "%s duplicates %s[%d] for route %s reserve %s network %s", + name, + typeName, + previousIndex, + route, + reserve, + network, + ), + } + } + seen[scopeKey] = i + normalized[i] = build(route, reserve, network, publicKey) + } + + return normalized, nil +} + +func normalizeDepositorTrustRoots( + trustRoots []DepositorTrustRoot, +) ([]DepositorTrustRoot, error) { + return normalizeScopedTrustRoots( + "depositorTrustRoots", + trustRoots, + func(t DepositorTrustRoot) (TemplateID, string, string, string) { + return t.Route, t.Reserve, t.Network, t.PublicKey + }, + func(route TemplateID, reserve, network, publicKey string) DepositorTrustRoot { + return DepositorTrustRoot{ + Route: route, Reserve: reserve, + Network: network, PublicKey: publicKey, + } + }, + ) +} + +func normalizeCustodianTrustRoots( + trustRoots []CustodianTrustRoot, +) ([]CustodianTrustRoot, error) { + return normalizeScopedTrustRoots( + "custodianTrustRoots", + trustRoots, + func(t CustodianTrustRoot) (TemplateID, string, string, string) { + return t.Route, t.Reserve, t.Network, t.PublicKey + }, + func(route TemplateID, reserve, network, publicKey string) CustodianTrustRoot { + return CustodianTrustRoot{ + Route: route, Reserve: reserve, + Network: network, PublicKey: publicKey, + } + }, + ) +} + +func trustRootLookupScope(request RouteSubmitRequest) (TemplateID, string, string) { + network := "" + if request.MigrationDestination != nil { + network = strings.ToLower(strings.TrimSpace(request.MigrationDestination.Network)) + } + + return request.Route, normalizeLowerHex(request.Reserve), network +} + +func migrationPlanQuoteSigningPayloadBytes( + quote *MigrationDestinationPlanQuote, +) ([]byte, error) { + return canonicaljson.Marshal(migrationPlanQuoteSigningPayload{ + QuoteVersion: quote.QuoteVersion, + QuoteID: quote.QuoteID, + ReservationID: quote.ReservationID, + Reserve: normalizeLowerHex(quote.Reserve), + Epoch: quote.Epoch, + Route: string(quote.Route), + Revealer: normalizeLowerHex(quote.Revealer), + Vault: normalizeLowerHex(quote.Vault), + Network: quote.Network, + DestinationCommitmentHash: normalizeLowerHex(quote.DestinationCommitmentHash), + ActiveOutpointTxID: normalizeLowerHex(quote.ActiveOutpointTxID), + ActiveOutpointVout: quote.ActiveOutpointVout, + PlanCommitmentHash: normalizeLowerHex(quote.PlanCommitmentHash), + IssuedAt: quote.IssuedAt, + ExpiresAt: quote.ExpiresAt, + ExpiresInSeconds: quote.ExpiresInSeconds, + }) +} + +func migrationPlanQuoteSigningPreimage( + quote *MigrationDestinationPlanQuote, +) ([]byte, error) { + payload, err := migrationPlanQuoteSigningPayloadBytes(quote) + if err != nil { + return nil, err + } + + return []byte(migrationPlanQuoteSigningDomain + string(payload)), nil +} + +func migrationPlanQuoteSigningHash( + quote *MigrationDestinationPlanQuote, +) ([]byte, error) { + preimage, err := migrationPlanQuoteSigningPreimage(quote) + if err != nil { + return nil, err + } + + sum := sha256.Sum256(preimage) + return sum[:], nil +} + +func normalizeMigrationPlanQuote( + request RouteSubmitRequest, + options validationOptions, +) (*MigrationDestinationPlanQuote, error) { + quote := request.MigrationPlanQuote + if quote == nil { + if len(options.migrationPlanQuoteTrustRoots) > 0 && !options.policyIndependentDigest { + return nil, &inputError{ + "request.migrationPlanQuote is required when migrationPlanQuoteTrustRoots are configured", + } + } + + return nil, nil + } + if len(options.migrationPlanQuoteTrustRoots) == 0 && !options.policyIndependentDigest { + return nil, &inputError{"request.migrationPlanQuote verification requires configured trust roots"} + } + if request.MigrationDestination == nil { + return nil, &inputError{"request.migrationDestination is required when request.migrationPlanQuote is present"} + } + if request.MigrationTransactionPlan == nil { + return nil, &inputError{"request.migrationTransactionPlan is required when request.migrationPlanQuote is present"} + } + if quote.QuoteVersion != migrationPlanQuoteVersion { + return nil, &inputError{"request.migrationPlanQuote.quoteVersion must equal 1"} + } + if strings.TrimSpace(quote.QuoteID) == "" { + return nil, &inputError{"request.migrationPlanQuote.quoteId is required"} + } + if strings.TrimSpace(quote.ReservationID) == "" { + return nil, &inputError{"request.migrationPlanQuote.reservationId is required"} + } + if strings.TrimSpace(quote.IdempotencyKey) == "" { + return nil, &inputError{"request.migrationPlanQuote.idempotencyKey is required"} + } + if quote.Route != ReservationRouteMigration { + return nil, &inputError{"request.migrationPlanQuote.route must be MIGRATION"} + } + if err := validateAddressString("request.migrationPlanQuote.reserve", quote.Reserve); err != nil { + return nil, err + } + if err := validateAddressString("request.migrationPlanQuote.revealer", quote.Revealer); err != nil { + return nil, err + } + if err := validateAddressString("request.migrationPlanQuote.vault", quote.Vault); err != nil { + return nil, err + } + if strings.TrimSpace(quote.Network) == "" { + return nil, &inputError{"request.migrationPlanQuote.network is required"} + } + if err := validateBytes32HexString( + "request.migrationPlanQuote.destinationCommitmentHash", + quote.DestinationCommitmentHash, + ); err != nil { + return nil, err + } + if err := validateBytes32HexString( + "request.migrationPlanQuote.activeOutpointTxid", + quote.ActiveOutpointTxID, + ); err != nil { + return nil, err + } + if err := validateBytes32HexString( + "request.migrationPlanQuote.planCommitmentHash", + quote.PlanCommitmentHash, + ); err != nil { + return nil, err + } + if quote.ExpiresInSeconds == 0 { + return nil, &inputError{"request.migrationPlanQuote.expiresInSeconds must be greater than zero"} + } + if quote.Signature.SignatureVersion != migrationPlanQuoteSignatureVersion { + return nil, &inputError{"request.migrationPlanQuote.signature.signatureVersion must equal 1"} + } + if quote.Signature.Algorithm != migrationPlanQuoteSignatureAlgorithm { + return nil, &inputError{"request.migrationPlanQuote.signature.algorithm must equal ed25519"} + } + if strings.TrimSpace(quote.Signature.KeyID) == "" { + return nil, &inputError{"request.migrationPlanQuote.signature.keyId is required"} + } + if err := validateHexString("request.migrationPlanQuote.signature.signature", quote.Signature.Signature); err != nil { + return nil, err + } + + normalizedIssuedAt, err := normalizeCanonicalTimestamp( + "request.migrationPlanQuote.issuedAt", + quote.IssuedAt, + ) + if err != nil { + return nil, err + } + issuedAt, err := time.Parse(time.RFC3339Nano, normalizedIssuedAt) + if err != nil { + return nil, &inputError{ + "request.migrationPlanQuote.issuedAt must be a parseable UTC ISO-8601 timestamp", + } + } + normalizedExpiresAt, err := normalizeCanonicalTimestamp( + "request.migrationPlanQuote.expiresAt", + quote.ExpiresAt, + ) + if err != nil { + return nil, err + } + expiresAt, err := time.Parse(time.RFC3339Nano, normalizedExpiresAt) + if err != nil { + return nil, &inputError{ + "request.migrationPlanQuote.expiresAt must be a parseable UTC ISO-8601 timestamp", + } + } + if !expiresAt.After(issuedAt) { + return nil, &inputError{"request.migrationPlanQuote.expiresAt must be after request.migrationPlanQuote.issuedAt"} + } + if expiresAt.Sub(issuedAt) != time.Duration(quote.ExpiresInSeconds)*time.Second { + return nil, &inputError{"request.migrationPlanQuote.expiresAt must equal request.migrationPlanQuote.issuedAt + expiresInSeconds"} + } + if quote.Epoch != request.Epoch { + return nil, &inputError{"request.migrationPlanQuote.epoch must match request.epoch"} + } + if normalizeLowerHex(quote.Reserve) != normalizeLowerHex(request.Reserve) { + return nil, &inputError{"request.migrationPlanQuote.reserve must match request.reserve"} + } + if quote.ReservationID != request.MigrationDestination.ReservationID { + return nil, &inputError{"request.migrationPlanQuote.reservationId must match request.migrationDestination.reservationId"} + } + if normalizeLowerHex(quote.Revealer) != normalizeLowerHex(request.MigrationDestination.Revealer) { + return nil, &inputError{"request.migrationPlanQuote.revealer must match request.migrationDestination.revealer"} + } + if normalizeLowerHex(quote.Vault) != normalizeLowerHex(request.MigrationDestination.Vault) { + return nil, &inputError{"request.migrationPlanQuote.vault must match request.migrationDestination.vault"} + } + if strings.TrimSpace(quote.Network) != strings.TrimSpace(request.MigrationDestination.Network) { + return nil, &inputError{"request.migrationPlanQuote.network must match request.migrationDestination.network"} + } + if normalizeLowerHex(quote.DestinationCommitmentHash) != normalizeLowerHex(request.DestinationCommitmentHash) { + return nil, &inputError{"request.migrationPlanQuote.destinationCommitmentHash must match request.destinationCommitmentHash"} + } + if normalizeLowerHex(quote.DestinationCommitmentHash) != normalizeLowerHex(request.MigrationDestination.DestinationCommitmentHash) { + return nil, &inputError{"request.migrationPlanQuote.destinationCommitmentHash must match request.migrationDestination.destinationCommitmentHash"} + } + if normalizeLowerHex(quote.ActiveOutpointTxID) != normalizeLowerHex(request.ActiveOutpoint.TxID) { + return nil, &inputError{"request.migrationPlanQuote.activeOutpointTxid must match request.activeOutpoint.txid"} + } + if quote.ActiveOutpointVout != request.ActiveOutpoint.Vout { + return nil, &inputError{"request.migrationPlanQuote.activeOutpointVout must match request.activeOutpoint.vout"} + } + if normalizeLowerHex(quote.PlanCommitmentHash) != normalizeLowerHex(request.MigrationTransactionPlan.PlanCommitmentHash) { + return nil, &inputError{"request.migrationPlanQuote.planCommitmentHash must match request.migrationTransactionPlan.planCommitmentHash"} + } + + normalizedQuotePlan := normalizeMigrationTransactionPlan(quote.MigrationTransactionPlan) + if normalizedQuotePlan == nil { + return nil, &inputError{"request.migrationPlanQuote.migrationTransactionPlan is required"} + } + if err := validateMigrationTransactionPlan(request, quote.MigrationTransactionPlan); err != nil { + return nil, err + } + if !migrationTransactionPlansEqual(normalizedQuotePlan, normalizeMigrationTransactionPlan(request.MigrationTransactionPlan)) { + return nil, &inputError{"request.migrationPlanQuote.migrationTransactionPlan must match request.migrationTransactionPlan"} + } + + normalizedQuote := &MigrationDestinationPlanQuote{ + QuoteID: strings.TrimSpace(quote.QuoteID), + QuoteVersion: migrationPlanQuoteVersion, + ReservationID: strings.TrimSpace(quote.ReservationID), + Reserve: normalizeLowerHex(quote.Reserve), + Epoch: quote.Epoch, + Route: ReservationRouteMigration, + Revealer: normalizeLowerHex(quote.Revealer), + Vault: normalizeLowerHex(quote.Vault), + Network: strings.TrimSpace(quote.Network), + DestinationCommitmentHash: normalizeLowerHex(quote.DestinationCommitmentHash), + ActiveOutpointTxID: normalizeLowerHex(quote.ActiveOutpointTxID), + ActiveOutpointVout: quote.ActiveOutpointVout, + PlanCommitmentHash: normalizeLowerHex(quote.PlanCommitmentHash), + MigrationTransactionPlan: normalizedQuotePlan, + IdempotencyKey: strings.TrimSpace(quote.IdempotencyKey), + ExpiresInSeconds: quote.ExpiresInSeconds, + IssuedAt: normalizedIssuedAt, + ExpiresAt: normalizedExpiresAt, + Signature: MigrationDestinationPlanQuoteSignature{ + SignatureVersion: migrationPlanQuoteSignatureVersion, + Algorithm: migrationPlanQuoteSignatureAlgorithm, + KeyID: strings.TrimSpace(quote.Signature.KeyID), + Signature: normalizeLowerHex(quote.Signature.Signature), + }, + } + if options.policyIndependentDigest { + return normalizedQuote, nil + } + + var publicKey ed25519.PublicKey + foundTrustRoot := false + for i, trustRoot := range options.migrationPlanQuoteTrustRoots { + if trustRoot.KeyID != quote.Signature.KeyID { + continue + } + + publicKey, err = parseMigrationPlanQuoteTrustRoot( + fmt.Sprintf("migrationPlanQuoteTrustRoots[%d]", i), + trustRoot, + ) + if err != nil { + return nil, err + } + foundTrustRoot = true + break + } + if !foundTrustRoot { + return nil, &inputError{"request.migrationPlanQuote.signature.keyId does not match a configured trust root"} + } + + signingHash, err := migrationPlanQuoteSigningHash(normalizedQuote) + if err != nil { + return nil, err + } + + rawSignature, err := hex.DecodeString(strings.TrimPrefix(normalizedQuote.Signature.Signature, "0x")) + if err != nil { + return nil, &inputError{"request.migrationPlanQuote.signature.signature must be valid hex"} + } + if !ed25519.Verify(publicKey, signingHash, rawSignature) { + return nil, &inputError{"request.migrationPlanQuote.signature does not verify against the configured trust root"} + } + + if options.requireFreshMigrationPlanQuote { + verificationNow := options.migrationPlanQuoteVerificationNow + if verificationNow.IsZero() { + verificationNow = time.Now().UTC() + } + // Submit freshness is intentionally strict. Poll omits this check so + // already-accepted jobs remain addressable after quote expiry; operators + // must keep the destination service and keep-core on synchronized UTC + // time when enforcing quote freshness. + if expiresAt.Before(verificationNow) { + return nil, &inputError{"request.migrationPlanQuote is expired"} + } + } + + return normalizedQuote, nil +} diff --git a/pkg/covenantsigner/validation_template.go b/pkg/covenantsigner/validation_template.go new file mode 100644 index 0000000000..1095b0ce74 --- /dev/null +++ b/pkg/covenantsigner/validation_template.go @@ -0,0 +1,317 @@ +package covenantsigner + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "math" + "strings" + + "github.com/keep-network/keep-core/pkg/internal/canonicaljson" +) + +func computeMigrationExtraData(revealer string) string { + return "0x" + hex.EncodeToString([]byte("AC_MIGRATEV1")) + strings.TrimPrefix(normalizeLowerHex(revealer), "0x") +} + +func computeDepositScriptHash(depositScript string) (string, error) { + rawScript, err := hex.DecodeString(strings.TrimPrefix(depositScript, "0x")) + if err != nil { + return "", err + } + + sum := sha256.Sum256(rawScript) + return "0x" + hex.EncodeToString(sum[:]), nil +} + +type destinationCommitmentPayload struct { + // Field order is hash-significant and must stay aligned with the TypeScript + // reservation-service object literal used to compute the same commitment. + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route string `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + DepositScriptHash string `json:"depositScriptHash"` + MigrationExtraData string `json:"migrationExtraData"` +} + +type migrationPlanCommitmentPayload struct { + // Field order is hash-significant and must stay aligned with the TypeScript + // migration transaction-plan commitment payload. planCommitmentHash is + // intentionally omitted because it is the output of this computation. + PlanVersion uint32 `json:"planVersion"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + ActiveOutpointTxID string `json:"activeOutpointTxid"` + ActiveOutpointVout uint32 `json:"activeOutpointVout"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` + InputValueSats uint64 `json:"inputValueSats"` + DestinationValueSats uint64 `json:"destinationValueSats"` + AnchorValueSats uint64 `json:"anchorValueSats"` + FeeSats uint64 `json:"feeSats"` + InputSequence uint32 `json:"inputSequence"` + LockTime uint32 `json:"lockTime"` +} + +func computeDestinationCommitmentHash( + reservation *MigrationDestinationReservation, +) (string, error) { + payload, err := canonicaljson.Marshal(destinationCommitmentPayload{ + Reserve: normalizeLowerHex(reservation.Reserve), + Epoch: reservation.Epoch, + Route: string(reservation.Route), + Revealer: normalizeLowerHex(reservation.Revealer), + Vault: normalizeLowerHex(reservation.Vault), + Network: strings.TrimSpace(reservation.Network), + DepositScriptHash: normalizeLowerHex(reservation.DepositScriptHash), + MigrationExtraData: normalizeLowerHex(reservation.MigrationExtraData), + }) + if err != nil { + return "", err + } + + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]), nil +} + +func computeMigrationTransactionPlanCommitmentHash( + request RouteSubmitRequest, + plan *MigrationTransactionPlan, +) (string, error) { + payload, err := canonicaljson.Marshal(migrationPlanCommitmentPayload{ + PlanVersion: plan.PlanVersion, + Reserve: normalizeLowerHex(request.Reserve), + Epoch: request.Epoch, + ActiveOutpointTxID: normalizeLowerHex(request.ActiveOutpoint.TxID), + ActiveOutpointVout: request.ActiveOutpoint.Vout, + DestinationCommitmentHash: normalizeLowerHex(request.DestinationCommitmentHash), + InputValueSats: plan.InputValueSats, + DestinationValueSats: plan.DestinationValueSats, + AnchorValueSats: plan.AnchorValueSats, + FeeSats: plan.FeeSats, + InputSequence: plan.InputSequence, + LockTime: plan.LockTime, + }) + if err != nil { + return "", err + } + + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]), nil +} + +func validateMigrationDestination( + request RouteSubmitRequest, + reservation *MigrationDestinationReservation, +) error { + if reservation == nil { + return &inputError{"request.migrationDestination is required"} + } + if reservation.Route != ReservationRouteMigration { + return &inputError{"request.migrationDestination.route must be MIGRATION"} + } + if reservation.Status != ReservationStatusReserved && + reservation.Status != ReservationStatusCommittedToEpoch { + return &inputError{"request.migrationDestination.status must be RESERVED or COMMITTED_TO_EPOCH"} + } + if err := validateAddressString("request.migrationDestination.reserve", reservation.Reserve); err != nil { + return err + } + if err := validateAddressString("request.migrationDestination.revealer", reservation.Revealer); err != nil { + return err + } + if err := validateAddressString("request.migrationDestination.vault", reservation.Vault); err != nil { + return err + } + if strings.TrimSpace(reservation.Network) == "" { + return &inputError{"request.migrationDestination.network is required"} + } + if err := validateHexString("request.migrationDestination.depositScript", reservation.DepositScript); err != nil { + return err + } + if err := validateHexString("request.migrationDestination.depositScriptHash", reservation.DepositScriptHash); err != nil { + return err + } + if err := validateHexString("request.migrationDestination.migrationExtraData", reservation.MigrationExtraData); err != nil { + return err + } + if err := validateHexString("request.migrationDestination.destinationCommitmentHash", reservation.DestinationCommitmentHash); err != nil { + return err + } + if request.Epoch != reservation.Epoch { + return &inputError{"request.migrationDestination.epoch does not match request.epoch"} + } + if normalizeLowerHex(request.Reserve) != normalizeLowerHex(reservation.Reserve) { + return &inputError{"request.migrationDestination.reserve does not match request.reserve"} + } + if normalizeLowerHex(request.DestinationCommitmentHash) != normalizeLowerHex(reservation.DestinationCommitmentHash) { + return &inputError{"request.migrationDestination.destinationCommitmentHash does not match request.destinationCommitmentHash"} + } + + expectedExtraData := computeMigrationExtraData(reservation.Revealer) + if normalizeLowerHex(reservation.MigrationExtraData) != expectedExtraData { + return &inputError{"request.migrationDestination.migrationExtraData does not match migration revealer encoding"} + } + + depositScriptHash, err := computeDepositScriptHash(reservation.DepositScript) + if err != nil { + return &inputError{"request.migrationDestination.depositScript is not valid hex"} + } + if normalizeLowerHex(reservation.DepositScriptHash) != depositScriptHash { + return &inputError{"request.migrationDestination.depositScriptHash does not match depositScript"} + } + + commitmentHash, err := computeDestinationCommitmentHash(reservation) + if err != nil { + return err + } + if normalizeLowerHex(reservation.DestinationCommitmentHash) != commitmentHash { + return &inputError{"request.migrationDestination.destinationCommitmentHash does not match canonical reservation artifact"} + } + + return nil +} + +func validateMigrationTransactionPlan( + request RouteSubmitRequest, + plan *MigrationTransactionPlan, +) error { + if plan == nil { + return &inputError{"request.migrationTransactionPlan is required"} + } + if plan.PlanVersion != migrationTransactionPlanVersion { + return &inputError{"request.migrationTransactionPlan.planVersion must equal 1"} + } + if err := validateHexString("request.migrationTransactionPlan.planCommitmentHash", plan.PlanCommitmentHash); err != nil { + return err + } + if plan.InputValueSats == 0 { + return &inputError{"request.migrationTransactionPlan.inputValueSats must be greater than zero"} + } + if plan.DestinationValueSats == 0 { + return &inputError{"request.migrationTransactionPlan.destinationValueSats must be greater than zero"} + } + if plan.FeeSats == 0 { + return &inputError{"request.migrationTransactionPlan.feeSats must be greater than zero"} + } + if plan.AnchorValueSats != canonicalAnchorValueSats { + return &inputError{"request.migrationTransactionPlan.anchorValueSats must equal the canonical 330 sat anchor"} + } + if plan.InputSequence != canonicalCovenantInputSequence { + return &inputError{"request.migrationTransactionPlan.inputSequence must equal 0xFFFFFFFD"} + } + if request.MaturityHeight > math.MaxUint32 { + return &inputError{"request.maturityHeight must fit in uint32"} + } + if uint64(plan.LockTime) != request.MaturityHeight { + return &inputError{"request.migrationTransactionPlan.lockTime must match request.maturityHeight"} + } + if plan.InputValueSats < plan.DestinationValueSats { + return &inputError{"request.migrationTransactionPlan.inputValueSats must cover destinationValueSats"} + } + remainingAfterDestination := plan.InputValueSats - plan.DestinationValueSats + if remainingAfterDestination < plan.AnchorValueSats { + return &inputError{"request.migrationTransactionPlan.inputValueSats must cover anchorValueSats"} + } + remainingAfterAnchor := remainingAfterDestination - plan.AnchorValueSats + if remainingAfterAnchor != plan.FeeSats { + return &inputError{"request.migrationTransactionPlan values must satisfy inputValueSats = destinationValueSats + anchorValueSats + feeSats"} + } + + expectedCommitmentHash, err := computeMigrationTransactionPlanCommitmentHash(request, plan) + if err != nil { + return err + } + if normalizeLowerHex(plan.PlanCommitmentHash) != expectedCommitmentHash { + return &inputError{"request.migrationTransactionPlan.planCommitmentHash does not match canonical migration transaction plan"} + } + + return nil +} + +func normalizeMigrationDestination( + destination *MigrationDestinationReservation, +) *MigrationDestinationReservation { + if destination == nil { + return nil + } + + return &MigrationDestinationReservation{ + ReservationID: strings.TrimSpace(destination.ReservationID), + Reserve: normalizeLowerHex(destination.Reserve), + Epoch: destination.Epoch, + Route: destination.Route, + Revealer: normalizeLowerHex(destination.Revealer), + Vault: normalizeLowerHex(destination.Vault), + Network: strings.TrimSpace(destination.Network), + Status: destination.Status, + DepositScript: normalizeLowerHex(destination.DepositScript), + DepositScriptHash: normalizeLowerHex(destination.DepositScriptHash), + MigrationExtraData: normalizeLowerHex(destination.MigrationExtraData), + DestinationCommitmentHash: normalizeLowerHex(destination.DestinationCommitmentHash), + } +} + +func normalizeMigrationTransactionPlan( + plan *MigrationTransactionPlan, +) *MigrationTransactionPlan { + if plan == nil { + return nil + } + + return &MigrationTransactionPlan{ + PlanVersion: plan.PlanVersion, + PlanCommitmentHash: normalizeLowerHex(plan.PlanCommitmentHash), + InputValueSats: plan.InputValueSats, + DestinationValueSats: plan.DestinationValueSats, + AnchorValueSats: plan.AnchorValueSats, + FeeSats: plan.FeeSats, + InputSequence: plan.InputSequence, + LockTime: plan.LockTime, + } +} + +// migrationTransactionPlansEqual compares two normalized MigrationTransactionPlan +// values for equality without using reflect.DeepEqual. +func migrationTransactionPlansEqual(a, b *MigrationTransactionPlan) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return a.PlanVersion == b.PlanVersion && + a.PlanCommitmentHash == b.PlanCommitmentHash && + a.InputValueSats == b.InputValueSats && + a.DestinationValueSats == b.DestinationValueSats && + a.AnchorValueSats == b.AnchorValueSats && + a.FeeSats == b.FeeSats && + a.InputSequence == b.InputSequence && + a.LockTime == b.LockTime +} + +func normalizeScriptTemplate(route TemplateID, rawTemplate json.RawMessage) (json.RawMessage, error) { + switch route { + case TemplateSelfV1: + template := &SelfV1Template{} + if err := strictUnmarshal(rawTemplate, template); err != nil { + return nil, err + } + template.DepositorPublicKey = normalizeLowerHex(template.DepositorPublicKey) + template.SignerPublicKey = normalizeLowerHex(template.SignerPublicKey) + return json.Marshal(template) + case TemplateQcV1: + template := &QcV1Template{} + if err := strictUnmarshal(rawTemplate, template); err != nil { + return nil, err + } + template.DepositorPublicKey = normalizeLowerHex(template.DepositorPublicKey) + template.CustodianPublicKey = normalizeLowerHex(template.CustodianPublicKey) + template.SignerPublicKey = normalizeLowerHex(template.SignerPublicKey) + return json.Marshal(template) + default: + return nil, &inputError{"unsupported request.route"} + } +} From f7f72435e8bcdccb06cc4a3f0fbad46ee402dd86 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 13:10:04 +0000 Subject: [PATCH 131/143] fix(ci): suppress gosec false positives and handle cleanup errors in lock acquisition paths --- pkg/covenantsigner/server.go | 3 +++ pkg/covenantsigner/service.go | 4 +++- pkg/covenantsigner/store.go | 12 +++++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index c34d9424df..9f2e91f8e9 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -148,6 +148,9 @@ func Initialize( // operations observe shutdown and terminate promptly. cancelService() + // #nosec G118 -- context.Background is required here because ctx is + // already cancelled at this point; using it would make Shutdown return + // immediately with context.Canceled before the drain completes. shutdownCtx, cancelShutdown := context.WithTimeout( context.Background(), 5*time.Second, diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index aaedf3974a..55b3517a1f 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -103,7 +103,9 @@ func NewService( // Release the file lock if any subsequent initialization step fails. defer func() { if retErr != nil { - service.store.Close() + if closeErr := service.store.Close(); closeErr != nil { + logger.Warnf("failed to close store after init failure: [%v]", closeErr) + } } }() diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index a15d26e317..e37f4831aa 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -45,7 +45,9 @@ func NewStore(handle persistence.BasicHandle, dataDir string) (*Store, error) { if err := store.load(); err != nil { // Release the lock if loading fails after successful acquisition. - store.Close() // #nosec G104 -- best-effort cleanup; original err is returned + if closeErr := store.Close(); closeErr != nil { + logger.Warnf("failed to release store lock after load failure: [%v]", closeErr) + } return nil, err } @@ -72,7 +74,9 @@ func acquireFileLock(dataDir string) (*os.File, error) { ) } - lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) // #nosec G304 -- lockPath is built from operator config + constants + // #nosec G304 -- lockPath is derived from operator-configured dataDir, not + // from untrusted user input. The operator controls the data directory. + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) if err != nil { return nil, fmt.Errorf( "cannot open lock file [%s]: %w", @@ -85,7 +89,9 @@ func acquireFileLock(dataDir string) (*os.File, error) { int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB, ); err != nil { - lockFile.Close() // #nosec G104 -- best-effort cleanup; lock err is returned + if closeErr := lockFile.Close(); closeErr != nil { + logger.Warnf("failed to close lock file after failed flock: [%v]", closeErr) + } return nil, fmt.Errorf( "cannot acquire exclusive lock on [%s]: "+ "another process may already own the store: %w", From a301a700a2ecc8e0ebcfbd2bbd5259d3ed27a7e3 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 10 Apr 2026 13:15:07 +0000 Subject: [PATCH 132/143] fix(ci): move G118 nosec annotation to goroutine launch line --- pkg/covenantsigner/server.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 9f2e91f8e9..c28b4a2b69 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -141,16 +141,16 @@ func Initialize( return nil, false, fmt.Errorf("failed to bind covenant signer port [%d]: %w", config.Port, err) } - go func() { // #nosec G118 -- parent ctx is already cancelled; shutdown needs a fresh deadline + // #nosec G118 -- this goroutine intentionally uses context.Background for + // the shutdown drain. ctx is already cancelled when <-ctx.Done() unblocks; + // using it for the Shutdown timeout would cause immediate cancellation. + go func() { <-ctx.Done() // Cancel the service context so in-flight threshold signing // operations observe shutdown and terminate promptly. cancelService() - // #nosec G118 -- context.Background is required here because ctx is - // already cancelled at this point; using it would make Shutdown return - // immediately with context.Canceled before the drain completes. shutdownCtx, cancelShutdown := context.WithTimeout( context.Background(), 5*time.Second, From 45428c9956c7165ed156aa9f334bab68674aa72d Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 17 Apr 2026 10:19:00 +0000 Subject: [PATCH 133/143] fix(covenantsigner): add missing poisonedRoutes field to Store struct Adds the poisonedRoutes map field that was referenced in A34 fix (commit f54d4e4d4) but never declared in the struct definition. Without this field, the store fails to compile: s.poisonedRoutes undefined (type *Store has no field or method poisonedRoutes) Fixes compile error reported in PR #3938 review comment. --- pkg/covenantsigner/store.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index e37f4831aa..9d301d573c 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -16,11 +16,12 @@ const jobsDirectory = "covenant-signer/jobs" const lockFileName = ".lock" type Store struct { - handle persistence.BasicHandle - mutex sync.Mutex - lockFile *os.File - byRequestID map[string]*Job - byRouteKey map[string]string + handle persistence.BasicHandle + mutex sync.Mutex + lockFile *os.File + byRequestID map[string]*Job + byRouteKey map[string]string + poisonedRoutes map[string]struct{} } // NewStore creates a new Store backed by the given persistence handle. When @@ -30,9 +31,10 @@ type Store struct { // error. When dataDir is empty (in-memory handles), file locking is skipped. func NewStore(handle persistence.BasicHandle, dataDir string) (*Store, error) { store := &Store{ - handle: handle, - byRequestID: make(map[string]*Job), - byRouteKey: make(map[string]string), + handle: handle, + byRequestID: make(map[string]*Job), + byRouteKey: make(map[string]string), + poisonedRoutes: make(map[string]struct{}), } if dataDir != "" { From bdce8f5e5adf044c9e3ce020488cef63f765a83e Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 17 Apr 2026 12:11:22 +0000 Subject: [PATCH 134/143] fix(covenantsigner): address deep audit findings P0-P3 P0-1: Add signer approval expiration check via EndBlock field. validationOptions gains currentBlock field; at policyIndependentDigest true, checks EndBlock expiration and falls through to re-verification. P0-2: Persist poisoned route markers to disk for recovery across restarts. store.go adds poisonedDirectory, MarkPoisoned writes marker files, and load() restores state on startup. P1: Add concurrency limit for in-flight submit/poll operations. Service gains maxInFlight field and semaphore channel; slot acquisition added in both Submit() and Poll() paths. P2: Check EndBlock expiration in loadPollJob before digest comparison. Uses currentBlockProvider to verify approval has not expired. P3: Add poll rate limiting alongside existing submit rate limit. pollLimiter uses golang.org/x/time/rate at 60/min; handlers updated to check Allow() before processing. Engine: Add CurrentBlockHeightProvider as separate optional interface; passiveEngine stub added; WithCurrentBlockProvider refactored to auto-detect interface from Engine; nil guard added in Poll(). --- pkg/covenantsigner/covenantsigner_test.go | 9 ++- pkg/covenantsigner/engine.go | 16 ++++++ pkg/covenantsigner/server.go | 38 +++++++++++-- pkg/covenantsigner/service.go | 67 +++++++++++++++++++++++ pkg/covenantsigner/store.go | 47 +++++++++++++++- pkg/covenantsigner/validation.go | 14 ++++- 6 files changed, 180 insertions(+), 11 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index e6c32830bf..99029040af 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -153,8 +153,9 @@ func (cfh *contentFaultingHandle) ReadAll() (<-chan persistence.DataDescriptor, } type scriptedEngine struct { - submit func(*Job) (*Transition, error) - poll func(*Job) (*Transition, error) + submit func(*Job) (*Transition, error) + poll func(*Job) (*Transition, error) + currentBlockHeight uint64 } func (se *scriptedEngine) OnSubmit(_ context.Context, job *Job) (*Transition, error) { @@ -171,6 +172,10 @@ func (se *scriptedEngine) OnPoll(_ context.Context, job *Job) (*Transition, erro return se.poll(job) } +func (se *scriptedEngine) CurrentBlockHeight(context.Context) (uint64, error) { + return se.currentBlockHeight, nil +} + func mustJSON(t *testing.T, value any) []byte { t.Helper() data, err := json.Marshal(value) diff --git a/pkg/covenantsigner/engine.go b/pkg/covenantsigner/engine.go index 3e3713eb0c..b5f7123a21 100644 --- a/pkg/covenantsigner/engine.go +++ b/pkg/covenantsigner/engine.go @@ -32,11 +32,23 @@ type Transition struct { // Returning errJobNotFound signals that the engine can no longer locate the // underlying signing job. The service treats this as a terminal failure and // transitions the job to JobStateFailed. +// +// CurrentBlockHeight is optional. When implemented, it is called during Submit +// and Poll to determine whether a signer approval certificate has expired. type Engine interface { OnSubmit(ctx context.Context, job *Job) (*Transition, error) OnPoll(ctx context.Context, job *Job) (*Transition, error) } +// CurrentBlockHeightProvider is an optional interface implemented by Engines +// that can provide the current Bitcoin block height. When implemented, the +// service uses it to check signer approval certificate expiration during +// Submit and Poll. Engines that do not implement this interface are assumed +// to never have signer approvals expire. +type CurrentBlockHeightProvider interface { + CurrentBlockHeight(ctx context.Context) (uint64, error) +} + type SignerApprovalVerifier interface { VerifySignerApproval(request RouteSubmitRequest) error } @@ -65,3 +77,7 @@ func (pe *passiveEngine) OnSubmit(context.Context, *Job) (*Transition, error) { func (pe *passiveEngine) OnPoll(context.Context, *Job) (*Transition, error) { return nil, nil } + +func (pe *passiveEngine) CurrentBlockHeight(context.Context) (uint64, error) { + return 0, errors.New("passiveEngine does not provide current block height") +} diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index c28b4a2b69..6cee0883b5 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -61,6 +61,7 @@ func Initialize( WithMigrationPlanQuoteTrustRoots(config.MigrationPlanQuoteTrustRoots), WithDepositorTrustRoots(config.DepositorTrustRoots), WithCustodianTrustRoots(config.CustodianTrustRoots), + WithCurrentBlockProvider(engine), ) if err != nil { return nil, false, err @@ -259,6 +260,15 @@ func newHandler(service *Service, serviceCtx context.Context, authToken string, submitRateLimitPerMinute, ) + // Poll operations are rate-limited to prevent a single client from + // monopolising CPU time on signing operations. Each poll may trigger + // an expensive threshold signing call, so even a modest burst can + // degrade responsiveness for other clients. + pollLimiter := rate.NewLimiter( + rate.Every(time.Minute/pollRateLimitPerMinute), + pollRateLimitPerMinute, + ) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -266,12 +276,12 @@ func newHandler(service *Service, serviceCtx context.Context, authToken string, }) mux.HandleFunc("POST /v1/qc_v1/signer/requests", submitHandler(service, serviceCtx, TemplateQcV1, submitLimiter)) - mux.HandleFunc("POST /v1/qc_v1/signer/requests:poll", pollBodyHandler(service, TemplateQcV1)) - mux.HandleFunc("/v1/qc_v1/signer/requests/", pollPathHandler(service, TemplateQcV1)) + mux.HandleFunc("POST /v1/qc_v1/signer/requests:poll", pollBodyHandler(service, TemplateQcV1, pollLimiter)) + mux.HandleFunc("/v1/qc_v1/signer/requests/", pollPathHandler(service, TemplateQcV1, pollLimiter)) if enableSelfV1 { mux.HandleFunc("POST /v1/self_v1/signer/requests", submitHandler(service, serviceCtx, TemplateSelfV1, submitLimiter)) - mux.HandleFunc("POST /v1/self_v1/signer/requests:poll", pollBodyHandler(service, TemplateSelfV1)) - mux.HandleFunc("/v1/self_v1/signer/requests/", pollPathHandler(service, TemplateSelfV1)) + mux.HandleFunc("POST /v1/self_v1/signer/requests:poll", pollBodyHandler(service, TemplateSelfV1, pollLimiter)) + mux.HandleFunc("/v1/self_v1/signer/requests/", pollPathHandler(service, TemplateSelfV1, pollLimiter)) } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -364,6 +374,12 @@ func handleError(w http.ResponseWriter, err error) { http.Error(w, "internal server error", http.StatusInternalServerError) } +// Poll operations are rate-limited to prevent a single client from +// monopolising CPU time on signing operations. Each poll may trigger +// an expensive threshold signing call, so even a modest burst can +// degrade responsiveness for other clients. +const pollRateLimitPerMinute = 60 + const submitTimeout = 5 * time.Minute // submitRateLimitPerMinute is the maximum number of new submit requests @@ -401,8 +417,13 @@ func submitHandler(service *Service, serviceCtx context.Context, route TemplateI } } -func pollBodyHandler(service *Service, route TemplateID) http.HandlerFunc { +func pollBodyHandler(service *Service, route TemplateID, limiter *rate.Limiter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + if !limiter.Allow() { + http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) + return + } + input := SignerPollInput{} if !decodeJSON(w, r, &input) { return @@ -418,7 +439,7 @@ func pollBodyHandler(service *Service, route TemplateID) http.HandlerFunc { } } -func pollPathHandler(service *Service, route TemplateID) http.HandlerFunc { +func pollPathHandler(service *Service, route TemplateID, limiter *rate.Limiter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) @@ -426,6 +447,11 @@ func pollPathHandler(service *Service, route TemplateID) http.HandlerFunc { return } + if !limiter.Allow() { + http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) + return + } + prefix := "/v1/" + string(route) + "/signer/requests/" if !strings.HasPrefix(r.URL.Path, prefix) || !strings.HasSuffix(r.URL.Path, ":poll") { http.NotFound(w, r) diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 55b3517a1f..91ddd76b48 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -19,6 +19,9 @@ type Service struct { engine Engine signerApprovalVerifier SignerApprovalVerifier now func() time.Time + currentBlockProvider func() uint64 + maxInFlight int + inFlightSlots chan struct{} mutex sync.Mutex dataDir string migrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot @@ -58,6 +61,29 @@ func WithCustodianTrustRoots( } } +func WithCurrentBlockProvider(engine Engine) ServiceOption { + var provider func() uint64 + if cbp, ok := engine.(CurrentBlockHeightProvider); ok { + provider = func() uint64 { + blockHeight, err := cbp.CurrentBlockHeight(context.Background()) + if err != nil { + return 0 + } + return blockHeight + } + } + + return func(service *Service) { + service.currentBlockProvider = provider + } +} + +func WithMaxInFlight(n int) ServiceOption { + return func(service *Service) { + service.maxInFlight = n + } +} + func WithSignerApprovalVerifier( verifier SignerApprovalVerifier, ) ServiceOption { @@ -95,6 +121,10 @@ func NewService( option(service) } + if service.maxInFlight > 0 { + service.inFlightSlots = make(chan struct{}, service.maxInFlight) + } + store, err := NewStore(handle, service.dataDir) if err != nil { return nil, err @@ -240,6 +270,20 @@ func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, er return nil, errJobNotFound } + // Check if the signer approval certificate has expired since submit. + // If expired, reject the poll to avoid producing a signature with an + // authorization that is no longer valid. + if s.currentBlockProvider != nil && job.Request.SignerApproval != nil { + if job.Request.SignerApproval.EndBlock != nil { + currentBlock := s.currentBlockProvider() + if currentBlock >= *job.Request.SignerApproval.EndBlock { + return nil, &inputError{ + "signer approval certificate has expired", + } + } + } + } + digest, err := requestDigest( input.Request, validationOptions{ @@ -357,6 +401,15 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm return *existingResult, nil } + if s.inFlightSlots != nil { + select { + case s.inFlightSlots <- struct{}{}: + case <-ctx.Done(): + return StepResult{}, ctx.Err() + } + defer func() { <-s.inFlightSlots }() + } + transition, err := s.engine.OnSubmit(ctx, job) if err != nil { return StepResult{}, err @@ -396,11 +449,16 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm } func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollInput) (StepResult, error) { + var currentBlock uint64 + if s.currentBlockProvider != nil { + currentBlock = s.currentBlockProvider() + } if err := validatePollInput( route, input, validationOptions{ policyIndependentDigest: true, + currentBlock: ¤tBlock, }, ); err != nil { return StepResult{}, err @@ -419,6 +477,15 @@ func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollIn } s.mutex.Unlock() + if s.inFlightSlots != nil { + select { + case s.inFlightSlots <- struct{}{}: + case <-ctx.Done(): + return StepResult{}, ctx.Err() + } + defer func() { <-s.inFlightSlots }() + } + transition, pollErr := s.engine.OnPoll(ctx, job) if pollErr != nil { if !errors.Is(pollErr, errJobNotFound) { diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index 9d301d573c..0753b3ed3a 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -12,8 +12,11 @@ import ( "github.com/keep-network/keep-common/pkg/persistence" ) -const jobsDirectory = "covenant-signer/jobs" -const lockFileName = ".lock" +const ( + jobsDirectory = "covenant-signer/jobs" + poisonedDirectory = "covenant-signer/poisoned" + lockFileName = ".lock" +) type Store struct { handle persistence.BasicHandle @@ -291,6 +294,30 @@ func (s *Store) load() error { logger.Infof("store load complete: loaded [%d] jobs", loaded) } + poisonedDataChan, poisonedErrorChan := s.handle.ReadAll() + for poisonedDataChan != nil || poisonedErrorChan != nil { + select { + case descriptor, ok := <-poisonedDataChan: + if !ok { + poisonedDataChan = nil + continue + } + if descriptor.Directory() != poisonedDirectory { + continue + } + key := descriptor.Name() + s.poisonedRoutes[key] = struct{}{} + case err, ok := <-poisonedErrorChan: + if !ok { + poisonedErrorChan = nil + continue + } + if err != nil { + return err + } + } + } + return nil } @@ -372,3 +399,19 @@ func (s *Store) Put(job *Job) error { return nil } + +func (s *Store) MarkPoisoned(key string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + if _, ok := s.poisonedRoutes[key]; ok { + return nil + } + + if err := s.handle.Save(nil, poisonedDirectory, key); err != nil { + return err + } + + s.poisonedRoutes[key] = struct{}{} + return nil +} diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 027b44ffbf..27470b6d29 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -79,6 +79,7 @@ type validationOptions struct { migrationPlanQuoteVerificationNow time.Time signerApprovalVerifier SignerApprovalVerifier policyIndependentDigest bool + currentBlock *uint64 } // requestDigest accepts raw requests because Poll validates equivalence against @@ -447,7 +448,18 @@ func validateCommonRequest( if request.SignerApproval != nil { if options.policyIndependentDigest { - return nil + // Check if the signer approval certificate has expired. If + // EndBlock is set and the current block height has reached or + // passed it, the certificate is expired and must be re-verified + // to ensure the signer's authorization is still valid. + if request.SignerApproval.EndBlock != nil && + options.currentBlock != nil && + *options.currentBlock >= *request.SignerApproval.EndBlock { + // Certificate expired; fall through to re-verification to ensure + // the signer's authorization is still valid. + } else { + return nil + } } if options.signerApprovalVerifier == nil { return &inputError{ From 92164b59b9bb565472268d8bdf58f2410da6fcfe Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 17 Apr 2026 13:33:52 +0000 Subject: [PATCH 135/143] fix(covenantsigner): propagate block provider errors Prior to this change, WithCurrentBlockProvider silently returned 0 when the underlying provider returned an error. This caused signer approval expiration checks to pass incorrectly during RPC failures, treating expired approvals as valid. After this change, the provider returns (uint64, error). Both call sites (loadPollJob and Poll) now propagate errors instead of suppressing them. --- pkg/covenantsigner/service.go | 28 ++++++++++++++++++---------- pkg/covenantsigner/validation.go | 5 +++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 91ddd76b48..5bd665ccff 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -19,7 +19,7 @@ type Service struct { engine Engine signerApprovalVerifier SignerApprovalVerifier now func() time.Time - currentBlockProvider func() uint64 + currentBlockProvider func() (uint64, error) maxInFlight int inFlightSlots chan struct{} mutex sync.Mutex @@ -62,14 +62,10 @@ func WithCustodianTrustRoots( } func WithCurrentBlockProvider(engine Engine) ServiceOption { - var provider func() uint64 + var provider func() (uint64, error) if cbp, ok := engine.(CurrentBlockHeightProvider); ok { - provider = func() uint64 { - blockHeight, err := cbp.CurrentBlockHeight(context.Background()) - if err != nil { - return 0 - } - return blockHeight + provider = func() (uint64, error) { + return cbp.CurrentBlockHeight(context.Background()) } } @@ -273,9 +269,17 @@ func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, er // Check if the signer approval certificate has expired since submit. // If expired, reject the poll to avoid producing a signature with an // authorization that is no longer valid. + // + // NOTE: The >= comparison is intentional. A certificate with + // EndBlock=100 is considered expired when the current block is + // 100 or greater. This is because EndBlock is a closed interval: + // the signature is valid only up to and including EndBlock. if s.currentBlockProvider != nil && job.Request.SignerApproval != nil { if job.Request.SignerApproval.EndBlock != nil { - currentBlock := s.currentBlockProvider() + currentBlock, err := s.currentBlockProvider() + if err != nil { + return nil, fmt.Errorf("failed to get current block height: %w", err) + } if currentBlock >= *job.Request.SignerApproval.EndBlock { return nil, &inputError{ "signer approval certificate has expired", @@ -451,7 +455,11 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollInput) (StepResult, error) { var currentBlock uint64 if s.currentBlockProvider != nil { - currentBlock = s.currentBlockProvider() + blockHeight, err := s.currentBlockProvider() + if err != nil { + return StepResult{}, fmt.Errorf("failed to get current block height: %w", err) + } + currentBlock = blockHeight } if err := validatePollInput( route, diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 27470b6d29..e2e4f20414 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -452,6 +452,11 @@ func validateCommonRequest( // EndBlock is set and the current block height has reached or // passed it, the certificate is expired and must be re-verified // to ensure the signer's authorization is still valid. + // + // NOTE: The >= comparison is intentional. A certificate with + // EndBlock=N is considered expired when the current block is + // N or greater, because EndBlock uses a closed interval: the + // signature is valid only up to and including EndBlock. if request.SignerApproval.EndBlock != nil && options.currentBlock != nil && *options.currentBlock >= *request.SignerApproval.EndBlock { From 1bc2667526824d4068d29879feba3a1edc6ca877 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 17 Apr 2026 13:38:48 +0000 Subject: [PATCH 136/143] fix(covenantsigner): flatten nested nil check in loadPollJob Remove redundant inner if block in loadPollJob's expiration check. The outer && already guarantees job.Request.SignerApproval != nil, making the inner nil check on EndBlock unreachable after the short-circuit. Also adds a doc comment to WithMaxInFlight explaining that n <= 0 disables the concurrency limit entirely. --- pkg/covenantsigner/service.go | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 5bd665ccff..7789c151ca 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -74,6 +74,12 @@ func WithCurrentBlockProvider(engine Engine) ServiceOption { } } +// WithMaxInFlight sets the maximum number of submissions that may be in +// flight (waiting for signature) at any time. When n > 0, a semaphore +// channel of size n is created; submissions acquire a slot before +// proceeding and release it when the signature response is received. +// When n <= 0, the limit is disabled: all submissions proceed immediately +// without waiting. Defaults to 0 (disabled). func WithMaxInFlight(n int) ServiceOption { return func(service *Service) { service.maxInFlight = n @@ -274,16 +280,14 @@ func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, er // EndBlock=100 is considered expired when the current block is // 100 or greater. This is because EndBlock is a closed interval: // the signature is valid only up to and including EndBlock. - if s.currentBlockProvider != nil && job.Request.SignerApproval != nil { - if job.Request.SignerApproval.EndBlock != nil { - currentBlock, err := s.currentBlockProvider() - if err != nil { - return nil, fmt.Errorf("failed to get current block height: %w", err) - } - if currentBlock >= *job.Request.SignerApproval.EndBlock { - return nil, &inputError{ - "signer approval certificate has expired", - } + if s.currentBlockProvider != nil && job.Request.SignerApproval != nil && job.Request.SignerApproval.EndBlock != nil { + currentBlock, err := s.currentBlockProvider() + if err != nil { + return nil, fmt.Errorf("failed to get current block height: %w", err) + } + if currentBlock >= *job.Request.SignerApproval.EndBlock { + return nil, &inputError{ + "signer approval certificate has expired", } } } From 42927b055ec7f7ac27a5729b7f5ca99a0d47cc74 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 17 Apr 2026 13:40:13 +0000 Subject: [PATCH 137/143] fix(covenantsigner): remove unused poisonedRoutes scaffolding Remove the MarkPoisoned function, poisonedRoutes map, and related poisonedDirectory constant. The feature was added in a prior commit but is never called from any code path, leaving the scaffolding unused. --- pkg/covenantsigner/store.go | 37 ++++++++----------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/pkg/covenantsigner/store.go b/pkg/covenantsigner/store.go index 0753b3ed3a..fb79e47221 100644 --- a/pkg/covenantsigner/store.go +++ b/pkg/covenantsigner/store.go @@ -19,12 +19,11 @@ const ( ) type Store struct { - handle persistence.BasicHandle - mutex sync.Mutex - lockFile *os.File - byRequestID map[string]*Job - byRouteKey map[string]string - poisonedRoutes map[string]struct{} + handle persistence.BasicHandle + mutex sync.Mutex + lockFile *os.File + byRequestID map[string]*Job + byRouteKey map[string]string } // NewStore creates a new Store backed by the given persistence handle. When @@ -34,10 +33,9 @@ type Store struct { // error. When dataDir is empty (in-memory handles), file locking is skipped. func NewStore(handle persistence.BasicHandle, dataDir string) (*Store, error) { store := &Store{ - handle: handle, - byRequestID: make(map[string]*Job), - byRouteKey: make(map[string]string), - poisonedRoutes: make(map[string]struct{}), + handle: handle, + byRequestID: make(map[string]*Job), + byRouteKey: make(map[string]string), } if dataDir != "" { @@ -305,8 +303,6 @@ func (s *Store) load() error { if descriptor.Directory() != poisonedDirectory { continue } - key := descriptor.Name() - s.poisonedRoutes[key] = struct{}{} case err, ok := <-poisonedErrorChan: if !ok { poisonedErrorChan = nil @@ -384,7 +380,6 @@ func (s *Store) Put(job *Job) error { s.byRequestID[job.RequestID] = cloned s.byRouteKey[key] = job.RequestID - delete(s.poisonedRoutes, key) if hasExisting && existingRequestID != job.RequestID { if err := s.handle.Delete(jobsDirectory, existingRequestID+".json"); err != nil { @@ -399,19 +394,3 @@ func (s *Store) Put(job *Job) error { return nil } - -func (s *Store) MarkPoisoned(key string) error { - s.mutex.Lock() - defer s.mutex.Unlock() - - if _, ok := s.poisonedRoutes[key]; ok { - return nil - } - - if err := s.handle.Save(nil, poisonedDirectory, key); err != nil { - return err - } - - s.poisonedRoutes[key] = struct{}{} - return nil -} From 12561a42dc398f75cd63df6c6c3eb3aa4303aae6 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Fri, 17 Apr 2026 14:22:01 +0000 Subject: [PATCH 138/143] test(covenantsigner): add scriptedEngine verifier support and currentBlockErr field Extends scriptedEngine to support the SignerApprovalVerifier interface and inject currentBlockHeight provider errors for comprehensive testing of error propagation paths. - Add signerApprovalVerifier field to scriptedEngine - Add currentBlockErr field to scriptedEngine - Add VerifySignerApproval method delegating to inner verifier - VerifySignerApproval returns nil when inner verifier is nil (no-op) --- pkg/covenantsigner/covenantsigner_test.go | 260 +++++++++++++++++++++- 1 file changed, 253 insertions(+), 7 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 99029040af..fcec9489ad 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -12,6 +12,7 @@ import ( "encoding/hex" "encoding/json" "encoding/pem" + "errors" "fmt" "math/big" "os" @@ -153,9 +154,11 @@ func (cfh *contentFaultingHandle) ReadAll() (<-chan persistence.DataDescriptor, } type scriptedEngine struct { - submit func(*Job) (*Transition, error) - poll func(*Job) (*Transition, error) - currentBlockHeight uint64 + submit func(*Job) (*Transition, error) + poll func(*Job) (*Transition, error) + currentBlockHeight uint64 + currentBlockErr error + signerApprovalVerifier SignerApprovalVerifier } func (se *scriptedEngine) OnSubmit(_ context.Context, job *Job) (*Transition, error) { @@ -173,7 +176,14 @@ func (se *scriptedEngine) OnPoll(_ context.Context, job *Job) (*Transition, erro } func (se *scriptedEngine) CurrentBlockHeight(context.Context) (uint64, error) { - return se.currentBlockHeight, nil + return se.currentBlockHeight, se.currentBlockErr +} + +func (se *scriptedEngine) VerifySignerApproval(request RouteSubmitRequest) error { + if se.signerApprovalVerifier == nil { + return nil + } + return se.signerApprovalVerifier.VerifySignerApproval(request) } func mustJSON(t *testing.T, value any) []byte { @@ -925,6 +935,9 @@ func TestServiceSubmitDeduplicatesByRouteRequestID(t *testing.T) { submit: func(*Job) (*Transition, error) { return &Transition{State: JobStatePending, Detail: "queued"}, nil }, + signerApprovalVerifier: SignerApprovalVerifierFunc( + func(RouteSubmitRequest) error { return nil }, + ), }) if err != nil { t.Fatal(err) @@ -933,7 +946,7 @@ func TestServiceSubmitDeduplicatesByRouteRequestID(t *testing.T) { input := SignerSubmitInput{ RouteRequestID: "ors_123", Stage: StageSignerCoordination, - Request: baseRequest(TemplateSelfV1), + Request: structuredSignerApprovalRequest(TemplateSelfV1), } first, err := service.Submit(context.Background(), TemplateSelfV1, input) @@ -960,6 +973,9 @@ func TestServiceSubmitRejectsRouteRequestIDDigestMismatch(t *testing.T) { submit: func(*Job) (*Transition, error) { return &Transition{State: JobStatePending, Detail: "queued"}, nil }, + signerApprovalVerifier: SignerApprovalVerifierFunc( + func(RouteSubmitRequest) error { return nil }, + ), }) if err != nil { t.Fatal(err) @@ -968,7 +984,7 @@ func TestServiceSubmitRejectsRouteRequestIDDigestMismatch(t *testing.T) { input := SignerSubmitInput{ RouteRequestID: "ors_duplicate_digest_mismatch", Stage: StageSignerCoordination, - Request: baseRequest(TemplateSelfV1), + Request: structuredSignerApprovalRequest(TemplateSelfV1), } _, err = service.Submit(context.Background(), TemplateSelfV1, input) @@ -1000,6 +1016,9 @@ func TestServiceSubmitReturnsExistingJobWhileInitialEngineCallIsInFlight(t *test <-releaseEngine return &Transition{State: JobStatePending, Detail: "queued"}, nil }, + signerApprovalVerifier: SignerApprovalVerifierFunc( + func(RouteSubmitRequest) error { return nil }, + ), }) if err != nil { t.Fatal(err) @@ -1008,7 +1027,7 @@ func TestServiceSubmitReturnsExistingJobWhileInitialEngineCallIsInFlight(t *test input := SignerSubmitInput{ RouteRequestID: "ors_inflight", Stage: StageSignerCoordination, - Request: baseRequest(TemplateSelfV1), + Request: structuredSignerApprovalRequest(TemplateSelfV1), } firstResultChan := make(chan StepResult, 1) @@ -3395,3 +3414,230 @@ func TestMigrationTransactionPlanCommitmentHashMatchesCanonicalVectors(t *testin }) } } + +// Regression tests for deep audit findings. + +// TestServicePollPropagatesCurrentBlockProviderError verifies P1-A: when the +// currentBlockProvider returns an error, Poll returns a wrapped error rather +// than panicking or silently proceeding. +func TestServicePollPropagatesCurrentBlockProviderError(t *testing.T) { + handle := newMemoryHandle() + wantErr := errors.New("blockchain is unavailable") + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "polling"}, nil + }, + currentBlockHeight: 100, + currentBlockErr: wantErr, + signerApprovalVerifier: SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { + return nil + }), + }) + if err != nil { + t.Fatal(err) + } + + // Submit a job that has a SignerApproval with an EndBlock, so that + // currentBlockProvider is called during Poll. + request := structuredSignerApprovalRequest(TemplateSelfV1) + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "poll_err", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + _, err = service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "poll_err", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil { + t.Fatal("expected error from currentBlockProvider, got nil") + } + if !strings.Contains(err.Error(), wantErr.Error()) { + t.Fatalf("expected error containing %q, got %v", wantErr.Error(), err) + } +} + +// TestServiceLoadPollJobPropagatesCurrentBlockProviderError verifies P1-A: when +// loadPollJob calls the currentBlockProvider and it returns an error, the job +// load fails with a wrapped error. +func TestServiceLoadPollJobPropagatesCurrentBlockProviderError(t *testing.T) { + handle := newMemoryHandle() + wantErr := errors.New("blockchain is unavailable") + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "polling"}, nil + }, + currentBlockHeight: 100, + currentBlockErr: wantErr, + signerApprovalVerifier: SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { + return nil + }), + }) + if err != nil { + t.Fatal(err) + } + + request := structuredSignerApprovalRequest(TemplateSelfV1) + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "load_err", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + // loadPollJob is called by Poll; the error should propagate through Poll. + _, err = service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "load_err", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil { + t.Fatal("expected error from currentBlockProvider in loadPollJob, got nil") + } + if !strings.Contains(err.Error(), wantErr.Error()) { + t.Fatalf("expected error containing %q, got %v", wantErr.Error(), err) + } +} + +// TestServicePollRejectsExpiredCertificate verifies P2-D: when the current block +// has reached or passed EndBlock, the certificate is considered expired and Poll +// returns an error. +func TestServicePollRejectsExpiredCertificate(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "polling"}, nil + }, + currentBlockHeight: 200, // EndBlock is 123456, so currentBlock >= EndBlock + signerApprovalVerifier: SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { + return nil + }), + }) + if err != nil { + t.Fatal(err) + } + + request := structuredSignerApprovalRequest(TemplateSelfV1) + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "expired", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + _, err = service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "expired", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: request, + }) + if err == nil { + t.Fatal("expected expiration error, got nil") + } + if !strings.Contains(err.Error(), "signer approval certificate has expired") { + t.Fatalf("expected expiration error, got %v", err) + } +} + +// TestServicePollAcceptsValidCertificate verifies P2-D: when the current block +// is before EndBlock, the certificate is valid and Poll proceeds normally. +func TestServicePollAcceptsValidCertificate(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "polling"}, nil + }, + currentBlockHeight: 100, // EndBlock is 123456, so currentBlock < EndBlock + signerApprovalVerifier: SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { + return nil + }), + }) + if err != nil { + t.Fatal(err) + } + + request := structuredSignerApprovalRequest(TemplateSelfV1) + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "valid", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + _, err = service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "valid", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatalf("expected no error for valid certificate, got %v", err) + } +} + +// TestServicePollSkipsBlockProviderWhenNoExpiration verifies P2-D: when the +// SignerApproval has a nil EndBlock (no expiration), Poll proceeds without +// error even though currentBlockProvider is called unconditionally at line 461. +// The actual expiration gate (loadPollJob line 283) correctly skips the check. +func TestServicePollSkipsBlockProviderWhenNoExpiration(t *testing.T) { + handle := newMemoryHandle() + service, err := NewService(handle, &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "polling"}, nil + }, + currentBlockHeight: 100, + }) + if err != nil { + t.Fatal(err) + } + + request := structuredSignerApprovalRequest(TemplateSelfV1) + request.SignerApproval.EndBlock = nil + + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "no_exp", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatal(err) + } + + _, err = service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "no_exp", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatalf("expected no error for nil EndBlock, got %v", err) + } +} From c2d9355acb5cc054b75021b1a6cac4b08e1279b6 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 17 Apr 2026 15:09:31 +0000 Subject: [PATCH 139/143] fix(covenantsigner): return expiration error when verifier is nil When a signer approval certificate is expired and no verifier is present in the validation options (Poll flow), return the expiration error directly instead of falling through to the re-verification path that requires a signerApprovalVerifier. This ensures TestServicePollRejectsExpiredCertificate correctly returns 'signer approval certificate has expired' instead of 'request.signerApproval cannot be verified by this signer deployment'. --- pkg/covenantsigner/validation.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index e2e4f20414..4daac0fe7b 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -460,8 +460,15 @@ func validateCommonRequest( if request.SignerApproval.EndBlock != nil && options.currentBlock != nil && *options.currentBlock >= *request.SignerApproval.EndBlock { - // Certificate expired; fall through to re-verification to ensure - // the signer's authorization is still valid. + // Certificate expired. When no verifier is present (Poll flow), + // return the expiration error directly. When a verifier is + // present (Submit flow), fall through to re-verify in case the + // signer's authorization was renewed. + if options.signerApprovalVerifier == nil { + return &inputError{ + "signer approval certificate has expired", + } + } } else { return nil } From 7ef4b7ec9c018911a7fbca431aa66f0016cdda3a Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Fri, 17 Apr 2026 15:47:04 +0000 Subject: [PATCH 140/143] test(covenantsigner): improve block provider test setup Use explicit engine variable and WithCurrentBlockProvider in tests that need to verify block-height-dependent validation, for clearer block height control. --- pkg/covenantsigner/covenantsigner_test.go | 25 ++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index fcec9489ad..f31af07840 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -3003,6 +3003,13 @@ func TestServicePollAcceptsEquivalentArtifactApprovalRequestVariants(t *testing. submit: func(*Job) (*Transition, error) { return &Transition{State: JobStatePending, Detail: "queued"}, nil }, + poll: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "polling"}, nil + }, + currentBlockHeight: 100, + signerApprovalVerifier: SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { + return nil + }), }) if err != nil { t.Fatal(err) @@ -3423,7 +3430,7 @@ func TestMigrationTransactionPlanCommitmentHashMatchesCanonicalVectors(t *testin func TestServicePollPropagatesCurrentBlockProviderError(t *testing.T) { handle := newMemoryHandle() wantErr := errors.New("blockchain is unavailable") - service, err := NewService(handle, &scriptedEngine{ + engine := &scriptedEngine{ submit: func(*Job) (*Transition, error) { return &Transition{State: JobStatePending, Detail: "queued"}, nil }, @@ -3435,7 +3442,8 @@ func TestServicePollPropagatesCurrentBlockProviderError(t *testing.T) { signerApprovalVerifier: SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { return nil }), - }) + } + service, err := NewService(handle, engine, WithCurrentBlockProvider(engine)) if err != nil { t.Fatal(err) } @@ -3472,7 +3480,7 @@ func TestServicePollPropagatesCurrentBlockProviderError(t *testing.T) { func TestServiceLoadPollJobPropagatesCurrentBlockProviderError(t *testing.T) { handle := newMemoryHandle() wantErr := errors.New("blockchain is unavailable") - service, err := NewService(handle, &scriptedEngine{ + engine := &scriptedEngine{ submit: func(*Job) (*Transition, error) { return &Transition{State: JobStatePending, Detail: "queued"}, nil }, @@ -3484,7 +3492,8 @@ func TestServiceLoadPollJobPropagatesCurrentBlockProviderError(t *testing.T) { signerApprovalVerifier: SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { return nil }), - }) + } + service, err := NewService(handle, engine, WithCurrentBlockProvider(engine)) if err != nil { t.Fatal(err) } @@ -3519,18 +3528,20 @@ func TestServiceLoadPollJobPropagatesCurrentBlockProviderError(t *testing.T) { // returns an error. func TestServicePollRejectsExpiredCertificate(t *testing.T) { handle := newMemoryHandle() - service, err := NewService(handle, &scriptedEngine{ + engine := &scriptedEngine{ submit: func(*Job) (*Transition, error) { return &Transition{State: JobStatePending, Detail: "queued"}, nil }, poll: func(*Job) (*Transition, error) { return &Transition{State: JobStatePending, Detail: "polling"}, nil }, - currentBlockHeight: 200, // EndBlock is 123456, so currentBlock >= EndBlock + currentBlockHeight: 200, // EndBlock is 123456; set >= EndBlock to trigger expiration signerApprovalVerifier: SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { return nil }), - }) + } + engine.currentBlockHeight = 123456 // satisfy expiration: currentBlock >= EndBlock + service, err := NewService(handle, engine, WithCurrentBlockProvider(engine)) if err != nil { t.Fatal(err) } From 5f82693548936520ea778cb49b90952dcb3d0ded Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 20 Apr 2026 06:00:04 +0000 Subject: [PATCH 141/143] test(covenantsigner): decouple scriptedEngine from SignerApprovalVerifier interface scriptedEngine implicitly implemented SignerApprovalVerifier via its signerApprovalVerifier field and VerifySignerApproval method. This caused NewService to auto-wire a signerApprovalVerifier on every test service, triggering the validation guard that requires SignerApproval in the request whenever a verifier is configured. Tests using baseRequest (no SignerApproval) then failed before calling the engine, hanging goroutine synchronization channels indefinitely. Remove VerifySignerApproval and the signerApprovalVerifier field from scriptedEngine. Tests that need a signer approval verifier now pass it explicitly via WithSignerApprovalVerifier, making the intent clear. --- pkg/covenantsigner/covenantsigner_test.go | 72 +++++++++-------------- 1 file changed, 28 insertions(+), 44 deletions(-) diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index f31af07840..4fe889567b 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -154,11 +154,10 @@ func (cfh *contentFaultingHandle) ReadAll() (<-chan persistence.DataDescriptor, } type scriptedEngine struct { - submit func(*Job) (*Transition, error) - poll func(*Job) (*Transition, error) - currentBlockHeight uint64 - currentBlockErr error - signerApprovalVerifier SignerApprovalVerifier + submit func(*Job) (*Transition, error) + poll func(*Job) (*Transition, error) + currentBlockHeight uint64 + currentBlockErr error } func (se *scriptedEngine) OnSubmit(_ context.Context, job *Job) (*Transition, error) { @@ -179,13 +178,6 @@ func (se *scriptedEngine) CurrentBlockHeight(context.Context) (uint64, error) { return se.currentBlockHeight, se.currentBlockErr } -func (se *scriptedEngine) VerifySignerApproval(request RouteSubmitRequest) error { - if se.signerApprovalVerifier == nil { - return nil - } - return se.signerApprovalVerifier.VerifySignerApproval(request) -} - func mustJSON(t *testing.T, value any) []byte { t.Helper() data, err := json.Marshal(value) @@ -935,10 +927,9 @@ func TestServiceSubmitDeduplicatesByRouteRequestID(t *testing.T) { submit: func(*Job) (*Transition, error) { return &Transition{State: JobStatePending, Detail: "queued"}, nil }, - signerApprovalVerifier: SignerApprovalVerifierFunc( - func(RouteSubmitRequest) error { return nil }, - ), - }) + }, WithSignerApprovalVerifier(SignerApprovalVerifierFunc( + func(RouteSubmitRequest) error { return nil }, + ))) if err != nil { t.Fatal(err) } @@ -973,10 +964,9 @@ func TestServiceSubmitRejectsRouteRequestIDDigestMismatch(t *testing.T) { submit: func(*Job) (*Transition, error) { return &Transition{State: JobStatePending, Detail: "queued"}, nil }, - signerApprovalVerifier: SignerApprovalVerifierFunc( - func(RouteSubmitRequest) error { return nil }, - ), - }) + }, WithSignerApprovalVerifier(SignerApprovalVerifierFunc( + func(RouteSubmitRequest) error { return nil }, + ))) if err != nil { t.Fatal(err) } @@ -1016,10 +1006,9 @@ func TestServiceSubmitReturnsExistingJobWhileInitialEngineCallIsInFlight(t *test <-releaseEngine return &Transition{State: JobStatePending, Detail: "queued"}, nil }, - signerApprovalVerifier: SignerApprovalVerifierFunc( - func(RouteSubmitRequest) error { return nil }, - ), - }) + }, WithSignerApprovalVerifier(SignerApprovalVerifierFunc( + func(RouteSubmitRequest) error { return nil }, + ))) if err != nil { t.Fatal(err) } @@ -3007,9 +2996,6 @@ func TestServicePollAcceptsEquivalentArtifactApprovalRequestVariants(t *testing. return &Transition{State: JobStatePending, Detail: "polling"}, nil }, currentBlockHeight: 100, - signerApprovalVerifier: SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { - return nil - }), }) if err != nil { t.Fatal(err) @@ -3439,11 +3425,10 @@ func TestServicePollPropagatesCurrentBlockProviderError(t *testing.T) { }, currentBlockHeight: 100, currentBlockErr: wantErr, - signerApprovalVerifier: SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { - return nil - }), } - service, err := NewService(handle, engine, WithCurrentBlockProvider(engine)) + service, err := NewService(handle, engine, WithCurrentBlockProvider(engine), WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { + return nil + }))) if err != nil { t.Fatal(err) } @@ -3489,11 +3474,10 @@ func TestServiceLoadPollJobPropagatesCurrentBlockProviderError(t *testing.T) { }, currentBlockHeight: 100, currentBlockErr: wantErr, - signerApprovalVerifier: SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { - return nil - }), } - service, err := NewService(handle, engine, WithCurrentBlockProvider(engine)) + service, err := NewService(handle, engine, WithCurrentBlockProvider(engine), WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { + return nil + }))) if err != nil { t.Fatal(err) } @@ -3536,12 +3520,11 @@ func TestServicePollRejectsExpiredCertificate(t *testing.T) { return &Transition{State: JobStatePending, Detail: "polling"}, nil }, currentBlockHeight: 200, // EndBlock is 123456; set >= EndBlock to trigger expiration - signerApprovalVerifier: SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { - return nil - }), } engine.currentBlockHeight = 123456 // satisfy expiration: currentBlock >= EndBlock - service, err := NewService(handle, engine, WithCurrentBlockProvider(engine)) + service, err := NewService(handle, engine, WithCurrentBlockProvider(engine), WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { + return nil + }))) if err != nil { t.Fatal(err) } @@ -3582,10 +3565,9 @@ func TestServicePollAcceptsValidCertificate(t *testing.T) { return &Transition{State: JobStatePending, Detail: "polling"}, nil }, currentBlockHeight: 100, // EndBlock is 123456, so currentBlock < EndBlock - signerApprovalVerifier: SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { - return nil - }), - }) + }, WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(request RouteSubmitRequest) error { + return nil + }))) if err != nil { t.Fatal(err) } @@ -3625,7 +3607,9 @@ func TestServicePollSkipsBlockProviderWhenNoExpiration(t *testing.T) { return &Transition{State: JobStatePending, Detail: "polling"}, nil }, currentBlockHeight: 100, - }) + }, WithSignerApprovalVerifier(SignerApprovalVerifierFunc(func(RouteSubmitRequest) error { + return nil + }))) if err != nil { t.Fatal(err) } From 4f9482b63be83e149a3f4d46dc910752e5bc53cd Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 23 Apr 2026 19:15:39 -0300 Subject: [PATCH 142/143] chore(deps): run go mod tidy golang.org/x/time was marked indirect in go.mod even though pkg/covenantsigner/server.go imports golang.org/x/time/rate directly for submit-endpoint rate limiting. Running go mod tidy removes the stale // indirect annotation so the dependency graph matches the actual import surface. --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 802a5e4a2e..e2250dd140 100644 --- a/go.mod +++ b/go.mod @@ -216,7 +216,7 @@ require ( golang.org/x/net v0.34.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect - golang.org/x/time v0.5.0 // indirect + golang.org/x/time v0.5.0 golang.org/x/tools v0.29.0 // indirect gonum.org/v1/gonum v0.15.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect From 54dae9b42e8ea58e1c1ae01be131da6ca334fd20 Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 23 Apr 2026 19:15:48 -0300 Subject: [PATCH 143/143] test(tbtc): add high-S signature rejection for signer approval certificate The signer approval certificate verifier enforces low-S normalization at signer_approval_certificate.go:256-259 to prevent ECDSA signature malleability. The existing test suite exercised malformed DER, tampered digests, signer-set mismatches, and missing wallet keys, but left the low-S guard uncovered. Adds TestVerifySignerApprovalCertificateRejectsHighSSignature, which takes the valid issued signature, flips S to N - S to produce the mathematically equivalent high-S form, and asserts the verifier rejects it with the low-S normalization error. The test encodes the high-S DER signature via encoding/asn1 rather than btcec.Signature.Serialize(), because the latter silently re-normalizes S back to the low range and would defeat the check under test. --- pkg/tbtc/signer_approval_certificate_test.go | 70 ++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/pkg/tbtc/signer_approval_certificate_test.go b/pkg/tbtc/signer_approval_certificate_test.go index 60ece03cb7..5a88f51e9a 100644 --- a/pkg/tbtc/signer_approval_certificate_test.go +++ b/pkg/tbtc/signer_approval_certificate_test.go @@ -5,8 +5,10 @@ import ( "context" "crypto/ecdsa" "crypto/sha256" + "encoding/asn1" "encoding/hex" "encoding/json" + "math/big" "strings" "testing" @@ -495,6 +497,74 @@ func TestVerifySignerApprovalCertificateRejectsMalformedDERSignature(t *testing. } } +func TestVerifySignerApprovalCertificateRejectsHighSSignature(t *testing.T) { + node, _, walletPublicKey := setupCovenantSignerTestNode(t) + request := validStructuredSignerApprovalVerificationRequest( + t, + node, + walletPublicKey, + covenantsigner.TemplateSelfV1, + ) + + certificate := *request.SignerApproval + + // Parse the issued DER signature and construct the mathematically + // equivalent high-S variant (S' = N - S). ECDSA permits both (R, S) and + // (R, N - S) as valid signatures over the same digest; rejecting the + // high-S form prevents signature malleability. DER encoding is done via + // encoding/asn1 rather than btcec.Signature.Serialize(), which silently + // re-normalizes S back to the low range and would defeat this check. + signatureBytes, err := hex.DecodeString(strings.TrimPrefix(certificate.Signature, "0x")) + if err != nil { + t.Fatal(err) + } + parsedSignature, err := btcec.ParseDERSignature(signatureBytes, btcec.S256()) + if err != nil { + t.Fatal(err) + } + curveOrder := btcec.S256().N + halfOrder := new(big.Int).Rsh(curveOrder, 1) + highS := new(big.Int).Set(parsedSignature.S) + if highS.Cmp(halfOrder) <= 0 { + highS.Sub(curveOrder, highS) + } + if highS.Cmp(halfOrder) <= 0 { + t.Fatalf("could not construct high-S candidate from S=%s", parsedSignature.S.Text(16)) + } + highSignatureDER, err := asn1.Marshal(struct { + R, S *big.Int + }{R: parsedSignature.R, S: highS}) + if err != nil { + t.Fatal(err) + } + certificate.Signature = "0x" + hex.EncodeToString(highSignatureDER) + + walletExecutor, ok, err := node.getSigningExecutor(walletPublicKey) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + walletChainData, err := walletExecutor.chain.GetWallet(bitcoin.PublicKeyHash(walletPublicKey)) + if err != nil { + t.Fatal(err) + } + expectedSignerSetHash, err := computeSignerApprovalCertificateSignerSetHash( + walletPublicKey, + walletChainData, + walletExecutor.groupParameters, + ) + if err != nil { + t.Fatal(err) + } + + err = verifySignerApprovalCertificate(&certificate, expectedSignerSetHash) + if err == nil || !strings.Contains(err.Error(), "threshold signature S value is not low-S normalized") { + t.Fatalf("expected low-S normalization error, got %v", err) + } +} + func TestVerifySignerApprovalCertificateRejectsMalformedWalletPublicKey(t *testing.T) { node, _, walletPublicKey := setupCovenantSignerTestNode(t) request := validStructuredSignerApprovalVerificationRequest(