From eb147683839279b64a4dedb844a5241b1c6ef766 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 07:37:12 -0500 Subject: [PATCH 01/87] 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 d0998da46dade8c353cf7f9dc4d32a18207bba45 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 07:52:47 -0500 Subject: [PATCH 02/87] 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 a5e084d561c218247071255a669fc8199436e80b Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 09:23:02 -0500 Subject: [PATCH 03/87] 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 12c11e413bc1f95450d4a9339a5be984d9f392e6 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 09:33:07 -0500 Subject: [PATCH 04/87] 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 d073f88a9427f17f1d302b62c0d6e0a1c8d2de18 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 09:39:08 -0500 Subject: [PATCH 05/87] 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 a5e2864266eab5a7ca605304d4d178e068cd81a6 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 09:47:13 -0500 Subject: [PATCH 06/87] 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 2db62266f5fd16f7114a10f02f73ee8801d105fd Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 10:39:31 -0500 Subject: [PATCH 07/87] 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 8cc87cdbff218f2dd6541ca9b21decb07f5db496 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 10:52:45 -0500 Subject: [PATCH 08/87] 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 38987f9827bd85cf67504f18ec74649b9b3440f9 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 11:07:32 -0500 Subject: [PATCH 09/87] 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 4244a8f901..bf7d72dcc6 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 2ea4e58243bfb276f2deb480e06b46316a4db5fc Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 11:10:22 -0500 Subject: [PATCH 10/87] 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 9e15a5da210fc49d4dcdfa88d80062af3b01c85e Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 11:20:48 -0500 Subject: [PATCH 11/87] 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 bf7d72dcc6..6bbef7db63 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 0d64dccd240d29e40745007b966c2d9d30e0539f Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 11:37:40 -0500 Subject: [PATCH 12/87] 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 d4e83a3bfb0ea5d9a0621c916ce0f599a5256aab Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 17:05:45 -0500 Subject: [PATCH 13/87] 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 d7388613bffe20b6676be03eda2ca34edf2b7f84 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 18:46:57 -0500 Subject: [PATCH 14/87] 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 2d1366adac54d4ccf7d15bede19a64ed49adad11 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 9 Mar 2026 19:15:15 -0500 Subject: [PATCH 15/87] 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 21b6381b3bc2ce681437a2ab48b2f73e46fab316 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 10 Mar 2026 13:06:06 -0500 Subject: [PATCH 16/87] 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 bd3f2037479e5dfd6ad0912504dc5ed41ba5e7cd Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 10 Mar 2026 13:46:16 -0500 Subject: [PATCH 17/87] 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 a246ad093fa9951db79e79d5979b06e1c36e86e2 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 11:37:57 -0500 Subject: [PATCH 18/87] 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 57eafa679737303bd704e29bd56606b278727339 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 12:00:58 -0500 Subject: [PATCH 19/87] 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 866dc0a30e7cca7655eb33fc5dd374254aa40255 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 12:59:53 -0500 Subject: [PATCH 20/87] 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 7470fcaaf3c81aeae920c95bb3cc5c58fe4e178a Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 12:21:13 -0500 Subject: [PATCH 21/87] 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 04971dd495ba9f5fa6e32a6fc79b4d70f9dba1d3 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 13:29:03 -0500 Subject: [PATCH 22/87] 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 29f0756a3a9601c5471b4d6e172be182ef3ff38b Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 13:51:10 -0500 Subject: [PATCH 23/87] 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 d65c8a400752db3cf72d58a073261620252db17f Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 14:35:11 -0500 Subject: [PATCH 24/87] 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 919554310656d1ccfae7ecbd8cbec6804646d80f Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 12 Mar 2026 19:52:16 -0500 Subject: [PATCH 25/87] 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 5994e52f65714c5f7317d84ee97cb2f65254e1e3 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 09:19:09 -0500 Subject: [PATCH 26/87] 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 0329fd8c1f0fff5bf18f4825bd372b2d470c9527 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 09:58:30 -0500 Subject: [PATCH 27/87] 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 13adc3008a6c829f90f0a8632c9e946ae4c5dac5 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 10:11:10 -0500 Subject: [PATCH 28/87] 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 4568657ac9354b662173951c7a3181e90b2a9f10 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 11:04:10 -0500 Subject: [PATCH 29/87] 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 90e59e60c73e809a98c4ef734fa768ab0568d945 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 11:18:46 -0500 Subject: [PATCH 30/87] 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 da023c41bda5eb784b7f8746797d20a7f837f200 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 11:32:51 -0500 Subject: [PATCH 31/87] 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 a6054d26f28678df706d64cb1f8ef8a9d5ca788f Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 11:36:55 -0500 Subject: [PATCH 32/87] 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 2186c3dbfd82c92c8f3f1ba5f7703f9da14008ec Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 11:51:20 -0500 Subject: [PATCH 33/87] 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 38e6773f5f23fb32b04b49e2d814fd9ffb1b4878 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 12:00:22 -0500 Subject: [PATCH 34/87] 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 909c82490b662ed734b67d6307ad6f71641be85b Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 12:58:05 -0500 Subject: [PATCH 35/87] 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 d367c8e4d7ea2ff2c207aa6bfd79f82388282ceb Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 13:12:04 -0500 Subject: [PATCH 36/87] 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 7a2f2996063c855d7c3f190e065cf0adb94bded8 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 15:03:31 -0500 Subject: [PATCH 37/87] 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 351bee9238edf200a90821ed947360817182e3ba Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 15:49:39 -0500 Subject: [PATCH 38/87] 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 dac9bc3a0a6509333fe0e20798569d050db6377a Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 15:53:14 -0500 Subject: [PATCH 39/87] 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 cecf08f4efade9f3e4cad22cfc0b96b49c350488 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 13 Mar 2026 22:17:33 -0500 Subject: [PATCH 40/87] 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 7c34a9cf72979b58d42fdd367e5c7e19568b94b9 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:47:54 +0000 Subject: [PATCH 41/87] 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 c3bc5006803b2590a9bec82838bf55279574ccb5 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:48:07 +0000 Subject: [PATCH 42/87] 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 7f84b94322a99c6cab2a00617bb094456bb58dc0 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:48:26 +0000 Subject: [PATCH 43/87] 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 acecf4cc095fedcf96fee01a4ad1bb076094d9c1 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:48:44 +0000 Subject: [PATCH 44/87] 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 40d434b4cf451409ab1e1c4435ef242e68b3b8eb Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:48:57 +0000 Subject: [PATCH 45/87] 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 14e227255ac95cf8dbd62ade9c9bb3c573f5ae22 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:49:10 +0000 Subject: [PATCH 46/87] 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 1d3928d630ddf780b5573c7597120da8e84f0bcd Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:49:18 +0000 Subject: [PATCH 47/87] 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 590e4d9456f29091efbdcc4e4449b3393b197eac Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:49:29 +0000 Subject: [PATCH 48/87] 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 9edc9ce3611e19b5bf0a12bae4dda0dc848376e3 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:49:39 +0000 Subject: [PATCH 49/87] 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 c2bd723413e0cf486245d2e05ca6636801339ee4 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:49:54 +0000 Subject: [PATCH 50/87] 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 27efef729c1b6070e322737f090e5a6d0d76521c Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:50:17 +0000 Subject: [PATCH 51/87] 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 ff09f1cda9a92c317a25b4cd06b1a65e437cb3ca Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:50:31 +0000 Subject: [PATCH 52/87] 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 aed5b13464df47c774a9b28f90b872581fbc3163 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:52:25 +0000 Subject: [PATCH 53/87] 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 aec52a6416473c3f9b0aa5141b9cf506f6d985bd Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 17:52:56 +0000 Subject: [PATCH 54/87] 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 f43964540dd2652f836cd3686f7d50bececadca2 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 18:09:13 +0000 Subject: [PATCH 55/87] 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 6a3a831ba597d45c59b4e3cc59d0b9cdd89cca47 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 18:30:35 +0000 Subject: [PATCH 56/87] chore: open follow-up branch for feat/psbt-covenant-final-project-pr From 815a09ba3a6818489d1a83a6bf5a9ed2e8501d65 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:00:27 +0000 Subject: [PATCH 57/87] 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 a0dbc11f3d..f7c5fc2a79 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 64f96b58a8872f691bd1ac222541fca2f801f661 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:01:32 +0000 Subject: [PATCH 58/87] 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 c7247053f01a5f5e45ab5a946dae3b493a73d0fe Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:02:37 +0000 Subject: [PATCH 59/87] 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 24854b579934e7663b1130386f125433111e79bb Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:04:13 +0000 Subject: [PATCH 60/87] 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 7cd92d67f550507ce411fafd7fb592690fa647a6 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:06:59 +0000 Subject: [PATCH 61/87] 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 18b46adba05494826cf20d2c82e9198e0660b1ab Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:07:14 +0000 Subject: [PATCH 62/87] 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 f7c5fc2a79..4049617746 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 458e09ea0ee27bf01ce068b9f4d364a5be4eb574 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:07:50 +0000 Subject: [PATCH 63/87] 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 def205f5bb5c6717a2eb2eb888c0d6f0788a5697 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:09:27 +0000 Subject: [PATCH 64/87] 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 2c8cadb3258838e818f40d29a36e8468410c69ef Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:13:50 +0000 Subject: [PATCH 65/87] 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 7970929aba0e5aa7736215200d25eb5deb7e9772 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Mon, 16 Mar 2026 20:14:58 +0000 Subject: [PATCH 66/87] 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 178b578b340224ac556551b0f0566194e90038d4 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 08:14:10 +0000 Subject: [PATCH 67/87] 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 4049617746..edb7791ae2 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 5863464cc1a11dd4c5b4846df41de3f1d7cbd7cb Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:29:58 +0000 Subject: [PATCH 68/87] 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 bd8f8040ece40e994cdd9c9fb20bf9102c55a451 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:31:58 +0000 Subject: [PATCH 69/87] 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 03ada74fdf702dad391dd6460c09a4d0e936c098 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:37:14 +0000 Subject: [PATCH 70/87] 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 1b6a3c889da5a412732b3d087d01253568e943ff Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:39:27 +0000 Subject: [PATCH 71/87] 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 0fb8ac5c86b08aea03e1da38e44841c014d36d3f Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:44:40 +0000 Subject: [PATCH 72/87] 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 ab8c2518ed84b6ba54dc8704982a544c061c4bff Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:47:06 +0000 Subject: [PATCH 73/87] 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 b23fc5aed2ad323bbb897cde52672dc45283edd3 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:48:32 +0000 Subject: [PATCH 74/87] 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 a318999b25e77fb845a1678a9896de9edea8e341 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 11:50:33 +0000 Subject: [PATCH 75/87] 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 f1883debe5b09a3485285f7ba9babc632601161b Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 12:24:58 +0000 Subject: [PATCH 76/87] 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 d40deee13fd7edc766f6a33826da7c8e6ee8097f Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 12:25:02 +0000 Subject: [PATCH 77/87] 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 b159f9fbb769a5203467c4e03bdc20901f74c59b Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 13:29:49 +0000 Subject: [PATCH 78/87] 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 d3e518472aaef8d57581b90a7c5c0bde103c179a Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Tue, 17 Mar 2026 13:29:52 +0000 Subject: [PATCH 79/87] 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 5941949f1381cdeae997a577b07051820e296f73 Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 01:04:11 -0300 Subject: [PATCH 80/87] 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 86cfc66053885234e2623969dd9bb03468cb84c7 Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 01:04:21 -0300 Subject: [PATCH 81/87] 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 de3e74565dc69e100f8bb61102cdb4e7307d4b88 Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 08:54:50 -0300 Subject: [PATCH 82/87] 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 3606c2995f8c898d666dfc28480b7de26c4b191b Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 08:55:00 -0300 Subject: [PATCH 83/87] 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 6d12e8fab0242f96c54de25597a53626b809ac66 Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 08:55:10 -0300 Subject: [PATCH 84/87] 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 7562a657b284adce2b687d2646419a3921fc258a Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 08:55:16 -0300 Subject: [PATCH 85/87] 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 054ef083f82892aecaf62c3ed3eefdbd9795a4ec Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 08:55:18 -0300 Subject: [PATCH 86/87] 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 99b79f516e85465c07b58c525f90922dcb9a3141 Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 19 Mar 2026 08:55:20 -0300 Subject: [PATCH 87/87] 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.