diff --git a/.golangci.yml b/.golangci.yml index e920f26e1..e2b8dc5f9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,6 +3,17 @@ version: "2" run: timeout: 5m +linters: + exclusions: + rules: + # h2c.NewHandler is deprecated in golang.org/x/net v0.55.0 but is still + # required for unencrypted HTTP/2 serving; migrating to http.Server's + # Protocols field is tracked as a separate follow-up. + - path: interface/grpc/service\.go + linters: + - staticcheck + text: SA1019 + formatters: enable: - gofmt diff --git a/Makefile b/Makefile index bbb310cd8..2685167cd 100755 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ .PHONY: \ - build build-all build-cli build-wallet clean cov \ + build build-all build-cli build-signer build-wallet clean cov \ docker-run docker-run-light docker-stop droppg droppgtest \ help integrationtest lint migrate pg pgmigrate pgsqlc pgtest \ pprof proto proto-lint psql \ redis-down redis-test-down redis-test-up redis-up \ - run run-light run-signer run-simulation run-wallet run-wallet-nosigner \ + run run-light run-signer run-simulation run-wallet \ sqlc test test-pkg vet define setup_env @@ -42,6 +42,11 @@ build-wallet: @echo "Building arkd wallet binary..." @bash ./scripts/build-arkd-wallet +## build-signer: build arkd signer for your platforms +build-signer: + @echo "Building arkd signer binary..." + @bash ./scripts/build-arkd-signer + ## clean: run go clean clean: @echo "Cleaning..." @@ -173,12 +178,11 @@ run-light: clean $(call setup_env, envs/arkd.light.env) @go run ./cmd/arkd -## run-signer: run arkd wallet as signer without a wallet +## run-signer: run arkd-signer in dev mode run-signer: - @echo "Running signer in dev mode" - @docker compose -f docker-compose.regtest.yml up -d pg nbxplorer + @echo "Running arkd-signer in dev mode" $(call setup_env, envs/signer.dev.env) - @go run ./cmd/arkd-wallet + @go run ./cmd/arkd-signer ## run-simulation: run the multi-VTXO batch settlement test ## Usage: make run-simulation [CLIENTS=n] @@ -205,7 +209,7 @@ run-simulation: ## run-vtxo-chain-simulation: run the vtxo chain smoke test ## Usage: make run-vtxo-chain-simulation [CHAIN_LENGTH=n] # Examples: -# make run-vtxo-chain-simulation # Default: 50 chained offchain txs +# make run-vtxo-chain-simulation # Default: 50 chained offchain txs # make run-vtxo-chain-simulation CHAIN_LENGTH=100 # 100 chained offchain txs run-vtxo-chain-simulation: @echo "Stopping any existing Docker environment..." @@ -221,18 +225,11 @@ run-vtxo-chain-simulation: ' @echo "Test completed." -## run-wallet: run arkd wallet based on nbxplorer in dev mode on regtest with a pre-loaded signer private key +## run-wallet: run arkd wallet based on nbxplorer in dev mode on regtest run-wallet: - @echo "Running arkd wallet in dev mode with NBXplorer on regtest with pre-loaded signer private key..." - @docker compose -f docker-compose.regtest.yml up -d pg nbxplorer - $(call setup_env, envs/arkd-wallet.regtest.env) - @go run ./cmd/arkd-wallet - -## run-wallet-nosigner: run arkd wallet based on nbxplorer in dev mode on regtest without a pre-loaded signer private key -run-wallet-nosigner: @echo "Running arkd wallet in dev mode with NBXplorer on regtest..." @docker compose -f docker-compose.regtest.yml up -d pg nbxplorer - $(call setup_env, envs/arkd-wallet-nosigner.regtest.env) + $(call setup_env, envs/arkd-wallet.regtest.env) @go run ./cmd/arkd-wallet ## sqlc: compile sql queries for sqlite diff --git a/README.md b/README.md index 9183dd9b6..c95c9e4b4 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The Operator's role is designed with strict boundaries that ensure users always * mutinynet * mainnet -and makes use of [arkd-wallet](./pkg/arkd-wallet/), an on-chain wallet based on NBXplorer, as liquidity provider and optionally also as signer. +and makes use of [arkd-wallet](./pkg/arkd-wallet/), an on-chain wallet based on NBXplorer, as liquidity provider, and [arkd-signer](./pkg/arkd-signer/) as the transaction signer. ## Usage Documentation @@ -82,7 +82,7 @@ The `arkd` server can be configured using environment variables and the admin se | `ARKD_REDIS_NUM_OF_RETRIES` | Maximum number of retries for Redis write operations in case of conflicts | - | | `ARKD_ESPLORA_URL` | Esplora API URL | `https://blockstream.info/api` | | `ARKD_WALLET_ADDR` | The arkd wallet address to connect to in the form `host:port` | - | -| `ARKD_SIGNER_ADDR` | The signer address to connect to in the form `host:port` | value of `ARKD_WALLET_ADDR` | +| `ARKD_SIGNER_ADDR` | The signer address to connect to in the form `host:port` (required) | - | | `ARKD_NO_MACAROONS` | Disable macaroon authentication | `false` | | `ARKD_NO_TLS` | Disable TLS | `true` | | `ARKD_UNLOCKER_TYPE` | Wallet unlocker type (env, file) to enable auto-unlock | - | @@ -163,13 +163,28 @@ export ARKD_WALLET_NBXPLORER_URL=http://localhost:32838 ### Configure signer -`arkd-wallet` can be used also as signer. +`arkd-signer` is a standalone service that holds the operator signing key and signs `arkd`'s protocol transactions. It is configured via the following environment variables: -The configuration can be done either via env vars or via API. To enable `arkd-wallet`'s signer mode use this environment variable: +| Environment Variable | Description | Default | +|-------------------------------|-----------------------------------------------------------------------------------|-------------| +| `ARKD_SIGNER_SECRET_KEY` | Hex-encoded operator signing key (required, sensitive) | - | +| `ARKD_SIGNER_DEPRECATED_KEYS` | Comma-separated old keys still accepted for signing, for key rotation (sensitive) | - | +| `ARKD_SIGNER_PORT` | Port to listen on | `6061` | +| `ARKD_SIGNER_LOG_LEVEL` | Logging level (0-6, where 6 is trace) | `4` (info) | + +Configure the signing key (required): ```sh # Make sure to use a random private key, this is just an example. -export ARKD_WALLET_SIGNER_KEY=19422b10efd05403820ff6a3365422be2fc5f07f34a6d1603f7298328f0f80f6 +export ARKD_SIGNER_SECRET_KEY=19422b10efd05403820ff6a3365422be2fc5f07f34a6d1603f7298328f0f80f6 +``` + +To rotate the signer key while still accepting coins locked to old keys, set `ARKD_SIGNER_DEPRECATED_KEYS` to a comma-separated list of old keys. Each entry is a hex key, optionally followed by `:` and a cutoff date, which is a Unix timestamp in **seconds** (UTC), after which clients should stop locking new funds to that key. Omit the cutoff to leave it unset: + +```sh +# Format: [:],[:] +# Example with two deprecated keys: the first retires at a cutoff date, the second has no cutoff. +export ARKD_SIGNER_DEPRECATED_KEYS=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6:1735689600,f0e1d2c3b4a59687766554433221100ff1e2d3c4b5a69788796a5b4c3d2e1f00 ``` ### Connect to wallet @@ -183,15 +198,11 @@ export ARKD_WALLET_ADDR=localhost:6060 ### Connect to signer -By default, `arkd` makes use of the provided `arkd-wallet` also as signer, but you can customize its url either via environment variable or via API. - -#### Connect to custom signer - -To connect `arkd` to a custom signer use this environment variable: +`arkd` connects to `arkd-signer` via this environment variable (required): ```sh # Make sure to use the right URL in the form host:port, this is just an example. -export ARKD_SIGNER_ADDR=localhost:7071 +export ARKD_SIGNER_ADDR=localhost:6061 ``` ### Setup arkd @@ -201,12 +212,17 @@ export ARKD_SIGNER_ADDR=localhost:7071 arkd-wallet ``` -2. Start arkd: +2. Start the signer: + ```sh + arkd-signer + ``` + +3. Start arkd (requires `ARKD_WALLET_ADDR` and `ARKD_SIGNER_ADDR`): ```sh arkd ``` -3. Create a new wallet: +4. Create a new wallet: ```sh arkd wallet create --password ``` @@ -215,14 +231,11 @@ export ARKD_SIGNER_ADDR=localhost:7071 ```sh arkd wallet create --mnemonic "your twelve word mnemonic phrase here" --password ``` -4. Only if you didn't configure either the wallet as signer, or a custom signer, you must load the signer before unlocking the wallet, or `arkd` will fail to start: + + You can repoint `arkd` at a different signer URL at runtime if needed: ```sh - # If you configured a custom signer - arkd signer load --signer-url localhost:7071 - # Or, if you want to configure the wallet as signer with a private key - arkd signer load --signer-prvkey 19422b10efd05403820ff6a3365422be2fc5f07f34a6d1603f7298328f0f80f6 + arkd signer load --signer-url localhost:6061 ``` - Remember, if you use this command, you must use it at every restart unless you export the required environment variable(s). 5. Unlock the wallet: ```sh @@ -256,7 +269,8 @@ For a complete list of available commands and options: - [`api-spec`](./api-spec/): Ark Protocol Buffer API specification. - [`pkg`](./pkg/): collection of reusable packages and services. - [`ark-lib`](./pkg/ark-lib): collection of data structures and functions reusable by arkd and sdk. - - [`arkd-wallet`](./pkg/arkd-wallet): bitcoin wallet service used as liquidity provider and signer. + - [`arkd-wallet`](./pkg/arkd-wallet): bitcoin wallet service used as liquidity provider. + - [`arkd-signer`](./pkg/arkd-signer): standalone service that holds the operator key and signs arkd's protocol transactions. - [`ark-cli`](./pkg/ark-cli): ark offchain and onchain wallet as command line interface. - [`internal`](./internal): arkd implementation. - [`core`](./internal/core): contains the core business logic of arkd. @@ -308,14 +322,10 @@ To compile the `arkd` binary from source, you can use the following Make command go mod download ``` -6. Run arkd wallet in dev mode: +6. Run arkd wallet and signer in dev mode (each in its own tab): ```sh - # run wallet with signer enabled make run-wallet - # or, run wallet with signer disabled... - make run-wallet-nosigner - # ... and in another tab run a custom signer make run-signer ``` @@ -352,6 +362,8 @@ To compile the `arkd` binary from source, you can use the following Make command make docker-stop ``` + `make docker-run` brings up the full stack defined in `docker-compose.regtest.yml` (postgres, redis, nbxplorer, `arkd-wallet`, `arkd-signer`, and `arkd`). + ### Protobuf Breaking Change Detection To ensure API compatibility, breaking changes in protobuf definitions are automatically detected: diff --git a/arkdsigner.Dockerfile b/arkdsigner.Dockerfile new file mode 100644 index 000000000..ccb2054bb --- /dev/null +++ b/arkdsigner.Dockerfile @@ -0,0 +1,31 @@ +# First stage: build the arkd-signer binary +FROM golang:1.26.5 AS builder + +ARG VERSION +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /app + +COPY . . + +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-X 'main.Version=${VERSION}'" -o /app/bin/arkd-signer ./cmd/arkd-signer/main.go + +# Second stage: minimal runtime image +FROM alpine:3.20 + +RUN apk update && apk upgrade + +WORKDIR /app + +COPY --from=builder /app/bin/arkd-signer /app/ + +ENV PATH="/app:${PATH}" + +# /healthz maps a NOT_SERVING health response to 503, and the signer reports +# NOT_SERVING until its key is usable, so this gates on readiness rather than on +# the process having started. +HEALTHCHECK --interval=5s --timeout=3s --start-period=5s --retries=5 \ + CMD wget -q --spider "http://127.0.0.1:${ARKD_SIGNER_PORT:-6061}/healthz" || exit 1 + +ENTRYPOINT [ "arkd-signer" ] diff --git a/cmd/arkd-signer/main.go b/cmd/arkd-signer/main.go new file mode 100644 index 000000000..a9a461d0f --- /dev/null +++ b/cmd/arkd-signer/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "os" + "os/signal" + "syscall" + + "github.com/arkade-os/arkd/pkg/arkd-signer/config" + grpcservice "github.com/arkade-os/arkd/pkg/arkd-signer/interface/grpc" + log "github.com/sirupsen/logrus" +) + +func main() { + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("invalid arkd-signer config: %s", err) + } + + log.SetLevel(log.Level(cfg.LogLevel)) + + svc, err := grpcservice.NewService(cfg) + if err != nil { + log.Fatalf("failed to create arkd-signer service: %s", err) + } + + log.Infof("arkd-signer config: %s", cfg) + + log.Info("starting arkd-signer service...") + if err := svc.Start(); err != nil { + log.Fatalf("failed to start arkd-signer service: %s", err) + } + log.Infof("arkd-signer listens on: %v", cfg.Port) + + log.RegisterExitHandler(svc.Stop) + + sigChan := make(chan os.Signal, 1) + signal.Notify( + sigChan, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGHUP, os.Interrupt, + ) + <-sigChan + + log.Info("shutting down arkd-signer service...") + log.Exit(0) +} diff --git a/cmd/arkd-wallet/main.go b/cmd/arkd-wallet/main.go index a338ec652..c110f2f39 100644 --- a/cmd/arkd-wallet/main.go +++ b/cmd/arkd-wallet/main.go @@ -14,7 +14,7 @@ import ( func main() { cfg, err := config.LoadConfig() if err != nil { - log.Fatalf("invalid config: %s", err) + log.Fatalf("invalid arkd-wallet config: %s", err) } log.SetLevel(log.Level(cfg.LogLevel)) @@ -24,16 +24,16 @@ func main() { svc, err := grpcservice.NewService(cfg) if err != nil { - log.Fatalf("failed to create service: %s", err) + log.Fatalf("failed to create arkd-wallet service: %s", err) } - log.Infof("arkd wallet config: %+v", cfg) + log.Infof("arkd-wallet config: %+v", cfg) - log.Info("starting service...") + log.Info("starting arkd-wallet service...") if err := svc.Start(); err != nil { log.Fatalf("failed to start service: %s", err) } - log.Infof("arkd wallet listens on: %v", cfg.Port) + log.Infof("arkd-wallet listens on: %v", cfg.Port) log.RegisterExitHandler(svc.Stop) @@ -43,6 +43,6 @@ func main() { ) <-sigChan - log.Info("shutting down service...") + log.Info("shutting down arkd-wallet service...") log.Exit(0) } diff --git a/cmd/arkd/commands.go b/cmd/arkd/commands.go index 75617d2b7..c03eb19eb 100644 --- a/cmd/arkd/commands.go +++ b/cmd/arkd/commands.go @@ -87,9 +87,9 @@ var ( } signerLoadCmd = &cli.Command{ Name: "load", - Usage: "Load the ark signer address or private key", + Usage: "Load the ark signer url", Action: signerLoadAction, - Flags: []cli.Flag{signerKeyFlag, signerUrlFlag}, + Flags: []cli.Flag{signerUrlFlag}, } noteCmd = &cli.Command{ Name: "note", @@ -447,13 +447,9 @@ func walletWithdrawAction(ctx *cli.Context) error { func signerLoadAction(ctx *cli.Context) error { baseURL := ctx.String(urlFlagName) - signerKey := ctx.String(signerKeyFlagName) signerUrl := ctx.String(signerUrlFlagName) - if signerKey == "" && signerUrl == "" { - return fmt.Errorf("either private key or url must be provided") - } - if signerKey != "" && signerUrl != "" { - return fmt.Errorf("private key and url are mutually exclusive, only one must be provided") + if signerUrl == "" { + return fmt.Errorf("signer url must be provided") } macaroon, tlsConfig, err := getCredentials(ctx) if err != nil { @@ -462,9 +458,6 @@ func signerLoadAction(ctx *cli.Context) error { url := fmt.Sprintf("%s/v1/admin/signer", baseURL) body := fmt.Sprintf(`{"signerUrl": "%s"}`, signerUrl) - if signerKey != "" { - body = fmt.Sprintf(`{"signerPrivateKey": "%s"}`, signerKey) - } if _, err := post[struct{}](url, body, "", macaroon, tlsConfig); err != nil { return err diff --git a/cmd/arkd/flags.go b/cmd/arkd/flags.go index 6e4dad41e..3f485311d 100644 --- a/cmd/arkd/flags.go +++ b/cmd/arkd/flags.go @@ -30,7 +30,6 @@ const ( scheduledSessionPeriodFlagName = "period" roundMinParticipantsFlagName = "round-min-participants" roundMaxParticipantsFlagName = "round-max-participants" - signerKeyFlagName = "signer-prvkey" signerUrlFlagName = "signer-url" tokenFlagName = "token" convictionIdsFlagName = "ids" @@ -184,10 +183,6 @@ var ( Name: roundMaxParticipantsFlagName, Usage: "the max number of participants per round", } - signerKeyFlag = &cli.StringFlag{ - Name: signerKeyFlagName, - Usage: "the private key to be loaded to arkd wallet and used as signer", - } signerUrlFlag = &cli.StringFlag{ Name: signerUrlFlagName, Usage: "the url of the signer to connect to", diff --git a/cmd/arkd/main.go b/cmd/arkd/main.go index 90f8890c2..1473f66b7 100755 --- a/cmd/arkd/main.go +++ b/cmd/arkd/main.go @@ -26,7 +26,7 @@ const ( func startAction(_ *cli.Context) error { cfg, err := config.LoadConfig() if err != nil { - return fmt.Errorf("invalid config: %s", err) + return fmt.Errorf("invalid arkd config: %s", err) } log.SetLevel(log.Level(cfg.LogLevel)) @@ -54,9 +54,9 @@ func startAction(_ *cli.Context) error { return err } - log.Infof("ark server config: %s", cfg) + log.Infof("arkd server config: %s", cfg) - log.Debug("starting service...") + log.Debug("starting arkd service...") if err := svc.Start(); err != nil { return err } @@ -69,7 +69,7 @@ func startAction(_ *cli.Context) error { ) <-sigChan - log.Debug("shutting down service...") + log.Debug("shutting down arkd service...") log.Exit(0) return nil diff --git a/docker-compose.regtest.yml b/docker-compose.regtest.yml index 4acfb846b..8e71ef027 100644 --- a/docker-compose.regtest.yml +++ b/docker-compose.regtest.yml @@ -57,10 +57,24 @@ services: - ARKD_WALLET_NBXPLORER_URL=http://nbxplorer:32838 - ARKD_WALLET_DATADIR=./data/regtest - ARKD_WALLET_NETWORK=regtest - - ARKD_WALLET_SIGNER_KEY=${ARKD_WALLET_SIGNER_KEY:-afcd3fa10f82a05fddc9574fdb13b3991b568e89cc39a72ba4401df8abef35f0} - - ARKD_WALLET_DEPRECATED_SIGNER_KEYS=${ARKD_WALLET_DEPRECATED_SIGNER_KEYS:-} volumes: - arkd-wallet-volume:/app/data + arkd-signer: + restart: unless-stopped + build: + context: . + dockerfile: arkdsigner.Dockerfile + container_name: arkd-signer + # Deliberately not published to the host. The signer holds the operator key + # and has no auth, so only arkd reaches it, over the compose network at + # arkd-signer:6061. + expose: + - "6061" + environment: + - ARKD_SIGNER_LOG_LEVEL=5 + - ARKD_SIGNER_PORT=6061 + - ARKD_SIGNER_SECRET_KEY=${ARKD_SIGNER_SECRET_KEY:-afcd3fa10f82a05fddc9574fdb13b3991b568e89cc39a72ba4401df8abef35f0} + - ARKD_SIGNER_DEPRECATED_KEYS=${ARKD_SIGNER_DEPRECATED_KEYS:-} redis: restart: unless-stopped image: redis:7-alpine @@ -79,9 +93,16 @@ services: container_name: arkd restart: unless-stopped depends_on: - - arkd-wallet - - pg - - redis + # Wait for readiness, not just container start, wherever a healthcheck + # exists. arkd-wallet has none, so it stays on the default. + arkd-wallet: + condition: service_started + arkd-signer: + condition: service_healthy + pg: + condition: service_healthy + redis: + condition: service_healthy ports: - "7070:7070" - "7071:7071" @@ -104,6 +125,7 @@ services: - ARKD_BAN_THRESHOLD=1 - ARKD_DATADIR=./data/regtest - ARKD_WALLET_ADDR=arkd-wallet:6060 + - ARKD_SIGNER_ADDR=arkd-signer:6061 - ARKD_ESPLORA_URL=http://chopsticks:3000 - ARKD_DB_TYPE=${ARKD_DB_TYPE:-sqlite} - ARKD_PG_DB_URL=${ARKD_PG_DB_URL:-} diff --git a/envs/arkd-wallet-nosigner.regtest.env b/envs/arkd-wallet-nosigner.regtest.env deleted file mode 100644 index 154f1f003..000000000 --- a/envs/arkd-wallet-nosigner.regtest.env +++ /dev/null @@ -1,4 +0,0 @@ -ARKD_WALLET_LOG_LEVEL=5 -ARKD_WALLET_NBXPLORER_URL=http://localhost:32838 -ARKD_WALLET_DATADIR=./data/wallet-nosigner -ARKD_WALLET_NETWORK=regtest \ No newline at end of file diff --git a/envs/arkd-wallet.regtest.env b/envs/arkd-wallet.regtest.env index fcaa5f117..3d2fd8974 100644 --- a/envs/arkd-wallet.regtest.env +++ b/envs/arkd-wallet.regtest.env @@ -1,5 +1,4 @@ ARKD_WALLET_LOG_LEVEL=5 ARKD_WALLET_NBXPLORER_URL=http://localhost:32838 ARKD_WALLET_DATADIR=./data/wallet -ARKD_WALLET_NETWORK=regtest -ARKD_WALLET_SIGNER_KEY=19422b10efd05403820ff6a3365422be2fc5f07f34a6d1603f7298328f0f80f6 \ No newline at end of file +ARKD_WALLET_NETWORK=regtest \ No newline at end of file diff --git a/envs/signer.dev.env b/envs/signer.dev.env index b5d27d503..3721d955c 100644 --- a/envs/signer.dev.env +++ b/envs/signer.dev.env @@ -1,6 +1,3 @@ -ARKD_WALLET_LOG_LEVEL=5 -ARKD_WALLET_NBXPLORER_URL=http://localhost:32838 -ARKD_WALLET_DATADIR=./data/signer -ARKD_WALLET_NETWORK=regtest -ARKD_WALLET_SIGNER_KEY=19422b10efd05403820ff6a3365422be2fc5f07f34a6d1603f7298328f0f80f6 -ARKD_WALLET_PORT=6161 \ No newline at end of file +ARKD_SIGNER_LOG_LEVEL=5 +ARKD_SIGNER_SECRET_KEY=19422b10efd05403820ff6a3365422be2fc5f07f34a6d1603f7298328f0f80f6 +ARKD_SIGNER_PORT=6061 diff --git a/go.mod b/go.mod index 54c397882..9e6456522 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ replace github.com/arkade-os/arkd/pkg/ark-lib => ./pkg/ark-lib replace github.com/arkade-os/arkd/pkg/arkd-wallet => ./pkg/arkd-wallet +replace github.com/arkade-os/arkd/pkg/arkd-signer => ./pkg/arkd-signer + replace github.com/arkade-os/arkd/pkg/macaroons => ./pkg/macaroons replace github.com/arkade-os/arkd/pkg/kvdb => ./pkg/kvdb @@ -22,6 +24,7 @@ require ( github.com/ThreeDotsLabs/watermill-sql/v3 v3.1.0 github.com/arkade-os/arkd/api-spec v0.0.0-00010101000000-000000000000 github.com/arkade-os/arkd/pkg/ark-lib v0.8.1-0.20260210151408-67ee91bbd639 + github.com/arkade-os/arkd/pkg/arkd-signer v0.0.0-00010101000000-000000000000 github.com/arkade-os/arkd/pkg/arkd-wallet v0.0.0-00010101000000-000000000000 github.com/arkade-os/arkd/pkg/client-lib v0.0.0-00010101000000-000000000000 github.com/arkade-os/arkd/pkg/errors v0.0.0-00010101000000-000000000000 diff --git a/internal/config/config.go b/internal/config/config.go index 0b6e690b0..4ac8d81d7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -393,7 +393,7 @@ func LoadConfig() (*Config, error) { signerAddr := viper.GetString(SignerAddr) if signerAddr == "" { - signerAddr = viper.GetString(WalletAddr) + return nil, fmt.Errorf("ARKD_SIGNER_ADDR is required") } // In case the admin port is unset, fallback to service port. diff --git a/internal/core/application/fraud.go b/internal/core/application/fraud.go index 9c7706b81..2270ec19b 100644 --- a/internal/core/application/fraud.go +++ b/internal/core/application/fraud.go @@ -10,6 +10,7 @@ import ( "github.com/arkade-os/arkd/internal/core/domain" "github.com/arkade-os/arkd/pkg/ark-lib/tree" + "github.com/arkade-os/arkd/pkg/ark-lib/txsigner" "github.com/arkade-os/arkd/pkg/ark-lib/txutils" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" @@ -95,7 +96,12 @@ func (s *service) broadcastCheckpointTx( } checkpointB64 := txs[0] - txHex, err := s.builder.FinalizeAndExtract(checkpointB64) + checkpointPtx, err := psbt.NewFromRawBytes(strings.NewReader(checkpointB64), true) + if err != nil { + return nil, fmt.Errorf("failed to parse checkpoint tx: %s", err) + } + + txHex, err := txsigner.ExtractFinalizedTx(checkpointPtx) if err != nil { return nil, fmt.Errorf("failed to finalize checkpoint tx: %s", err) } @@ -164,14 +170,29 @@ func (s *service) broadcastForfeitTx(ctx context.Context, vtxo domain.Vtxo) erro return fmt.Errorf("failed to encode forfeit tx: %s", err) } + // Sign the vtxo input (tapscript) with the operator signer. signedForfeitTx, err := s.signer.SignTransactionTapscript(ctx, forfeitTxB64, nil) if err != nil { return fmt.Errorf("failed to sign forfeit tx: %s", err) } - forfeitTxHex, err := s.builder.FinalizeAndExtract(signedForfeitTx) + // Sign the connector input (a wallet-owned key-path output) with the wallet: + // the operator signer holds no wallet keys, so it cannot sign it. The wallet + // finalizes and extracts the raw tx too. + // + // This pass is not scoped to the connector input, so the wallet also appends + // a TaprootScriptSpendSig from its forfeit key to the vtxo input the signer + // just signed. That is inert only because script.FinalizeVtxoScript keys the + // witness args by xonly pubkey and the wallet forfeit key is not among the + // forfeit closure's PubKeys, so closure.Witness never reads it. Put the + // wallet forfeit pubkey inside a vtxo closure and the finalizer would start + // preferring the wallet's signature over the operator signer's, silently + // changing which key authorized the spend. Scoping this call to the + // connector index needs input indexes on the wallet's SignTransaction RPC, + // which today only SignTransactionTapscript carries. + forfeitTxHex, err := s.wallet.SignTransaction(ctx, signedForfeitTx, true) if err != nil { - return fmt.Errorf("failed to finalize forfeit tx: %s", err) + return fmt.Errorf("failed to sign forfeit connector input: %s", err) } var forfeit wire.MsgTx diff --git a/internal/core/application/fraud_dualsign_test.go b/internal/core/application/fraud_dualsign_test.go new file mode 100644 index 000000000..5da8939e9 --- /dev/null +++ b/internal/core/application/fraud_dualsign_test.go @@ -0,0 +1,157 @@ +package application + +import ( + "bytes" + "testing" + + "github.com/arkade-os/arkd/pkg/ark-lib/script" + "github.com/arkade-os/arkd/pkg/ark-lib/txsigner" + "github.com/arkade-os/arkd/pkg/ark-lib/txutils" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// broadcastForfeitTx signs a forfeit tx in two passes: the operator signer signs +// the vtxo tapscript input, then the wallet signs the connector input. The +// wallet pass is not scoped to the connector, so it also appends a signature +// from its own forfeit key to the vtxo input the signer just signed. +// +// That extra signature is inert only because script.FinalizeVtxoScript keys +// witness args by xonly pubkey and the wallet forfeit key is absent from the +// forfeit closure. These tests pin that invariant: the witness that actually +// reaches the chain must be authorized by the operator key, and the wallet's +// stray signature must not appear in it. If someone puts the wallet forfeit +// pubkey inside a vtxo closure, the second case here is what should fail. +func TestForfeitWitnessIsAuthorizedByOperatorKey(t *testing.T) { + owner, err := btcec.NewPrivateKey() + require.NoError(t, err) + operator, err := btcec.NewPrivateKey() + require.NoError(t, err) + walletForfeit, err := btcec.NewPrivateKey() + require.NoError(t, err) + + t.Run("operator signature survives the wallet's extra pass", func(t *testing.T) { + ptx, sigHashes := forfeitPacket(t, owner, operator.PubKey()) + + // Pass 1: the operator signer, plus the owner so the 2-of-2 leaf can be + // satisfied at all. + require.NoError(t, txsigner.SignTapscriptInput(ptx, 0, operator, sigHashes)) + require.NoError(t, txsigner.SignTapscriptInput(ptx, 0, owner, sigHashes)) + + // Pass 2: the wallet's unscoped sweep over every tapscript input. + require.NoError(t, txsigner.SignTapscriptInput(ptx, 0, walletForfeit, sigHashes)) + require.Len(t, ptx.Inputs[0].TaprootScriptSpendSig, 3, + "the psbt should carry the stray wallet signature before finalization") + + // Read the partial signatures before finalizing. A finalizer is allowed + // to clear them (BIP-370 says it SHOULD), so reading them afterwards + // would make this test depend on FinalizeVtxoScript leaving them behind. + operatorSig := partialSigFor(t, ptx, 0, operator.PubKey()) + walletSig := partialSigFor(t, ptx, 0, walletForfeit.PubKey()) + + require.NoError(t, script.FinalizeVtxoScript(ptx, 0)) + + witness := finalWitness(t, ptx, 0) + require.NotEmpty(t, witness) + require.True(t, witnessContains(witness, operatorSig), + "the operator signature must be the one that reaches the chain") + require.False(t, witnessContains(witness, walletSig), + "the wallet forfeit signature must not reach the chain") + }) + + // Without the operator's signature the leaf cannot be satisfied, so the + // wallet's stray signature must not be able to stand in for it. + t.Run("wallet signature alone cannot satisfy the leaf", func(t *testing.T) { + ptx, sigHashes := forfeitPacket(t, owner, operator.PubKey()) + + require.NoError(t, txsigner.SignTapscriptInput(ptx, 0, owner, sigHashes)) + require.NoError(t, txsigner.SignTapscriptInput(ptx, 0, walletForfeit, sigHashes)) + + require.Error(t, script.FinalizeVtxoScript(ptx, 0)) + }) +} + +// partialSigFor returns the signature the given key contributed to this input. +// Call it before finalizing: a finalizer may clear the partial-signature fields. +func partialSigFor( + t *testing.T, ptx *psbt.Packet, inputIndex int, pubkey *btcec.PublicKey, +) []byte { + t.Helper() + want := schnorr.SerializePubKey(pubkey) + for _, s := range ptx.Inputs[inputIndex].TaprootScriptSpendSig { + if bytes.Equal(s.XOnlyPubKey, want) { + return s.Signature + } + } + t.Fatalf("no partial signature recorded for %x", want) + return nil +} + +func finalWitness(t *testing.T, ptx *psbt.Packet, inputIndex int) wire.TxWitness { + t.Helper() + witness, err := txutils.ReadTxWitness(ptx.Inputs[inputIndex].FinalScriptWitness) + require.NoError(t, err) + return witness +} + +func witnessContains(witness wire.TxWitness, sig []byte) bool { + for _, item := range witness { + if bytes.Equal(item, sig) { + return true + } + } + return false +} + +// forfeitPacket builds a forfeit-shaped psbt: input 0 is the vtxo, spent through +// an owner+operator multisig leaf, which is the input both signing passes touch. +func forfeitPacket( + t *testing.T, owner *btcec.PrivateKey, operator *btcec.PublicKey, +) (*psbt.Packet, *txscript.TxSigHashes) { + t.Helper() + closure := &script.MultisigClosure{ + PubKeys: []*btcec.PublicKey{owner.PubKey(), operator}, + Type: script.MultisigTypeChecksig, + } + leaf, err := closure.Script() + require.NoError(t, err) + + tapLeaf := txscript.NewBaseTapLeaf(leaf) + tapTree := txscript.AssembleTaprootScriptTree(tapLeaf) + rootHash := tapTree.RootNode.TapHash() + outputKey := txscript.ComputeTaprootOutputKey(owner.PubKey(), rootHash[:]) + pkScript, err := txscript.NewScriptBuilder(). + AddOp(txscript.OP_1).AddData(schnorr.SerializePubKey(outputKey)).Script() + require.NoError(t, err) + + ctrlBlock := tapTree.LeafMerkleProofs[0].ToControlBlock(owner.PubKey()) + ctrlBytes, err := ctrlBlock.ToBytes() + require.NoError(t, err) + + prevHash, err := chainhash.NewHashFromStr( + "0000000000000000000000000000000000000000000000000000000000000001", + ) + require.NoError(t, err) + + unsigned := wire.NewMsgTx(2) + unsigned.AddTxIn(wire.NewTxIn(&wire.OutPoint{Hash: *prevHash, Index: 0}, nil, nil)) + unsigned.AddTxOut(&wire.TxOut{Value: 900, PkScript: pkScript}) + + ptx, err := psbt.NewFromUnsignedTx(unsigned) + require.NoError(t, err) + ptx.Inputs[0].WitnessUtxo = &wire.TxOut{Value: 1000, PkScript: pkScript} + ptx.Inputs[0].TaprootLeafScript = []*psbt.TaprootTapLeafScript{{ + ControlBlock: ctrlBytes, + Script: leaf, + LeafVersion: txscript.BaseLeafVersion, + }} + + fetcher, err := txutils.GetPrevOutputFetcher(ptx) + require.NoError(t, err) + return ptx, txscript.NewTxSigHashes(ptx.UnsignedTx, fetcher) +} diff --git a/internal/core/application/service.go b/internal/core/application/service.go index 959ab5ef0..dc3be68d7 100644 --- a/internal/core/application/service.go +++ b/internal/core/application/service.go @@ -24,6 +24,7 @@ import ( "github.com/arkade-os/arkd/pkg/ark-lib/offchain" "github.com/arkade-os/arkd/pkg/ark-lib/script" "github.com/arkade-os/arkd/pkg/ark-lib/tree" + "github.com/arkade-os/arkd/pkg/ark-lib/txsigner" "github.com/arkade-os/arkd/pkg/ark-lib/txutils" "github.com/arkade-os/arkd/pkg/errors" "github.com/btcsuite/btcd/btcec/v2" @@ -1035,7 +1036,15 @@ func (s *service) SubmitOffchainTx( }) } - txHex, err := s.builder.FinalizeAndExtract(fullySignedArkTx) + fullySignedArkPtx, err := psbt.NewFromRawBytes(strings.NewReader(fullySignedArkTx), true) + if err != nil { + return nil, errors.INTERNAL_ERROR.New("failed to parse ark tx: %w", err). + WithMetadata(map[string]any{ + "ark_tx": fullySignedArkTx, + }) + } + + txHex, err := txsigner.ExtractFinalizedTx(fullySignedArkPtx) if err != nil { return nil, errors.INTERNAL_ERROR.New("failed to finalize ark tx: %w", err). WithMetadata(map[string]any{ diff --git a/internal/core/application/sweeper_test.go b/internal/core/application/sweeper_test.go index 5e6e1c86d..d29ec3970 100644 --- a/internal/core/application/sweeper_test.go +++ b/internal/core/application/sweeper_test.go @@ -611,7 +611,6 @@ func (m *mockTxBuilder) GetSweepableBatchOutputs( ) (*arklib.RelativeLocktime, *ports.TxInput, error) { return nil, nil, nil } -func (m *mockTxBuilder) FinalizeAndExtract(tx string) (string, error) { return "", nil } func (m *mockTxBuilder) VerifyVtxoTapscriptSigs( tx string, mustIncludeSignerSig bool, diff --git a/internal/core/ports/tx_builder.go b/internal/core/ports/tx_builder.go index ccbe61199..5c12225fa 100644 --- a/internal/core/ports/tx_builder.go +++ b/internal/core/ports/tx_builder.go @@ -64,7 +64,6 @@ type TxBuilder interface { GetSweepableBatchOutputs(vtxoTree *tree.TxTree) ( vtxoTreeExpiry *arklib.RelativeLocktime, batchOutputs *TxInput, err error, ) - FinalizeAndExtract(tx string) (txhex string, err error) VerifyVtxoTapscriptSigs( tx string, mustIncludeSignerSig bool, ) (valid bool, ptx *psbt.Packet, err error) diff --git a/internal/core/ports/wallet.go b/internal/core/ports/wallet.go index 7bb267a27..3aaee8a18 100644 --- a/internal/core/ports/wallet.go +++ b/internal/core/ports/wallet.go @@ -49,7 +49,6 @@ type WalletService interface { GetOutpointStatus(ctx context.Context, outpoint domain.Outpoint) (spent bool, err error) GetCurrentBlockTime(ctx context.Context) (*BlockTimestamp, error) Withdraw(ctx context.Context, address string, amount uint64, all bool) (string, error) - LoadSignerKey(ctx context.Context, prvkey string) error Close() } diff --git a/internal/infrastructure/live-store/live_store_test.go b/internal/infrastructure/live-store/live_store_test.go index 9552412a7..8dd7c773b 100644 --- a/internal/infrastructure/live-store/live_store_test.go +++ b/internal/infrastructure/live-store/live_store_test.go @@ -901,12 +901,6 @@ func (m *mockedTxBuilder) GetSweepableBatchOutputs( return res0, res1, args.Error(2) } -func (m *mockedTxBuilder) FinalizeAndExtract(tx string) (txhex string, err error) { - args := m.Called(tx) - res0 := args.Get(0).(string) - return res0, args.Error(1) -} - func (m *mockedTxBuilder) VerifyVtxoTapscriptSigs( tx string, mustIncludeSignerSig bool, ) (valid bool, ptx *psbt.Packet, err error) { diff --git a/internal/infrastructure/tx-builder/covenantless/builder.go b/internal/infrastructure/tx-builder/covenantless/builder.go index 7a1212a21..5d292d3b0 100644 --- a/internal/infrastructure/tx-builder/covenantless/builder.go +++ b/internal/infrastructure/tx-builder/covenantless/builder.go @@ -237,40 +237,6 @@ func (b *txBuilder) verifyTapscriptPartialSigs( return true, ptx, nil } -func (b *txBuilder) FinalizeAndExtract(tx string) (string, error) { - ptx, err := psbt.NewFromRawBytes(strings.NewReader(tx), true) - if err != nil { - return "", err - } - - for i, in := range ptx.Inputs { - isTaproot := txscript.IsPayToTaproot(in.WitnessUtxo.PkScript) - if isTaproot && len(in.TaprootLeafScript) > 0 { - if err := script.FinalizeVtxoScript(ptx, i); err != nil { - return "", err - } - continue - } - - if err := psbt.Finalize(ptx, i); err != nil { - return "", fmt.Errorf("failed to finalize input %d: %w", i, err) - } - } - - signed, err := psbt.Extract(ptx) - if err != nil { - return "", err - } - - var serialized bytes.Buffer - - if err := signed.Serialize(&serialized); err != nil { - return "", err - } - - return hex.EncodeToString(serialized.Bytes()), nil -} - func (b *txBuilder) BuildSweepTx(inputs []ports.TxInput) ( txid, signedSweepTx string, err error, ) { diff --git a/internal/infrastructure/tx-builder/covenantless/mocks_test.go b/internal/infrastructure/tx-builder/covenantless/mocks_test.go index fd6e135c3..8764f32cc 100644 --- a/internal/infrastructure/tx-builder/covenantless/mocks_test.go +++ b/internal/infrastructure/tx-builder/covenantless/mocks_test.go @@ -382,11 +382,6 @@ func (m *mockedWallet) GetOutpointStatus( return res, args.Error(1) } -func (m *mockedWallet) LoadSignerKey(ctx context.Context, privateKey string) error { - args := m.Called(ctx, privateKey) - return args.Error(0) -} - func (m *mockedWallet) RescanUtxos(ctx context.Context, outs []wire.OutPoint) error { args := m.Called(ctx, outs) return args.Error(0) diff --git a/internal/infrastructure/wallet/wallet_client.go b/internal/infrastructure/wallet/wallet_client.go index aab7442bc..3ad14dfe2 100644 --- a/internal/infrastructure/wallet/wallet_client.go +++ b/internal/infrastructure/wallet/wallet_client.go @@ -556,11 +556,6 @@ func (w *walletDaemonClient) GetOutpointStatus( return resp.GetSpent(), nil } -func (w *walletDaemonClient) LoadSignerKey(ctx context.Context, prvkey string) error { - _, err := w.client.LoadSignerKey(ctx, &arkwalletv1.LoadSignerKeyRequest{PrivateKey: prvkey}) - return err -} - func (w *walletDaemonClient) RescanUtxos(ctx context.Context, outs []wire.OutPoint) error { outsStr := make([]string, 0, len(outs)) for _, out := range outs { diff --git a/internal/interface/grpc/handlers/signer_manager.go b/internal/interface/grpc/handlers/signer_manager.go index 6b5d760d8..6068ff137 100644 --- a/internal/interface/grpc/handlers/signer_manager.go +++ b/internal/interface/grpc/handlers/signer_manager.go @@ -4,42 +4,38 @@ import ( "context" arkv1 "github.com/arkade-os/arkd/api-spec/protobuf/gen/ark/v1" - "github.com/arkade-os/arkd/internal/core/ports" log "github.com/sirupsen/logrus" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) type signerManagerHandler struct { - walletSvc ports.WalletService onLoadSigner func(addr string) error } func NewSignerManagerHandler( - walletSvc ports.WalletService, onLoadSigner func(addr string) error, + onLoadSigner func(addr string) error, ) arkv1.SignerManagerServiceServer { - return &signerManagerHandler{walletSvc, onLoadSigner} + return &signerManagerHandler{onLoadSigner} } func (h *signerManagerHandler) LoadSigner( ctx context.Context, req *arkv1.LoadSignerRequest, ) (*arkv1.LoadSignerResponse, error) { - signerUrl := req.GetSignerUrl() - signerPrvkey := req.GetSignerPrivateKey() - if signerUrl == "" && signerPrvkey == "" { - return nil, status.Error(codes.InvalidArgument, "missing address or private key") - } - if signerUrl != "" && signerPrvkey != "" { + // Runtime key injection into arkd-wallet is no longer supported: the operator + // key now lives in arkd-signer (ARKD_SIGNER_SECRET_KEY). Only repointing arkd + // at a signer URL is supported. + if req.GetSignerPrivateKey() != "" { return nil, status.Error( - codes.InvalidArgument, "address and private key are mutually exclusive", + codes.InvalidArgument, + "runtime signer key injection is no longer supported; "+ + "configure ARKD_SIGNER_SECRET_KEY on arkd-signer and provide signer_url", ) } - if signerPrvkey != "" { - if err := h.walletSvc.LoadSignerKey(ctx, signerPrvkey); err != nil { - return nil, status.Error(codes.Internal, err.Error()) - } - return &arkv1.LoadSignerResponse{}, nil + signerUrl := req.GetSignerUrl() + if signerUrl == "" { + return nil, status.Error(codes.InvalidArgument, "missing signer url") } if h.onLoadSigner == nil { diff --git a/internal/interface/grpc/service.go b/internal/interface/grpc/service.go index e24aa1e88..27cc18abb 100644 --- a/internal/interface/grpc/service.go +++ b/internal/interface/grpc/service.go @@ -368,7 +368,7 @@ func (s *service) newServer(tlsConfig *tls.Config, withPprof, withChannelz bool) ) walletHandler := handlers.NewWalletHandler(walletSvc) walletInitHandler := handlers.NewWalletInitializerHandler(walletSvc, onInit, onUnlock, onReady) - signerManagerHandler := handlers.NewSignerManagerHandler(walletSvc, onLoadSigner) + signerManagerHandler := handlers.NewSignerManagerHandler(onLoadSigner) healthHandler := handlers.NewHealthHandler() var adminGrpcServer *grpc.Server diff --git a/internal/test/e2e/e2e_test.go b/internal/test/e2e/e2e_test.go index 89ebda062..45ebc2411 100644 --- a/internal/test/e2e/e2e_test.go +++ b/internal/test/e2e/e2e_test.go @@ -6663,7 +6663,7 @@ func TestDeprecatedSignerKey(t *testing.T) { // Restore the old signer key without deprecated keys for other integration tests t.Cleanup(func() { - require.NoError(t, recreateArkdWallet(oldSignerKey, "")) + require.NoError(t, recreateArkdSigner(oldSignerKey, "")) }) alice := setupClientWallet(t) @@ -6697,7 +6697,7 @@ func TestDeprecatedSignerKey(t *testing.T) { require.NotZero(t, int(balBefore.OffchainBalance.Total)) // rotate: new key current, old key deprecated with a cutoff date - require.NoError(t, recreateArkdWallet( + require.NoError(t, recreateArkdSigner( newSignerKey, fmt.Sprintf("%s:%d", oldSignerKey, cutoffDate), )) @@ -6774,7 +6774,7 @@ func TestDeprecatedSignerKey(t *testing.T) { // rotate again, this time with a cutoff date in the past: the old key // must no longer be accepted by the server. expiredCutoff := time.Now().Add(-time.Hour).Unix() - require.NoError(t, recreateArkdWallet( + require.NoError(t, recreateArkdSigner( newSignerKey, fmt.Sprintf("%s:%d", oldSignerKey, expiredCutoff), )) diff --git a/internal/test/e2e/utils_test.go b/internal/test/e2e/utils_test.go index eee98677e..59b2e836d 100644 --- a/internal/test/e2e/utils_test.go +++ b/internal/test/e2e/utils_test.go @@ -591,26 +591,20 @@ func restartArkd() error { return waitUntilReady(adminHttpClient) } -// recreate the arkd-wallet container with overridden signer keys, reusing the -// named data volume so the seed persists, then unlock it and restart arkd so it -// re-fetches the signer pubkey. -func recreateArkdWallet(signerKey, deprecated string) error { +// recreate the arkd-signer container with overridden signer keys, then restart +// arkd so it re-fetches the signer pubkey. The signer is stateless (key from +// config), so no data volume or wallet unlock is involved. +func recreateArkdSigner(secretKey, deprecated string) error { env := []string{ - "ARKD_WALLET_SIGNER_KEY=" + signerKey, - "ARKD_WALLET_DEPRECATED_SIGNER_KEYS=" + deprecated, + "ARKD_SIGNER_SECRET_KEY=" + secretKey, + "ARKD_SIGNER_DEPRECATED_KEYS=" + deprecated, } args := []string{ "compose", "-f", "../../../docker-compose.regtest.yml", - "up", "-d", "--force-recreate", "--no-deps", "arkd-wallet", + "up", "-d", "--force-recreate", "--no-deps", "arkd-signer", } if _, err := runCommandWithEnv(env, "docker", args...); err != nil { - return fmt.Errorf("failed to recreate arkd-wallet: %w", err) - } - - time.Sleep(8 * time.Second) - - if err := unlockArkdWallet(); err != nil { - return err + return fmt.Errorf("failed to recreate arkd-signer: %w", err) } time.Sleep(5 * time.Second) @@ -618,13 +612,6 @@ func recreateArkdWallet(signerKey, deprecated string) error { return restartArkd() } -func unlockArkdWallet() error { - adminHttpClient := &http.Client{Timeout: 15 * time.Second} - url := fmt.Sprintf("%s/v1/admin/wallet/unlock", adminUrl) - body := fmt.Sprintf(`{"password": "%s"}`, password) - return post(adminHttpClient, url, body, "unlock") -} - func setupArkd() error { adminHttpClient := &http.Client{ Timeout: 15 * time.Second, diff --git a/pkg/ark-lib/txsigner/txsigner.go b/pkg/ark-lib/txsigner/txsigner.go new file mode 100644 index 000000000..c281a769c --- /dev/null +++ b/pkg/ark-lib/txsigner/txsigner.go @@ -0,0 +1,89 @@ +// Package txsigner provides chain-free tapscript PSBT signing primitives shared +// by arkd-wallet, arkd-signer, and the emulator. It never fetches prevouts from +// chain: every input must already carry a WitnessUtxo. +package txsigner + +import ( + "bytes" + "encoding/hex" + "fmt" + + "github.com/arkade-os/arkd/pkg/ark-lib/script" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" +) + +// SignTapscriptInput signs the tapscript-path input at inputIndex with signingKey +// and appends a TaprootScriptSpendSig. The input must carry a WitnessUtxo and at +// least one TaprootLeafScript. +func SignTapscriptInput( + ptx *psbt.Packet, inputIndex int, signingKey *btcec.PrivateKey, + sigHashes *txscript.TxSigHashes, +) error { + if inputIndex < 0 || inputIndex >= len(ptx.Inputs) { + return fmt.Errorf("input index %d out of range", inputIndex) + } + in := ptx.Inputs[inputIndex] + if in.WitnessUtxo == nil { + return fmt.Errorf("missing witness utxo on input %d", inputIndex) + } + if len(in.TaprootLeafScript) == 0 || in.TaprootLeafScript[0] == nil { + return fmt.Errorf("no taproot leaf script on input %d", inputIndex) + } + + tapLeaf := txscript.NewBaseTapLeaf(in.TaprootLeafScript[0].Script) + signature, err := txscript.RawTxInTapscriptSignature( + ptx.UnsignedTx, sigHashes, inputIndex, in.WitnessUtxo.Value, + in.WitnessUtxo.PkScript, tapLeaf, in.SighashType, signingKey, + ) + if err != nil { + return fmt.Errorf("failed to sign tapscript input %d: %w", inputIndex, err) + } + + leafHash := tapLeaf.TapHash() + ptx.Inputs[inputIndex].TaprootScriptSpendSig = append( + ptx.Inputs[inputIndex].TaprootScriptSpendSig, + &psbt.TaprootScriptSpendSig{ + // drop the trailing sighash byte: it is encoded separately below + Signature: signature[:64], + XOnlyPubKey: schnorr.SerializePubKey(signingKey.PubKey()), + LeafHash: leafHash[:], + SigHash: in.SighashType, + }, + ) + return nil +} + +// ExtractFinalizedTx finalizes every input and returns the hex-encoded raw tx. +// Tapscript inputs are finalized as vtxo scripts, other inputs via psbt.Finalize. +func ExtractFinalizedTx(ptx *psbt.Packet) (string, error) { + for i, in := range ptx.Inputs { + if in.WitnessUtxo == nil { + return "", fmt.Errorf("missing witness utxo on input %d", i) + } + + isTaproot := txscript.IsPayToTaproot(in.WitnessUtxo.PkScript) + if isTaproot && len(in.TaprootLeafScript) > 0 { + if err := script.FinalizeVtxoScript(ptx, i); err != nil { + return "", err + } + continue + } + + if err := psbt.Finalize(ptx, i); err != nil { + return "", fmt.Errorf("failed to finalize input %d: %w", i, err) + } + } + + extracted, err := psbt.Extract(ptx) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := extracted.Serialize(&buf); err != nil { + return "", err + } + return hex.EncodeToString(buf.Bytes()), nil +} diff --git a/pkg/ark-lib/txsigner/txsigner_test.go b/pkg/ark-lib/txsigner/txsigner_test.go new file mode 100644 index 000000000..72a9a07fc --- /dev/null +++ b/pkg/ark-lib/txsigner/txsigner_test.go @@ -0,0 +1,130 @@ +package txsigner_test + +import ( + "testing" + + arkscript "github.com/arkade-os/arkd/pkg/ark-lib/script" + "github.com/arkade-os/arkd/pkg/ark-lib/txsigner" + "github.com/arkade-os/arkd/pkg/ark-lib/txutils" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +func TestTxSigner(t *testing.T) { + t.Run("SignTapscriptInput", func(t *testing.T) { + t.Run("signs the leaf and appends the spend sig", func(t *testing.T) { + priv, err := btcec.NewPrivateKey() + require.NoError(t, err) + xonly := schnorr.SerializePubKey(priv.PubKey()) + + ptx := forfeitLikePsbt(t, priv, chainhash.Hash{0x01}) + + fetcher, err := txutils.GetPrevOutputFetcher(ptx) + require.NoError(t, err) + sigHashes := txscript.NewTxSigHashes(ptx.UnsignedTx, fetcher) + + require.NoError(t, txsigner.SignTapscriptInput(ptx, 0, priv, sigHashes)) + + require.Len(t, ptx.Inputs[0].TaprootScriptSpendSig, 1) + spend := ptx.Inputs[0].TaprootScriptSpendSig[0] + require.Len(t, spend.Signature, 64) + require.Equal(t, xonly, spend.XOnlyPubKey) + }) + + t.Run("rejects an input without a leaf script", func(t *testing.T) { + priv, err := btcec.NewPrivateKey() + require.NoError(t, err) + pkScript := taprootPkScript(t, priv.PubKey()) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{0x02}, Index: 0}, + }) + ptx, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + ptx.Inputs[0].WitnessUtxo = &wire.TxOut{Value: 100_000, PkScript: pkScript} + + fetcher, err := txutils.GetPrevOutputFetcher(ptx) + require.NoError(t, err) + sigHashes := txscript.NewTxSigHashes(ptx.UnsignedTx, fetcher) + + err = txsigner.SignTapscriptInput(ptx, 0, priv, sigHashes) + require.Error(t, err) + }) + }) + + t.Run("ExtractFinalizedTx", func(t *testing.T) { + t.Run("finalizes the tapscript input as a vtxo script", func(t *testing.T) { + priv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + ptx := forfeitLikePsbt(t, priv, chainhash.Hash{0x03}) + + fetcher, err := txutils.GetPrevOutputFetcher(ptx) + require.NoError(t, err) + sigHashes := txscript.NewTxSigHashes(ptx.UnsignedTx, fetcher) + require.NoError(t, txsigner.SignTapscriptInput(ptx, 0, priv, sigHashes)) + + txHex, err := txsigner.ExtractFinalizedTx(ptx) + require.NoError(t, err) + require.NotEmpty(t, txHex) + require.NotEmpty(t, ptx.Inputs[0].FinalScriptWitness) + }) + + t.Run("rejects an input without a witness utxo", func(t *testing.T) { + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{0x04}, Index: 0}, + }) + ptx, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + _, err = txsigner.ExtractFinalizedTx(ptx) + require.Error(t, err) + }) + }) +} + +// taprootPkScript returns a syntactically valid P2TR scriptPubKey for the given +// key. The signing primitive only needs a P2TR-shaped prevout; it does not +// verify that the script commits to the leaf, so this is sufficient for a unit +// test of SignTapscriptInput. +func taprootPkScript(t *testing.T, key *btcec.PublicKey) []byte { + t.Helper() + pkScript, err := txscript.NewScriptBuilder(). + AddOp(txscript.OP_1).AddData(schnorr.SerializePubKey(key)).Script() + require.NoError(t, err) + return pkScript +} + +// forfeitLikePsbt builds a single-input psbt spending a real ark-lib multisig +// leaf gated by the given key. +func forfeitLikePsbt(t *testing.T, priv *btcec.PrivateKey, hash chainhash.Hash) *psbt.Packet { + t.Helper() + + closure := arkscript.MultisigClosure{PubKeys: []*btcec.PublicKey{priv.PubKey()}} + leafScript, err := closure.Script() + require.NoError(t, err) + + pkScript := taprootPkScript(t, priv.PubKey()) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Hash: hash, Index: 0}}) + tx.AddTxOut(&wire.TxOut{Value: 99_000, PkScript: pkScript}) + + ptx, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + ptx.Inputs[0].WitnessUtxo = &wire.TxOut{Value: 100_000, PkScript: pkScript} + ptx.Inputs[0].SighashType = txscript.SigHashDefault + ptx.Inputs[0].TaprootLeafScript = []*psbt.TaprootTapLeafScript{{ + Script: leafScript, + LeafVersion: txscript.BaseLeafVersion, + ControlBlock: []byte{byte(txscript.BaseLeafVersion)}, + }} + return ptx +} diff --git a/pkg/arkd-signer/config/config.go b/pkg/arkd-signer/config/config.go new file mode 100644 index 000000000..68ad016d4 --- /dev/null +++ b/pkg/arkd-signer/config/config.go @@ -0,0 +1,159 @@ +package config + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/arkade-os/arkd/pkg/arkd-signer/core/application" + "github.com/btcsuite/btcd/btcec/v2" + log "github.com/sirupsen/logrus" + "github.com/spf13/viper" +) + +var ( + Port = "PORT" + LogLevel = "LOG_LEVEL" + SecretKey = "SECRET_KEY" + DeprecatedKeys = "DEPRECATED_KEYS" + + defaultPort = 6061 + defaultLogLevel = int(log.InfoLevel) +) + +type Config struct { + Port uint32 + LogLevel int + SecretKey string + DeprecatedKeys string + + // never serialized: holds the live operator key; keep it out of String()/JSON + SignerSvc application.Signer `json:"-"` +} + +func LoadConfig() (*Config, error) { + viper.SetEnvPrefix("ARKD_SIGNER") + viper.AutomaticEnv() + + viper.SetDefault(Port, defaultPort) + viper.SetDefault(LogLevel, defaultLogLevel) + + cfg := &Config{ + Port: viper.GetUint32(Port), + LogLevel: viper.GetInt(LogLevel), + SecretKey: viper.GetString(SecretKey), + DeprecatedKeys: viper.GetString(DeprecatedKeys), + } + + if err := cfg.initServices(); err != nil { + return nil, fmt.Errorf("error while initializing services: %s", err) + } + + return cfg, nil +} + +func (c *Config) initServices() error { + if c.SecretKey == "" { + return fmt.Errorf("missing signer secret key (ARKD_SIGNER_SECRET_KEY)") + } + buf, err := hex.DecodeString(c.SecretKey) + if err != nil { + return fmt.Errorf("invalid signer secret key format, must be hex") + } + if len(buf) != 32 { + return fmt.Errorf("invalid signer secret key format, must be 32 bytes") + } + prvkey, err := privKeyFromBytes(buf) + if err != nil { + return fmt.Errorf("invalid signer secret key, %w", err) + } + + deprecated, err := parseDeprecatedKeys(c.DeprecatedKeys) + if err != nil { + return err + } + + currentPubkey := prvkey.PubKey().SerializeCompressed() + for _, k := range deprecated { + if bytes.Equal(k.Key.PubKey().SerializeCompressed(), currentPubkey) { + return fmt.Errorf( + "deprecated signer key %x matches the current signer key", currentPubkey, + ) + } + } + + c.SignerSvc = application.New(prvkey, deprecated) + return nil +} + +func (c *Config) String() string { + clone := *c + clone.SecretKey = "***" + clone.DeprecatedKeys = "***" + + out, err := json.MarshalIndent(clone, "", " ") + if err != nil { + return fmt.Sprintf("error while marshalling config JSON: %s", err) + } + return string(out) +} + +// parseDeprecatedKeys parses a comma-separated list of hex-encoded private keys, +// each optionally followed by a cutoff date: "[:]". The +// cutoff date is the time after which the key is no longer accepted, 0 if unset. +func parseDeprecatedKeys(raw string) ([]application.DeprecatedSignerKey, error) { + keys := make([]application.DeprecatedSignerKey, 0) + for _, entry := range strings.Split(raw, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + + keyPart, cutoffPart, hasCutoff := strings.Cut(entry, ":") + if strings.TrimSpace(keyPart) == "" { + return nil, fmt.Errorf("invalid signer key entry, missing hex key: %s", entry) + } + + buf, err := hex.DecodeString(keyPart) + if err != nil { + return nil, fmt.Errorf("invalid signer key format, must be hex: %s", keyPart) + } + if len(buf) != 32 { + return nil, fmt.Errorf("invalid signer key format") + } + key, err := privKeyFromBytes(buf) + if err != nil { + return nil, fmt.Errorf("invalid signer key %s, %w", keyPart, err) + } + + var cutoffDate int64 + if hasCutoff { + cutoff, err := strconv.ParseInt(cutoffPart, 10, 64) + if err != nil || cutoff < 0 { + return nil, fmt.Errorf( + "invalid cutoff date, must be a positive unix timestamp: %s", entry, + ) + } + cutoffDate = cutoff + } + + keys = append(keys, application.DeprecatedSignerKey{Key: key, CutoffDate: cutoffDate}) + } + return keys, nil +} + +// privKeyFromBytes rejects what btcec.PrivKeyFromBytes silently accepts. That +// helper reduces the input mod N and cannot fail, so an all-zero key yields a +// pubkey at infinity and an out-of-range one becomes a different key than the +// operator configured. Either way the signer boots and every signature it +// produces is invalid, so fail at config load instead. +func privKeyFromBytes(buf []byte) (*btcec.PrivateKey, error) { + var scalar btcec.ModNScalar + if overflow := scalar.SetByteSlice(buf); overflow || scalar.IsZero() { + return nil, fmt.Errorf("must be a scalar in [1, N-1] for secp256k1") + } + return btcec.PrivKeyFromScalar(&scalar), nil +} diff --git a/pkg/arkd-signer/config/config_test.go b/pkg/arkd-signer/config/config_test.go new file mode 100644 index 000000000..edee3a6d8 --- /dev/null +++ b/pkg/arkd-signer/config/config_test.go @@ -0,0 +1,70 @@ +package config_test + +import ( + "testing" + + "github.com/arkade-os/arkd/pkg/arkd-signer/config" + "github.com/stretchr/testify/require" +) + +func TestLoadConfigRequiresSecretKey(t *testing.T) { + t.Setenv("ARKD_SIGNER_SECRET_KEY", "") + _, err := config.LoadConfig() + require.Error(t, err) +} + +func TestLoadConfigParsesSecretKey(t *testing.T) { + t.Setenv("ARKD_SIGNER_SECRET_KEY", + "afcd3fa10f82a05fddc9574fdb13b3991b568e89cc39a72ba4401df8abef35f0") + cfg, err := config.LoadConfig() + require.NoError(t, err) + require.NotNil(t, cfg.SignerSvc) + require.EqualValues(t, 6061, cfg.Port) +} + +// btcec.PrivKeyFromBytes reduces its input mod N and cannot fail, so without an +// explicit scalar check these all load a signer that produces invalid +// signatures for the rest of the process lifetime. +func TestLoadConfigRejectsInvalidScalars(t *testing.T) { + const curveN = "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141" + + for _, tc := range []struct { + name string + key string + }{ + {"all zero", "0000000000000000000000000000000000000000000000000000000000000000"}, + {"curve order", curveN}, + {"above curve order", "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("ARKD_SIGNER_SECRET_KEY", tc.key) + _, err := config.LoadConfig() + require.ErrorContains(t, err, "scalar") + }) + } + + t.Run("deprecated key is checked too", func(t *testing.T) { + t.Setenv("ARKD_SIGNER_SECRET_KEY", + "afcd3fa10f82a05fddc9574fdb13b3991b568e89cc39a72ba4401df8abef35f0") + t.Setenv("ARKD_SIGNER_DEPRECATED_KEYS", + "0000000000000000000000000000000000000000000000000000000000000000") + _, err := config.LoadConfig() + require.ErrorContains(t, err, "scalar") + }) +} + +func TestConfigStringRedactsSecrets(t *testing.T) { + secretKey := "afcd3fa10f82a05fddc9574fdb13b3991b568e89cc39a72ba4401df8abef35f0" + deprecatedKey := "1111111111111111111111111111111111111111111111111111111111111111" + t.Setenv("ARKD_SIGNER_SECRET_KEY", secretKey) + t.Setenv("ARKD_SIGNER_DEPRECATED_KEYS", deprecatedKey) + + cfg, err := config.LoadConfig() + require.NoError(t, err) + + out := cfg.String() + require.NotContains(t, out, secretKey) + require.NotContains(t, out, deprecatedKey) + require.Contains(t, out, "***") + require.Contains(t, out, "6061") // non-sensitive field preserved +} diff --git a/pkg/arkd-signer/core/application/signer.go b/pkg/arkd-signer/core/application/signer.go new file mode 100644 index 000000000..f00f560b2 --- /dev/null +++ b/pkg/arkd-signer/core/application/signer.go @@ -0,0 +1,182 @@ +package application + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "slices" + "strings" + + "github.com/arkade-os/arkd/pkg/ark-lib/script" + "github.com/arkade-os/arkd/pkg/ark-lib/txsigner" + "github.com/arkade-os/arkd/pkg/ark-lib/txutils" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" +) + +// DeprecatedSignerKey is an operator key still accepted for signing but scheduled +// for retirement. CutoffDate is the unix timestamp after which clients should +// stop locking new funds to it (0 = unset); it is informational and not enforced +// at signing time, matching arkd-wallet's behaviour. +type DeprecatedSignerKey struct { + Key *btcec.PrivateKey + CutoffDate int64 +} + +// DeprecatedSignerPubkey is the public view of a DeprecatedSignerKey. +type DeprecatedSignerPubkey struct { + Pubkey string + CutoffDate int64 +} + +// Signer is the operator signing service: it holds an injected current key plus +// any deprecated keys, and signs tapscript-path PSBT inputs. It has no chain +// access and requires complete PSBTs (every input must carry a WitnessUtxo). +type Signer interface { + GetPubkey(ctx context.Context) (string, error) + GetDeprecatedPubkeys(ctx context.Context) ([]DeprecatedSignerPubkey, error) + IsReady(ctx context.Context) bool + SignTransaction(ctx context.Context, partialTx string, extractRawTx bool) (string, error) + SignTransactionTapscript(ctx context.Context, partialTx string, inputIndexes []int) (string, error) +} + +type signer struct { + key *btcec.PrivateKey + deprecated []DeprecatedSignerKey +} + +// New returns a Signer backed by the given current operator key and optional +// deprecated keys. +func New(key *btcec.PrivateKey, deprecated []DeprecatedSignerKey) Signer { + return &signer{key: key, deprecated: deprecated} +} + +func (s *signer) GetPubkey(_ context.Context) (string, error) { + if s.key == nil { + return "", fmt.Errorf("signer key not loaded") + } + return hex.EncodeToString(s.key.PubKey().SerializeCompressed()), nil +} + +func (s *signer) GetDeprecatedPubkeys(_ context.Context) ([]DeprecatedSignerPubkey, error) { + pubkeys := make([]DeprecatedSignerPubkey, 0, len(s.deprecated)) + for _, k := range s.deprecated { + pubkeys = append(pubkeys, DeprecatedSignerPubkey{ + Pubkey: hex.EncodeToString(k.Key.PubKey().SerializeCompressed()), + CutoffDate: k.CutoffDate, + }) + } + return pubkeys, nil +} + +func (s *signer) IsReady(_ context.Context) bool { + return s.key != nil +} + +func (s *signer) SignTransaction( + ctx context.Context, partialTx string, extractRawTx bool, +) (string, error) { + return s.sign(ctx, partialTx, extractRawTx, nil) +} + +func (s *signer) SignTransactionTapscript( + ctx context.Context, partialTx string, inputIndexes []int, +) (string, error) { + return s.sign(ctx, partialTx, false, inputIndexes) +} + +func (s *signer) sign( + _ context.Context, partialTx string, extractRawTx bool, inputIndexes []int, +) (string, error) { + if s.key == nil { + return "", fmt.Errorf("signer key not loaded") + } + + ptx, err := psbt.NewFromRawBytes(strings.NewReader(partialTx), true) + if err != nil { + return "", err + } + + for _, idx := range inputIndexes { + if idx < 0 || idx >= len(ptx.Inputs) { + return "", fmt.Errorf("input index %d out of range [0,%d)", idx, len(ptx.Inputs)) + } + } + + fetcher, err := txutils.GetPrevOutputFetcher(ptx) + if err != nil { + return "", err + } + sigHashes := txscript.NewTxSigHashes(ptx.UnsignedTx, fetcher) + + for i, in := range ptx.Inputs { + // skip P2A anchor inputs + if bytes.Equal(in.WitnessUtxo.PkScript, txutils.ANCHOR_PKSCRIPT) { + continue + } + // skip inputs not selected when a subset was requested + if len(inputIndexes) > 0 && !slices.Contains(inputIndexes, i) { + continue + } + // pure signer signs taproot script-path inputs only + if !txscript.IsPayToTaproot(in.WitnessUtxo.PkScript) { + continue + } + if len(in.TaprootLeafScript) == 0 { + continue + } + key := s.signerKeyForLeaf(in.TaprootLeafScript[0].Script) + if err := txsigner.SignTapscriptInput(ptx, i, key, sigHashes); err != nil { + return "", err + } + } + + if extractRawTx { + return txsigner.ExtractFinalizedTx(ptx) + } + return ptx.B64Encode() +} + +// signerKeyForLeaf returns the deprecated signer key whose pubkey appears in the +// leaf's multisig closure, or the current key. The cutoff date is not enforced +// here: the wallet always signs with the matching key whether or not the cutoff +// has passed, and arkd-signer preserves that behaviour. +func (s *signer) signerKeyForLeaf(leafScript []byte) *btcec.PrivateKey { + if len(s.deprecated) == 0 { + return s.key + } + + closure, err := script.DecodeClosure(leafScript) + if err != nil { + return s.key + } + + var leafKeys []*btcec.PublicKey + switch c := closure.(type) { + case *script.MultisigClosure: + leafKeys = c.PubKeys + case *script.CSVMultisigClosure: + leafKeys = c.PubKeys + case *script.CLTVMultisigClosure: + leafKeys = c.PubKeys + case *script.ConditionMultisigClosure: + leafKeys = c.PubKeys + case *script.ConditionCSVMultisigClosure: + leafKeys = c.PubKeys + default: + return s.key + } + + for _, k := range s.deprecated { + want := schnorr.SerializePubKey(k.Key.PubKey()) + for _, pubkey := range leafKeys { + if bytes.Equal(schnorr.SerializePubKey(pubkey), want) { + return k.Key + } + } + } + return s.key +} diff --git a/pkg/arkd-signer/core/application/signer_test.go b/pkg/arkd-signer/core/application/signer_test.go new file mode 100644 index 000000000..4f1493311 --- /dev/null +++ b/pkg/arkd-signer/core/application/signer_test.go @@ -0,0 +1,235 @@ +package application_test + +import ( + "context" + "encoding/hex" + "strings" + "testing" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + arkscript "github.com/arkade-os/arkd/pkg/ark-lib/script" + "github.com/arkade-os/arkd/pkg/arkd-signer/core/application" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +func TestGetPubkey(t *testing.T) { + priv, err := btcec.NewPrivateKey() + require.NoError(t, err) + svc := application.New(priv, nil) + + got, err := svc.GetPubkey(context.Background()) + require.NoError(t, err) + require.Equal(t, hex.EncodeToString(priv.PubKey().SerializeCompressed()), got) + require.True(t, svc.IsReady(context.Background())) +} + +func TestGetDeprecatedPubkeys(t *testing.T) { + current, err := btcec.NewPrivateKey() + require.NoError(t, err) + old, err := btcec.NewPrivateKey() + require.NoError(t, err) + + svc := application.New(current, []application.DeprecatedSignerKey{ + {Key: old, CutoffDate: 1234}, + }) + + deprecated, err := svc.GetDeprecatedPubkeys(context.Background()) + require.NoError(t, err) + require.Len(t, deprecated, 1) + require.Equal(t, + hex.EncodeToString(old.PubKey().SerializeCompressed()), deprecated[0].Pubkey) + require.EqualValues(t, 1234, deprecated[0].CutoffDate) +} + +// TestSignTransactionTapscriptSelectsKeyByLeaf mirrors arkd-wallet's +// signer_keys_test: the signer signs with the deprecated key whose pubkey is in +// the leaf, otherwise with the current key. +func TestSignTransactionTapscriptSelectsKeyByLeaf(t *testing.T) { + owner, err := btcec.NewPrivateKey() + require.NoError(t, err) + current, err := btcec.NewPrivateKey() + require.NoError(t, err) + old, err := btcec.NewPrivateKey() + require.NoError(t, err) + + tests := []struct { + name string + svc application.Signer + leafSigner *btcec.PublicKey + wantSigner *btcec.PublicKey + }{ + { + name: "current key", + svc: application.New(current, nil), + leafSigner: current.PubKey(), + wantSigner: current.PubKey(), + }, + { + name: "deprecated key by leaf", + svc: application.New(current, []application.DeprecatedSignerKey{ + {Key: old, CutoffDate: 0}, + }), + leafSigner: old.PubKey(), + wantSigner: old.PubKey(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b64 := signablePacket(t, owner, multisig(owner, tt.leafSigner)) + signed, err := tt.svc.SignTransactionTapscript(context.Background(), b64, nil) + require.NoError(t, err) + + out, err := psbt.NewFromRawBytes(strings.NewReader(signed), true) + require.NoError(t, err) + require.Len(t, out.Inputs[0].TaprootScriptSpendSig, 1) + require.Equal(t, + hex.EncodeToString(schnorr.SerializePubKey(tt.wantSigner)), + hex.EncodeToString(out.Inputs[0].TaprootScriptSpendSig[0].XOnlyPubKey), + ) + }) + } + + // Key selection reads PubKeys off the decoded closure, so every closure type + // the switch handles has to be exercised: an unhandled type falls through to + // the current key and silently produces a signature the leaf cannot satisfy. + // CSVMultisigClosure and ConditionCSVMultisigClosure are newly handled here + // and were the two the old arkd-wallet implementation missed. + t.Run("deprecated key is selected for every closure type", func(t *testing.T) { + csv := arklib.RelativeLocktime{Type: arklib.LocktimeTypeBlock, Value: 144} + condition := []byte{txscript.OP_TRUE} + + for _, tc := range []struct { + name string + closure arkscript.Closure + }{ + { + name: "multisig", + closure: multisig(owner, old.PubKey()), + }, + { + name: "csv multisig", + closure: &arkscript.CSVMultisigClosure{ + MultisigClosure: *multisig(owner, old.PubKey()), + Locktime: csv, + }, + }, + { + name: "cltv multisig", + closure: &arkscript.CLTVMultisigClosure{ + MultisigClosure: *multisig(owner, old.PubKey()), + Locktime: arklib.AbsoluteLocktime(500_000), + }, + }, + { + name: "condition multisig", + closure: &arkscript.ConditionMultisigClosure{ + MultisigClosure: *multisig(owner, old.PubKey()), + Condition: condition, + }, + }, + { + name: "condition csv multisig", + closure: &arkscript.ConditionCSVMultisigClosure{ + CSVMultisigClosure: arkscript.CSVMultisigClosure{ + MultisigClosure: *multisig(owner, old.PubKey()), + Locktime: csv, + }, + Condition: condition, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + svc := application.New(current, []application.DeprecatedSignerKey{ + {Key: old, CutoffDate: 0}, + }) + + b64 := signablePacket(t, owner, tc.closure) + signed, err := svc.SignTransactionTapscript(context.Background(), b64, nil) + require.NoError(t, err) + + out, err := psbt.NewFromRawBytes(strings.NewReader(signed), true) + require.NoError(t, err) + require.Len(t, out.Inputs[0].TaprootScriptSpendSig, 1) + require.Equal(t, + hex.EncodeToString(schnorr.SerializePubKey(old.PubKey())), + hex.EncodeToString(out.Inputs[0].TaprootScriptSpendSig[0].XOnlyPubKey), + "expected the deprecated key named in the leaf, got the current key", + ) + }) + } + }) +} + +func TestSignRejectsMissingWitnessUtxo(t *testing.T) { + priv, err := btcec.NewPrivateKey() + require.NoError(t, err) + svc := application.New(priv, nil) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{0x02}, Index: 0}, + }) + ptx, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + b64, err := ptx.B64Encode() + require.NoError(t, err) + + _, err = svc.SignTransactionTapscript(context.Background(), b64, nil) + require.Error(t, err) +} + +// multisig builds the owner + leafSigner multisig closure that every other +// closure type here embeds. +func multisig(owner *btcec.PrivateKey, leafSigner *btcec.PublicKey) *arkscript.MultisigClosure { + return &arkscript.MultisigClosure{ + PubKeys: []*btcec.PublicKey{owner.PubKey(), leafSigner}, + Type: arkscript.MultisigTypeChecksig, + } +} + +// signablePacket builds a single-input PSBT spending a taproot output via the +// given closure, returning the base64 PSBT. +func signablePacket(t *testing.T, owner *btcec.PrivateKey, closure arkscript.Closure) string { + t.Helper() + leaf, err := closure.Script() + require.NoError(t, err) + + tapLeaf := txscript.NewBaseTapLeaf(leaf) + tapTree := txscript.AssembleTaprootScriptTree(tapLeaf) + rootHash := tapTree.RootNode.TapHash() + outputKey := txscript.ComputeTaprootOutputKey(owner.PubKey(), rootHash[:]) + pkScript, err := txscript.NewScriptBuilder(). + AddOp(txscript.OP_1).AddData(schnorr.SerializePubKey(outputKey)).Script() + require.NoError(t, err) + + ctrlBlock := tapTree.LeafMerkleProofs[0].ToControlBlock(owner.PubKey()) + ctrlBytes, err := ctrlBlock.ToBytes() + require.NoError(t, err) + + prevHash, err := chainhash.NewHashFromStr( + "0000000000000000000000000000000000000000000000000000000000000001") + require.NoError(t, err) + prevOut := wire.OutPoint{Hash: *prevHash, Index: 0} + unsigned := wire.NewMsgTx(2) + unsigned.AddTxIn(wire.NewTxIn(&prevOut, nil, nil)) + unsigned.AddTxOut(&wire.TxOut{Value: 900, PkScript: pkScript}) + + packet, err := psbt.NewFromUnsignedTx(unsigned) + require.NoError(t, err) + packet.Inputs[0].WitnessUtxo = &wire.TxOut{Value: 1000, PkScript: pkScript} + packet.Inputs[0].TaprootLeafScript = []*psbt.TaprootTapLeafScript{{ + ControlBlock: ctrlBytes, + Script: leaf, + LeafVersion: txscript.BaseLeafVersion, + }} + b64, err := packet.B64Encode() + require.NoError(t, err) + return b64 +} diff --git a/pkg/arkd-signer/go.mod b/pkg/arkd-signer/go.mod new file mode 100644 index 000000000..3106aeb37 --- /dev/null +++ b/pkg/arkd-signer/go.mod @@ -0,0 +1,59 @@ +module github.com/arkade-os/arkd/pkg/arkd-signer + +go 1.26.5 + +replace github.com/btcsuite/btcd/btcec/v2 => github.com/btcsuite/btcd/btcec/v2 v2.3.3 + +replace github.com/arkade-os/arkd/pkg/ark-lib => ../ark-lib + +replace github.com/arkade-os/arkd/api-spec => ../../api-spec + +require ( + github.com/arkade-os/arkd/api-spec v0.0.0-00010101000000-000000000000 + github.com/arkade-os/arkd/pkg/ark-lib v0.0.0-00010101000000-000000000000 + github.com/btcsuite/btcd v0.24.3-0.20240921052913-67b8efd3ba53 + github.com/btcsuite/btcd/btcec/v2 v2.3.4 + github.com/btcsuite/btcd/btcutil/psbt v1.1.9 + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 + github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 + github.com/meshapi/grpc-api-gateway v0.1.0 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/viper v1.20.1 + github.com/stretchr/testify v1.11.1 + golang.org/x/net v0.56.0 + google.golang.org/grpc v1.82.1 +) + +require ( + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect +) + +require ( + github.com/btcsuite/btcd/btcutil v1.1.5 // indirect + github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/julienschmidt/httprouter v1.3.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect + google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/pkg/arkd-signer/go.sum b/pkg/arkd-signer/go.sum new file mode 100644 index 000000000..cb47f197c --- /dev/null +++ b/pkg/arkd-signer/go.sum @@ -0,0 +1,296 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd v0.24.3-0.20240921052913-67b8efd3ba53 h1:XOZ/wRGHkKv0AqxfDks5IkzaQ1Ge6fq322ZOOG5VIkU= +github.com/btcsuite/btcd v0.24.3-0.20240921052913-67b8efd3ba53/go.mod h1:zHK7t7sw8XbsCkD64WePHE3r3k9/XoGAcf6mXV14c64= +github.com/btcsuite/btcd/btcec/v2 v2.3.3 h1:6+iXlDKE8RMtKsvK0gshlXIuPbyWM/h84Ensb7o3sC0= +github.com/btcsuite/btcd/btcec/v2 v2.3.3/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/btcutil/psbt v1.1.9 h1:UmfOIiWMZcVMOLaN+lxbbLSuoINGS1WmK1TZNI0b4yk= +github.com/btcsuite/btcd/btcutil/psbt v1.1.9/go.mod h1:ehBEvU91lxSlXtA+zZz3iFYx7Yq9eqnKx4/kSrnsvMY= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/meshapi/grpc-api-gateway v0.1.0 h1:0rGp4qZQ6T9Ud0KfzdHYsEju4AX/Q3AQOU7unoBLssY= +github.com/meshapi/grpc-api-gateway v0.1.0/go.mod h1:lkFQUbwq7i/JqEPZMzCIRskp9Jb7tm1uLODwsOdw064= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= +google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/arkd-signer/interface/grpc/handlers/healthservice.go b/pkg/arkd-signer/interface/grpc/handlers/healthservice.go new file mode 100644 index 000000000..5999bfadc --- /dev/null +++ b/pkg/arkd-signer/interface/grpc/handlers/healthservice.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "context" + "time" + + "github.com/arkade-os/arkd/pkg/arkd-signer/core/application" + grpchealth "google.golang.org/grpc/health/grpc_health_v1" +) + +const ( + healthServiceName = "arkd-signer" + + // The signer has no readiness state change to subscribe to, so Watch polls + // rather than being woken. + watchPollInterval = time.Second +) + +type healthHandler struct { + signer application.Signer +} + +func NewHealthHandler(signer application.Signer) grpchealth.HealthServer { + return &healthHandler{signer: signer} +} + +func (h *healthHandler) Check( + ctx context.Context, + _ *grpchealth.HealthCheckRequest, +) (*grpchealth.HealthCheckResponse, error) { + return &grpchealth.HealthCheckResponse{Status: h.status(ctx)}, nil +} + +// Watch streams the current status and then every change to it. Returning +// immediately instead, as this did, closes the stream on a watching client +// without ever telling it anything. +func (h *healthHandler) Watch( + _ *grpchealth.HealthCheckRequest, + srv grpchealth.Health_WatchServer, +) error { + ctx := srv.Context() + + last := h.status(ctx) + if err := srv.Send(&grpchealth.HealthCheckResponse{Status: last}); err != nil { + return err + } + + ticker := time.NewTicker(watchPollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + current := h.status(ctx) + if current == last { + continue + } + last = current + if err := srv.Send(&grpchealth.HealthCheckResponse{Status: current}); err != nil { + return err + } + } + } +} + +func (h *healthHandler) List( + ctx context.Context, + _ *grpchealth.HealthListRequest, +) (*grpchealth.HealthListResponse, error) { + return &grpchealth.HealthListResponse{ + Statuses: map[string]*grpchealth.HealthCheckResponse{ + healthServiceName: {Status: h.status(ctx)}, + }, + }, nil +} + +// status reports NOT_SERVING until the signing key is usable, so an orchestrator +// waiting on the health endpoint can tell a ready signer from one that came up +// without a usable key. Reporting SERVING unconditionally makes a misconfigured +// signer indistinguishable from a working one until the first signing request. +func (h *healthHandler) status(ctx context.Context) grpchealth.HealthCheckResponse_ServingStatus { + if h.signer == nil || !h.signer.IsReady(ctx) { + return grpchealth.HealthCheckResponse_NOT_SERVING + } + return grpchealth.HealthCheckResponse_SERVING +} diff --git a/pkg/arkd-wallet/interface/grpc/handlers/signer_handler.go b/pkg/arkd-signer/interface/grpc/handlers/signer_handler.go similarity index 60% rename from pkg/arkd-wallet/interface/grpc/handlers/signer_handler.go rename to pkg/arkd-signer/interface/grpc/handlers/signer_handler.go index 7f44211bc..9e994d2a1 100644 --- a/pkg/arkd-wallet/interface/grpc/handlers/signer_handler.go +++ b/pkg/arkd-signer/interface/grpc/handlers/signer_handler.go @@ -4,35 +4,31 @@ import ( "context" signerv1 "github.com/arkade-os/arkd/api-spec/protobuf/gen/signer/v1" - application "github.com/arkade-os/arkd/pkg/arkd-wallet/core/application" + "github.com/arkade-os/arkd/pkg/arkd-signer/core/application" ) type signerHandler struct { - wallet application.WalletService - scanner application.BlockchainScanner + signer application.Signer } -func NewSignerHandler(walletSvc application.WalletService) signerv1.SignerServiceServer { - return &signerHandler{wallet: walletSvc} +func NewSignerHandler(signer application.Signer) signerv1.SignerServiceServer { + return &signerHandler{signer: signer} } func (h *signerHandler) GetStatus( ctx context.Context, _ *signerv1.GetStatusRequest, ) (*signerv1.GetStatusResponse, error) { - _, err := h.wallet.GetSignerPubkey(ctx) - return &signerv1.GetStatusResponse{ - Ready: err == nil, - }, nil + return &signerv1.GetStatusResponse{Ready: h.signer.IsReady(ctx)}, nil } func (h *signerHandler) GetPubkey( - ctx context.Context, req *signerv1.GetPubkeyRequest, + ctx context.Context, _ *signerv1.GetPubkeyRequest, ) (*signerv1.GetPubkeyResponse, error) { - pubkey, err := h.wallet.GetSignerPubkey(ctx) + pubkey, err := h.signer.GetPubkey(ctx) if err != nil { return nil, err } - deprecated, err := h.wallet.GetDeprecatedSignerPubkeys(ctx) + deprecated, err := h.signer.GetDeprecatedPubkeys(ctx) if err != nil { return nil, err } @@ -43,14 +39,16 @@ func (h *signerHandler) GetPubkey( CutoffDate: d.CutoffDate, }) } - return &signerv1.GetPubkeyResponse{Pubkey: pubkey, DeprecatedSigners: deprecatedSigners}, nil + return &signerv1.GetPubkeyResponse{ + Pubkey: pubkey, + DeprecatedSigners: deprecatedSigners, + }, nil } func (h *signerHandler) SignTransaction( ctx context.Context, req *signerv1.SignTransactionRequest, ) (*signerv1.SignTransactionResponse, error) { - signMode := application.SignModeSigner - tx, err := h.wallet.SignTransaction(ctx, signMode, req.PartialTx, req.ExtractRawTx, nil) + tx, err := h.signer.SignTransaction(ctx, req.GetPartialTx(), req.GetExtractRawTx()) if err != nil { return nil, err } @@ -60,12 +58,11 @@ func (h *signerHandler) SignTransaction( func (h *signerHandler) SignTransactionTapscript( ctx context.Context, req *signerv1.SignTransactionTapscriptRequest, ) (*signerv1.SignTransactionTapscriptResponse, error) { - signMode := application.SignModeSigner inIndexes := make([]int, 0, len(req.GetInputIndexes())) for _, v := range req.GetInputIndexes() { inIndexes = append(inIndexes, int(v)) } - tx, err := h.wallet.SignTransaction(ctx, signMode, req.GetPartialTx(), false, inIndexes) + tx, err := h.signer.SignTransactionTapscript(ctx, req.GetPartialTx(), inIndexes) if err != nil { return nil, err } diff --git a/pkg/arkd-signer/interface/grpc/handlers/signer_handler_test.go b/pkg/arkd-signer/interface/grpc/handlers/signer_handler_test.go new file mode 100644 index 000000000..549a2fb4d --- /dev/null +++ b/pkg/arkd-signer/interface/grpc/handlers/signer_handler_test.go @@ -0,0 +1,349 @@ +package handlers_test + +import ( + "context" + "encoding/hex" + "strings" + "sync" + "testing" + "time" + + signerv1 "github.com/arkade-os/arkd/api-spec/protobuf/gen/signer/v1" + arkscript "github.com/arkade-os/arkd/pkg/ark-lib/script" + "github.com/arkade-os/arkd/pkg/ark-lib/txutils" + "github.com/arkade-os/arkd/pkg/arkd-signer/core/application" + "github.com/arkade-os/arkd/pkg/arkd-signer/interface/grpc/handlers" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + grpchealth "google.golang.org/grpc/health/grpc_health_v1" +) + +func TestSignerHandlerStatusAndPubkey(t *testing.T) { + priv, err := btcec.NewPrivateKey() + require.NoError(t, err) + h := handlers.NewSignerHandler(application.New(priv, nil)) + + status, err := h.GetStatus(context.Background(), &signerv1.GetStatusRequest{}) + require.NoError(t, err) + require.True(t, status.GetReady()) + + pub, err := h.GetPubkey(context.Background(), &signerv1.GetPubkeyRequest{}) + require.NoError(t, err) + require.Equal(t, + hex.EncodeToString(priv.PubKey().SerializeCompressed()), + pub.GetPubkey(), + ) +} + +// The health endpoint is what an orchestrator gates readiness on, so a signer +// that came up without a usable key has to report NOT_SERVING rather than be +// indistinguishable from a working one until the first signing request. +func TestHealthHandlerReflectsSignerReadiness(t *testing.T) { + priv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + for _, tc := range []struct { + name string + signer application.Signer + want grpchealth.HealthCheckResponse_ServingStatus + }{ + { + name: "ready signer serves", + signer: application.New(priv, nil), + want: grpchealth.HealthCheckResponse_SERVING, + }, + { + name: "signer without a key does not serve", + signer: application.New(nil, nil), + want: grpchealth.HealthCheckResponse_NOT_SERVING, + }, + { + name: "missing signer does not serve", + signer: nil, + want: grpchealth.HealthCheckResponse_NOT_SERVING, + }, + } { + t.Run(tc.name, func(t *testing.T) { + h := handlers.NewHealthHandler(tc.signer) + + check, err := h.Check( + context.Background(), &grpchealth.HealthCheckRequest{}, + ) + require.NoError(t, err) + require.Equal(t, tc.want, check.GetStatus()) + + list, err := h.List(context.Background(), &grpchealth.HealthListRequest{}) + require.NoError(t, err) + require.Equal(t, tc.want, list.GetStatuses()["arkd-signer"].GetStatus()) + + // Watch must report the status before it blocks, otherwise a + // watching client learns nothing before the stream closes. + ctx, cancel := context.WithCancel(context.Background()) + stream := &fakeWatchServer{ctx: ctx} + done := make(chan error, 1) + go func() { done <- h.Watch(&grpchealth.HealthCheckRequest{}, stream) }() + + require.Eventually(t, func() bool { + return len(stream.sent()) > 0 + }, 2*time.Second, 10*time.Millisecond) + require.Equal(t, tc.want, stream.sent()[0].GetStatus()) + + cancel() + select { + case err := <-done: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(2 * time.Second): + t.Fatal("Watch did not return after the stream context was cancelled") + } + }) + } +} + +// The handler converts proto uint32 indexes to the []int the signer expects. +// Nothing below the handler re-checks that mapping, so a wrong conversion would +// sign an input the caller never asked to have signed. +func TestSignerHandlerSignTransactionTapscript(t *testing.T) { + owner, err := btcec.NewPrivateKey() + require.NoError(t, err) + operator, err := btcec.NewPrivateKey() + require.NoError(t, err) + + h := handlers.NewSignerHandler(application.New(operator, nil)) + operatorXOnly := hex.EncodeToString(schnorr.SerializePubKey(operator.PubKey())) + + t.Run("input indexes select exactly one input", func(t *testing.T) { + b64 := multiInputPacket(t, owner, operator.PubKey(), 3) + + res, err := h.SignTransactionTapscript( + context.Background(), &signerv1.SignTransactionTapscriptRequest{ + PartialTx: b64, + InputIndexes: []int32{1}, + }, + ) + require.NoError(t, err) + + out, err := psbt.NewFromRawBytes(strings.NewReader(res.GetSignedTx()), true) + require.NoError(t, err) + require.Empty(t, out.Inputs[0].TaprootScriptSpendSig, "input 0 was not requested") + require.Len(t, out.Inputs[1].TaprootScriptSpendSig, 1) + require.Empty(t, out.Inputs[2].TaprootScriptSpendSig, "input 2 was not requested") + require.Equal(t, + operatorXOnly, + hex.EncodeToString(out.Inputs[1].TaprootScriptSpendSig[0].XOnlyPubKey), + ) + }) + + t.Run("empty index list signs every eligible input", func(t *testing.T) { + b64 := multiInputPacket(t, owner, operator.PubKey(), 2) + + res, err := h.SignTransactionTapscript( + context.Background(), &signerv1.SignTransactionTapscriptRequest{PartialTx: b64}, + ) + require.NoError(t, err) + + out, err := psbt.NewFromRawBytes(strings.NewReader(res.GetSignedTx()), true) + require.NoError(t, err) + for i := range out.Inputs { + require.Lenf(t, out.Inputs[i].TaprootScriptSpendSig, 1, "input %d", i) + } + }) + + t.Run("out of range index is rejected", func(t *testing.T) { + b64 := multiInputPacket(t, owner, operator.PubKey(), 2) + + // int32 on the wire, so a negative index is representable and has to be + // rejected rather than indexed into the slice. + for _, idx := range []int32{2, 99, -1} { + _, err := h.SignTransactionTapscript( + context.Background(), &signerv1.SignTransactionTapscriptRequest{ + PartialTx: b64, + InputIndexes: []int32{idx}, + }, + ) + require.ErrorContainsf(t, err, "out of range", "index %d", idx) + } + }) + + t.Run("anchor inputs are skipped, not signed", func(t *testing.T) { + b64 := packetWithAnchor(t, owner, operator.PubKey()) + + res, err := h.SignTransactionTapscript( + context.Background(), &signerv1.SignTransactionTapscriptRequest{PartialTx: b64}, + ) + require.NoError(t, err) + + out, err := psbt.NewFromRawBytes(strings.NewReader(res.GetSignedTx()), true) + require.NoError(t, err) + require.Len(t, out.Inputs[0].TaprootScriptSpendSig, 1, "the arkade input signs") + require.Empty(t, out.Inputs[1].TaprootScriptSpendSig, "the anchor input must not sign") + }) + + t.Run("malformed psbt is rejected", func(t *testing.T) { + _, err := h.SignTransactionTapscript( + context.Background(), + &signerv1.SignTransactionTapscriptRequest{PartialTx: "not-a-psbt"}, + ) + require.Error(t, err) + }) +} + +func TestSignerHandlerSignTransaction(t *testing.T) { + owner, err := btcec.NewPrivateKey() + require.NoError(t, err) + operator, err := btcec.NewPrivateKey() + require.NoError(t, err) + + h := handlers.NewSignerHandler(application.New(operator, nil)) + + t.Run("signs without extracting", func(t *testing.T) { + b64 := multiInputPacket(t, owner, operator.PubKey(), 1) + + res, err := h.SignTransaction(context.Background(), &signerv1.SignTransactionRequest{ + PartialTx: b64, + ExtractRawTx: false, + }) + require.NoError(t, err) + + out, err := psbt.NewFromRawBytes(strings.NewReader(res.GetSignedTx()), true) + require.NoError(t, err) + require.Len(t, out.Inputs[0].TaprootScriptSpendSig, 1) + }) + + // The multisig leaf still needs the owner's signature, so extraction has to + // fail rather than hand back a half-witnessed transaction. + t.Run("extract on an incomplete witness fails", func(t *testing.T) { + b64 := multiInputPacket(t, owner, operator.PubKey(), 1) + + _, err := h.SignTransaction(context.Background(), &signerv1.SignTransactionRequest{ + PartialTx: b64, + ExtractRawTx: true, + }) + require.Error(t, err) + }) +} + +// fakeWatchServer captures what Watch streams, standing in for the generated +// grpchealth.Health_WatchServer. +type fakeWatchServer struct { + grpc.ServerStream + ctx context.Context + + mu sync.Mutex + msgs []*grpchealth.HealthCheckResponse +} + +func (f *fakeWatchServer) Context() context.Context { return f.ctx } + +func (f *fakeWatchServer) Send(m *grpchealth.HealthCheckResponse) error { + f.mu.Lock() + defer f.mu.Unlock() + f.msgs = append(f.msgs, m) + return nil +} + +func (f *fakeWatchServer) sent() []*grpchealth.HealthCheckResponse { + f.mu.Lock() + defer f.mu.Unlock() + return append([]*grpchealth.HealthCheckResponse(nil), f.msgs...) +} + +// multiInputPacket builds a PSBT with n taproot script-path inputs, each +// spendable via an owner+operator multisig leaf. +func multiInputPacket( + t *testing.T, owner *btcec.PrivateKey, operator *btcec.PublicKey, n int, +) string { + t.Helper() + leaf, pkScript, ctrlBytes := arkadeLeaf(t, owner, operator) + + unsigned := wire.NewMsgTx(2) + for i := range n { + unsigned.AddTxIn(wire.NewTxIn(testOutpoint(t, uint32(i)), nil, nil)) + } + unsigned.AddTxOut(&wire.TxOut{Value: 900, PkScript: pkScript}) + + packet, err := psbt.NewFromUnsignedTx(unsigned) + require.NoError(t, err) + for i := range n { + packet.Inputs[i].WitnessUtxo = &wire.TxOut{Value: 1000, PkScript: pkScript} + packet.Inputs[i].TaprootLeafScript = []*psbt.TaprootTapLeafScript{{ + ControlBlock: ctrlBytes, + Script: leaf, + LeafVersion: txscript.BaseLeafVersion, + }} + } + + b64, err := packet.B64Encode() + require.NoError(t, err) + return b64 +} + +// packetWithAnchor builds a PSBT whose second input is a P2A anchor, which the +// signer must skip rather than attempt to sign. +func packetWithAnchor(t *testing.T, owner *btcec.PrivateKey, operator *btcec.PublicKey) string { + t.Helper() + leaf, pkScript, ctrlBytes := arkadeLeaf(t, owner, operator) + + unsigned := wire.NewMsgTx(2) + unsigned.AddTxIn(wire.NewTxIn(testOutpoint(t, 0), nil, nil)) + unsigned.AddTxIn(wire.NewTxIn(testOutpoint(t, 1), nil, nil)) + unsigned.AddTxOut(&wire.TxOut{Value: 900, PkScript: pkScript}) + + packet, err := psbt.NewFromUnsignedTx(unsigned) + require.NoError(t, err) + + packet.Inputs[0].WitnessUtxo = &wire.TxOut{Value: 1000, PkScript: pkScript} + packet.Inputs[0].TaprootLeafScript = []*psbt.TaprootTapLeafScript{{ + ControlBlock: ctrlBytes, + Script: leaf, + LeafVersion: txscript.BaseLeafVersion, + }} + packet.Inputs[1].WitnessUtxo = &wire.TxOut{ + Value: 240, PkScript: txutils.ANCHOR_PKSCRIPT, + } + + b64, err := packet.B64Encode() + require.NoError(t, err) + return b64 +} + +// arkadeLeaf returns the multisig leaf script, the taproot output script +// committing to it, and the control block proving the leaf. +func arkadeLeaf( + t *testing.T, owner *btcec.PrivateKey, operator *btcec.PublicKey, +) (leaf, pkScript, ctrlBytes []byte) { + t.Helper() + closure := &arkscript.MultisigClosure{ + PubKeys: []*btcec.PublicKey{owner.PubKey(), operator}, + Type: arkscript.MultisigTypeChecksig, + } + leaf, err := closure.Script() + require.NoError(t, err) + + tapLeaf := txscript.NewBaseTapLeaf(leaf) + tapTree := txscript.AssembleTaprootScriptTree(tapLeaf) + rootHash := tapTree.RootNode.TapHash() + outputKey := txscript.ComputeTaprootOutputKey(owner.PubKey(), rootHash[:]) + pkScript, err = txscript.NewScriptBuilder(). + AddOp(txscript.OP_1).AddData(schnorr.SerializePubKey(outputKey)).Script() + require.NoError(t, err) + + ctrlBlock := tapTree.LeafMerkleProofs[0].ToControlBlock(owner.PubKey()) + ctrlBytes, err = ctrlBlock.ToBytes() + require.NoError(t, err) + return leaf, pkScript, ctrlBytes +} + +func testOutpoint(t *testing.T, index uint32) *wire.OutPoint { + t.Helper() + h, err := chainhash.NewHashFromStr( + "0000000000000000000000000000000000000000000000000000000000000001", + ) + require.NoError(t, err) + return &wire.OutPoint{Hash: *h, Index: index} +} diff --git a/pkg/arkd-signer/interface/grpc/interceptors/interceptor.go b/pkg/arkd-signer/interface/grpc/interceptors/interceptor.go new file mode 100644 index 000000000..c41729cd6 --- /dev/null +++ b/pkg/arkd-signer/interface/grpc/interceptors/interceptor.go @@ -0,0 +1,16 @@ +package interceptors + +import ( + middleware "github.com/grpc-ecosystem/go-grpc-middleware" + "google.golang.org/grpc" +) + +// UnaryInterceptor returns the unary interceptor +func UnaryInterceptor() grpc.ServerOption { + return grpc.UnaryInterceptor(middleware.ChainUnaryServer(unaryLogger)) +} + +// StreamInterceptor returns the stream interceptor with a logrus log +func StreamInterceptor() grpc.ServerOption { + return grpc.StreamInterceptor(middleware.ChainStreamServer(streamLogger)) +} diff --git a/pkg/arkd-signer/interface/grpc/interceptors/logger.go b/pkg/arkd-signer/interface/grpc/interceptors/logger.go new file mode 100644 index 000000000..13244f707 --- /dev/null +++ b/pkg/arkd-signer/interface/grpc/interceptors/logger.go @@ -0,0 +1,28 @@ +package interceptors + +import ( + "context" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" +) + +func unaryLogger( + ctx context.Context, + req interface{}, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, +) (interface{}, error) { + log.Debugf("gRPC method: %s", info.FullMethod) + return handler(ctx, req) +} + +func streamLogger( + srv interface{}, + stream grpc.ServerStream, + info *grpc.StreamServerInfo, + handler grpc.StreamHandler, +) error { + log.Debugf("gRPC method: %s", info.FullMethod) + return handler(srv, stream) +} diff --git a/pkg/arkd-signer/interface/grpc/router_test.go b/pkg/arkd-signer/interface/grpc/router_test.go new file mode 100644 index 000000000..6504d0290 --- /dev/null +++ b/pkg/arkd-signer/interface/grpc/router_test.go @@ -0,0 +1,65 @@ +package grpcservice + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// The signer holds the operator key and has no auth of its own, so it must not +// advertise itself to browsers. Without CORS headers a page cannot read a +// response, and a gateway JSON post is not CORS-simple so it never gets past +// preflight in the first place. +func TestRouterSendsNoCORSHeaders(t *testing.T) { + gateway := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + h := router(nil, gateway) + + corsHeaders := []string{ + "Access-Control-Allow-Origin", + "Access-Control-Allow-Headers", + "Access-Control-Allow-Methods", + } + + t.Run("preflight is refused", func(t *testing.T) { + req := httptest.NewRequest(http.MethodOptions, "/v1/sign", nil) + req.Header.Set("Origin", "https://evil.example") + req.Header.Set("Access-Control-Request-Method", "POST") + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusMethodNotAllowed, rec.Code) + for _, k := range corsHeaders { + require.Emptyf(t, rec.Header().Get(k), "preflight must not send %s", k) + } + }) + + t.Run("gateway responses carry no CORS headers", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/v1/sign", strings.NewReader("{}")) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "https://evil.example") + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + for _, k := range corsHeaders { + require.Emptyf(t, rec.Header().Get(k), "response must not send %s", k) + } + }) + + t.Run("healthz still reachable for the container healthcheck", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + }) +} diff --git a/pkg/arkd-signer/interface/grpc/service.go b/pkg/arkd-signer/interface/grpc/service.go new file mode 100644 index 000000000..d89f4fbea --- /dev/null +++ b/pkg/arkd-signer/interface/grpc/service.go @@ -0,0 +1,149 @@ +package grpcservice + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "strings" + + signerv1 "github.com/arkade-os/arkd/api-spec/protobuf/gen/signer/v1" + "github.com/arkade-os/arkd/pkg/arkd-signer/config" + "github.com/arkade-os/arkd/pkg/arkd-signer/interface/grpc/handlers" + "github.com/arkade-os/arkd/pkg/arkd-signer/interface/grpc/interceptors" + "github.com/meshapi/grpc-api-gateway/gateway" + log "github.com/sirupsen/logrus" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + grpchealth "google.golang.org/grpc/health/grpc_health_v1" +) + +type service struct { + cfg *config.Config + server *http.Server + grpcSrv *grpc.Server + stopFn func() +} + +func NewService(cfg *config.Config) (*service, error) { + return &service{ + cfg: cfg, + }, nil +} + +func (s *service) Start() error { + grpcOpts := []grpc.ServerOption{ + grpc.Creds(insecure.NewCredentials()), + interceptors.UnaryInterceptor(), + interceptors.StreamInterceptor(), + } + grpcSrv := grpc.NewServer(grpcOpts...) + + ctx, cancel := context.WithCancel(context.Background()) + signerHandler := handlers.NewSignerHandler(s.cfg.SignerSvc) + signerv1.RegisterSignerServiceServer(grpcSrv, signerHandler) + + healthHandler := handlers.NewHealthHandler(s.cfg.SignerSvc) + grpchealth.RegisterHealthServer(grpcSrv, healthHandler) + + gatewayCreds := insecure.NewCredentials() + gatewayOpts := grpc.WithTransportCredentials(gatewayCreds) + conn, err := grpc.NewClient( + gatewayAddress(s.cfg.Port), gatewayOpts, + ) + if err != nil { + cancel() + return fmt.Errorf("failed to connect signer grpc-gateway: %w", err) + } + + gwmux := gateway.NewServeMux( + gateway.WithHealthzEndpoint(grpchealth.NewHealthClient(conn)), + ) + + signerv1.RegisterSignerServiceHandler(ctx, gwmux, conn) + + grpcGateway := http.Handler(gwmux) + handler := router(grpcSrv, grpcGateway) + mux := http.NewServeMux() + mux.Handle("/", handler) + + httpServerHandler := h2c.NewHandler(http.Handler(mux), &http2.Server{}) + + s.server = &http.Server{ + Addr: address(s.cfg.Port), + Handler: httpServerHandler, + } + s.grpcSrv = grpcSrv + s.stopFn = cancel + + // Bind before returning so a port conflict surfaces as an error from Start + // rather than a panic from the serving goroutine after main has already + // logged that the signer is listening. + listener, err := net.Listen("tcp", s.server.Addr) + if err != nil { + cancel() + return fmt.Errorf("failed to listen on %s: %w", s.server.Addr, err) + } + + go func() { + if err := s.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.WithError(err).Fatal("signer server stopped unexpectedly") + } + }() + return nil +} + +func (s *service) Stop() { + if s.stopFn != nil { + s.stopFn() + } + if s.server != nil { + _ = s.server.Shutdown(context.Background()) + } + if s.grpcSrv != nil { + s.grpcSrv.GracefulStop() + } +} + +// router deliberately sends no CORS headers. This service signs with the +// operator key and has no auth of its own, so its only callers are arkd and +// operator tooling, never a browser. Answering preflight with +// Access-Control-Allow-Origin: * is what would let a page the operator happens +// to be visiting issue signing requests to a signer it can route to, since +// gateway JSON posts are not CORS-simple and are otherwise blocked at preflight. +func router( + grpcServer *grpc.Server, grpcGateway http.Handler, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isOptionRequest(r) { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if isHttpRequest(r) { + grpcGateway.ServeHTTP(w, r) + return + } + grpcServer.ServeHTTP(w, r) + }) +} + +func isOptionRequest(req *http.Request) bool { + return req.Method == http.MethodOptions +} + +func isHttpRequest(req *http.Request) bool { + return req.Method == http.MethodGet || + strings.Contains(req.Header.Get("Content-Type"), "application/json") +} + +func address(port uint32) string { + return fmt.Sprintf(":%d", port) +} + +func gatewayAddress(port uint32) string { + return fmt.Sprintf("127.0.0.1:%d", port) +} diff --git a/pkg/arkd-wallet/config/config.go b/pkg/arkd-wallet/config/config.go index 4a233adc6..b212d8906 100644 --- a/pkg/arkd-wallet/config/config.go +++ b/pkg/arkd-wallet/config/config.go @@ -1,13 +1,10 @@ package config import ( - "bytes" - "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" - "strconv" "strings" arklib "github.com/arkade-os/arkd/pkg/ark-lib" @@ -17,7 +14,6 @@ import ( "github.com/arkade-os/arkd/pkg/arkd-wallet/core/infrastructure/cypher" db "github.com/arkade-os/arkd/pkg/arkd-wallet/core/infrastructure/db/badger" "github.com/arkade-os/arkd/pkg/arkd-wallet/core/infrastructure/nbxplorer" - "github.com/btcsuite/btcd/btcec/v2" log "github.com/sirupsen/logrus" "github.com/spf13/viper" ) @@ -28,8 +24,6 @@ var ( LogLevel = "LOG_LEVEL" Network = "NETWORK" NbxplorerURL = "NBXPLORER_URL" - SignerKey = "SIGNER_KEY" - DeprecatedSignerKeys = "DEPRECATED_SIGNER_KEYS" OtelCollectorEndpoint = "OTEL_COLLECTOR_ENDPOINT" OtelPushInterval = "OTEL_PUSH_INTERVAL" PyroscopeServerURL = "PYROSCOPE_SERVER_URL" @@ -71,8 +65,6 @@ func LoadConfig() (*Config, error) { LogLevel: viper.GetInt(LogLevel), Network: net, NbxplorerURL: viper.GetString(NbxplorerURL), - SignerKey: viper.GetString(SignerKey), - DeprecatedSignerKeys: viper.GetString(DeprecatedSignerKeys), OtelCollectorEndpoint: viper.GetString(OtelCollectorEndpoint), OtelPushInterval: viper.GetInt64(OtelPushInterval), PyroscopeServerURL: viper.GetString(PyroscopeServerURL), @@ -91,8 +83,6 @@ type Config struct { LogLevel int Network arklib.Network NbxplorerURL string - SignerKey string - DeprecatedSignerKeys string OtelCollectorEndpoint string OtelPushInterval int64 PyroscopeServerURL string @@ -112,32 +102,6 @@ func (c *Config) String() string { } func (c *Config) initServices() error { - var signerKey *btcec.PrivateKey - if c.SignerKey != "" { - buf, err := hex.DecodeString(c.SignerKey) - if err != nil { - return fmt.Errorf("invalid signer key format, must be hex") - } - signerKey, _ = btcec.PrivKeyFromBytes(buf) - } - - deprecatedSignerKeys, err := parseDeprecatedSignerKeys(c.DeprecatedSignerKeys) - if err != nil { - return err - } - - if signerKey != nil { - currentPubkey := signerKey.PubKey().SerializeCompressed() - for _, k := range deprecatedSignerKeys { - if bytes.Equal(k.Key.PubKey().SerializeCompressed(), currentPubkey) { - return fmt.Errorf( - "deprecated signer key %x matches the current signer key", - currentPubkey, - ) - } - } - } - repository, err := db.NewSeedRepository(c.DbDir, nil) if err != nil { return fmt.Errorf("error while creating seed repository: %s", err) @@ -156,12 +120,10 @@ func (c *Config) initServices() error { } walletSvc := wallet.New(wallet.WalletOptions{ - SeedRepository: repository, - Cypher: cryptoSvc, - Nbxplorer: nbxplorerSvc, - Network: network.Name, - SignerKey: signerKey, - DeprecatedSignerKeys: deprecatedSignerKeys, + SeedRepository: repository, + Cypher: cryptoSvc, + Nbxplorer: nbxplorerSvc, + Network: network.Name, }) scannerSvc, err := scanner.New(nbxplorerSvc, network.Name) @@ -204,44 +166,3 @@ func getNetwork() (arklib.Network, error) { return arklib.Network{}, fmt.Errorf("unknown network %s", viper.GetString(Network)) } } - -// parseDeprecatedSignerKeys parses a comma-separated list of hex-encoded private -// keys, each optionally followed by a cutoff date: "[:]". -// The cutoff date is the time after which the key is no longer accepted, 0 if unset. -func parseDeprecatedSignerKeys(raw string) ([]wallet.DeprecatedSignerKey, error) { - keys := make([]wallet.DeprecatedSignerKey, 0) - for _, entry := range strings.Split(raw, ",") { - entry = strings.TrimSpace(entry) - if entry == "" { - continue - } - - keyPart, cutoffPart, hasCutoff := strings.Cut(entry, ":") - if strings.TrimSpace(keyPart) == "" { - return nil, fmt.Errorf("invalid signer key entry, missing hex key: %s", entry) - } - - buf, err := hex.DecodeString(keyPart) - if err != nil { - return nil, fmt.Errorf("invalid signer key format, must be hex: %s", keyPart) - } - if len(buf) != 32 { - return nil, fmt.Errorf("invalid signer key format") - } - key, _ := btcec.PrivKeyFromBytes(buf) - - var cutoffDate int64 - if hasCutoff { - cutoff, err := strconv.ParseInt(cutoffPart, 10, 64) - if err != nil || cutoff < 0 { - return nil, fmt.Errorf( - "invalid cutoff date, must be a positive unix timestamp: %s", entry, - ) - } - cutoffDate = cutoff - } - - keys = append(keys, wallet.DeprecatedSignerKey{Key: key, CutoffDate: cutoffDate}) - } - return keys, nil -} diff --git a/pkg/arkd-wallet/core/application/types.go b/pkg/arkd-wallet/core/application/types.go index 7ef405ddd..3b7cc3b87 100644 --- a/pkg/arkd-wallet/core/application/types.go +++ b/pkg/arkd-wallet/core/application/types.go @@ -4,14 +4,12 @@ import ( "context" "fmt" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) const ( SignModeLiquidityProvider = "liquidity_provider" - SignModeSigner = "signer" ) type WalletService interface { @@ -23,8 +21,6 @@ type WalletService interface { Lock(ctx context.Context) error Status(ctx context.Context) WalletStatus GetNetwork(ctx context.Context) string - GetSignerPubkey(ctx context.Context) (string, error) - GetDeprecatedSignerPubkeys(ctx context.Context) ([]DeprecatedSignerPubkey, error) GetForfeitPubkey(ctx context.Context) (string, error) DeriveConnectorAddress(ctx context.Context) (string, error) DeriveAddresses(ctx context.Context, num int) ([]string, error) @@ -49,7 +45,6 @@ type WalletService interface { Withdraw(ctx context.Context, destinationAddress string, amount uint64) (string, error) // Withdraw both main and connectors account funds WithdrawAll(ctx context.Context, destinationAddress string) (string, error) - LoadSignerKey(ctx context.Context, prvkey *btcec.PrivateKey) error Close() } @@ -95,10 +90,4 @@ type BlockTimestamp struct { Time int64 } -type DeprecatedSignerPubkey struct { - Pubkey string - // unix timestamp after which the key is no longer accepted, 0 if unset - CutoffDate int64 -} - var ErrTransactionNotFound = fmt.Errorf("transaction not found") diff --git a/pkg/arkd-wallet/core/application/wallet/service.go b/pkg/arkd-wallet/core/application/wallet/service.go index 8d0d37bfe..1e71cd497 100644 --- a/pkg/arkd-wallet/core/application/wallet/service.go +++ b/pkg/arkd-wallet/core/application/wallet/service.go @@ -10,8 +10,7 @@ import ( "strings" "time" - "github.com/arkade-os/arkd/pkg/ark-lib/script" - "github.com/arkade-os/arkd/pkg/ark-lib/txutils" + "github.com/arkade-os/arkd/pkg/ark-lib/txsigner" "github.com/arkade-os/arkd/pkg/arkd-wallet/core/application" "github.com/arkade-os/arkd/pkg/arkd-wallet/core/ports" "github.com/btcsuite/btcd/btcec/v2" @@ -32,9 +31,7 @@ import ( ) var ( - ErrWalletLocked = fmt.Errorf("wallet is locked") - ErrSignerDisabled = fmt.Errorf("signer not enabled") - ErrSignerAlreadyLoaded = fmt.Errorf("signer key already loaded") + ErrWalletLocked = fmt.Errorf("wallet is locked") ANCHOR_PKSCRIPT = []byte{0x51, 0x02, 0x4e, 0x73} ) @@ -44,18 +41,10 @@ var ( const biggestInputSize = 148 + 182 // = 330 vbytes type WalletOptions struct { - SeedRepository ports.SeedRepository - Cypher ports.Cypher - Nbxplorer ports.Nbxplorer - Network string - SignerKey *btcec.PrivateKey - DeprecatedSignerKeys []DeprecatedSignerKey -} - -type DeprecatedSignerKey struct { - Key *btcec.PrivateKey - // unix timestamp after which the key is no longer accepted, 0 if unset - CutoffDate int64 + SeedRepository ports.SeedRepository + Cypher ports.Cypher + Nbxplorer ports.Nbxplorer + Network string } type wallet struct { @@ -236,28 +225,6 @@ func (w *wallet) DeriveConnectorAddress(ctx context.Context) (string, error) { return addresses[0], nil } -func (w *wallet) GetSignerPubkey(ctx context.Context) (string, error) { - if w.SignerKey == nil { - return "", ErrSignerDisabled - } - - pubkey := hex.EncodeToString(w.SignerKey.PubKey().SerializeCompressed()) - return pubkey, nil -} - -func (w *wallet) GetDeprecatedSignerPubkeys( - ctx context.Context, -) ([]application.DeprecatedSignerPubkey, error) { - pubkeys := make([]application.DeprecatedSignerPubkey, 0, len(w.DeprecatedSignerKeys)) - for _, k := range w.DeprecatedSignerKeys { - pubkeys = append(pubkeys, application.DeprecatedSignerPubkey{ - Pubkey: hex.EncodeToString(k.Key.PubKey().SerializeCompressed()), - CutoffDate: k.CutoffDate, - }) - } - return pubkeys, nil -} - func (w *wallet) EstimateFees(ctx context.Context, rawTx string) (uint64, error) { partial, err := psbt.NewFromRawBytes( strings.NewReader(rawTx), @@ -618,9 +585,6 @@ func (w *wallet) SignTransaction( if signMode == application.SignModeLiquidityProvider && w.keyMgr == nil { return "", ErrWalletLocked } - if signMode == application.SignModeSigner && w.SignerKey == nil { - return "", ErrSignerDisabled - } ptx, err := psbt.NewFromRawBytes(strings.NewReader(partialTx), true) if err != nil { @@ -670,9 +634,6 @@ func (w *wallet) SignTransaction( if len(input.TaprootLeafScript) > 0 { signingKey := w.keyMgr.forfeitPrvkey - if signMode == application.SignModeSigner { - signingKey = w.signerKeyForLeaf(input.TaprootLeafScript[0].Script) - } tapLeaf := txscript.NewBaseTapLeaf(input.TaprootLeafScript[0].Script) @@ -725,101 +686,12 @@ func (w *wallet) SignTransaction( } if extractRawTx { - for i, in := range ptx.Inputs { - isTaproot := txscript.IsPayToTaproot(in.WitnessUtxo.PkScript) - if isTaproot && len(in.TaprootLeafScript) > 0 { - closure, err := script.DecodeClosure(in.TaprootLeafScript[0].Script) - if err != nil { - return "", err - } - - conditionWitnessFields, err := txutils.GetArkPsbtFields(ptx, i, txutils.ConditionWitnessField) - if err != nil { - return "", err - } - - args := make(map[string][]byte) - if len(conditionWitnessFields) > 0 { - var conditionWitnessBytes bytes.Buffer - if err := psbt.WriteTxWitness(&conditionWitnessBytes, conditionWitnessFields[0]); err != nil { - return "", err - } - args[string(txutils.ArkFieldConditionWitness)] = conditionWitnessBytes.Bytes() - } - - for _, sig := range in.TaprootScriptSpendSig { - args[hex.EncodeToString(sig.XOnlyPubKey)] = sig.Signature - } - - witness, err := closure.Witness(in.TaprootLeafScript[0].ControlBlock, args) - if err != nil { - return "", err - } - - var witnessBuf bytes.Buffer - if err := psbt.WriteTxWitness(&witnessBuf, witness); err != nil { - return "", err - } - - ptx.Inputs[i].FinalScriptWitness = witnessBuf.Bytes() - continue - } - - if err := psbt.Finalize(ptx, i); err != nil { - return "", fmt.Errorf("failed to finalize input %d: %w", i, err) - } - } - - extracted, err := psbt.Extract(ptx) - if err != nil { - return "", err - } - - var buf bytes.Buffer - if err := extracted.Serialize(&buf); err != nil { - return "", err - } - - return hex.EncodeToString(buf.Bytes()), nil + return txsigner.ExtractFinalizedTx(ptx) } return ptx.B64Encode() } -// signerKeyForLeaf returns the deprecated signer key referenced by the leaf, or the current SignerKey. -func (w *wallet) signerKeyForLeaf(leafScript []byte) *btcec.PrivateKey { - if len(w.DeprecatedSignerKeys) == 0 { - return w.SignerKey - } - - closure, err := script.DecodeClosure(leafScript) - if err != nil { - return w.SignerKey - } - - leafKeys := make([]*btcec.PublicKey, 0) - switch c := closure.(type) { - case *script.MultisigClosure: - leafKeys = c.PubKeys - case *script.CLTVMultisigClosure: - leafKeys = c.PubKeys - case *script.ConditionMultisigClosure: - leafKeys = c.PubKeys - default: - return w.SignerKey - } - - for _, k := range w.DeprecatedSignerKeys { - want := schnorr.SerializePubKey(k.Key.PubKey()) - for _, pubkey := range leafKeys { - if bytes.Equal(schnorr.SerializePubKey(pubkey), want) { - return k.Key - } - } - } - return w.SignerKey -} - // WithdrawAll withdraws all available balance including connectors account funds func (w *wallet) WithdrawAll(ctx context.Context, destinationAddress string) (string, error) { destinationAddr, err := btcutil.DecodeAddress(destinationAddress, w.chainParams()) @@ -935,15 +807,6 @@ func (w *wallet) Withdraw(ctx context.Context, destinationAddress string, amount return txid, nil } -func (w *wallet) LoadSignerKey(ctx context.Context, prvkey *btcec.PrivateKey) error { - if w.SignerKey != nil { - return ErrSignerAlreadyLoaded - } - - w.SignerKey = prvkey - return nil -} - func (w *wallet) Close() { // nolint:errcheck w.Nbxplorer.Close() diff --git a/pkg/arkd-wallet/core/application/wallet/signer_keys_test.go b/pkg/arkd-wallet/core/application/wallet/signer_keys_test.go deleted file mode 100644 index 89fd99873..000000000 --- a/pkg/arkd-wallet/core/application/wallet/signer_keys_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package wallet - -import ( - "context" - "encoding/hex" - "strings" - "testing" - "time" - - "github.com/arkade-os/arkd/pkg/ark-lib/script" - "github.com/arkade-os/arkd/pkg/arkd-wallet/core/application" - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcec/v2/schnorr" - "github.com/btcsuite/btcd/btcutil/psbt" - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - "github.com/stretchr/testify/require" -) - -// TestSignTransaction makes sure SignTransaction signs taproot script-path inputs -// with the key referenced by the leaf, including deprecated signer keys. From the -// wallet's point of view the cutoff date is purely informational: it always signs -// with the deprecated key whether or not the cutoff has passed. -func TestSignTransaction(t *testing.T) { - owner, err := btcec.NewPrivateKey() - require.NoError(t, err) - current, err := btcec.NewPrivateKey() - require.NoError(t, err) - old, err := btcec.NewPrivateKey() - require.NoError(t, err) - - now := time.Now().Unix() - - tests := []struct { - name string - w *wallet - leafSigner *btcec.PublicKey - wantSigner *btcec.PublicKey - }{ - { - name: "sign with current key", - w: &wallet{WalletOptions: WalletOptions{SignerKey: current}}, - leafSigner: current.PubKey(), - wantSigner: current.PubKey(), - }, - { - name: "sign with deprecated key where cutoff date > now", - w: &wallet{WalletOptions: WalletOptions{ - SignerKey: current, - DeprecatedSignerKeys: []DeprecatedSignerKey{{Key: old, CutoffDate: now + 3600}}, - }}, - leafSigner: old.PubKey(), - wantSigner: old.PubKey(), - }, - { - name: "sign with deprecated key where cutoff passed", - w: &wallet{WalletOptions: WalletOptions{ - SignerKey: current, - DeprecatedSignerKeys: []DeprecatedSignerKey{{Key: old, CutoffDate: now - 3600}}, - }}, - leafSigner: old.PubKey(), - wantSigner: old.PubKey(), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - b64 := signablePacket(t, owner, tt.leafSigner) - - signed, err := tt.w.SignTransaction( - context.Background(), application.SignModeSigner, b64, false, nil) - require.NoError(t, err) - - out, err := psbt.NewFromRawBytes(strings.NewReader(signed), true) - require.NoError(t, err) - require.Len(t, out.Inputs[0].TaprootScriptSpendSig, 1) - require.Equal(t, - hex.EncodeToString(schnorr.SerializePubKey(tt.wantSigner)), - hex.EncodeToString(out.Inputs[0].TaprootScriptSpendSig[0].XOnlyPubKey), - ) - }) - } -} - -// signablePacket builds a single-input PSBT spending a taproot output via a -// multisig leaf that embeds signer's x-only pubkey, returning the base64 PSBT. -func signablePacket(t *testing.T, owner *btcec.PrivateKey, signer *btcec.PublicKey) string { - t.Helper() - leaf := leafScript(t, owner.PubKey(), signer) - tapLeaf := txscript.NewBaseTapLeaf(leaf) - tapTree := txscript.AssembleTaprootScriptTree(tapLeaf) - rootHash := tapTree.RootNode.TapHash() - outputKey := txscript.ComputeTaprootOutputKey(owner.PubKey(), rootHash[:]) - pkScript, err := txscript.NewScriptBuilder(). - AddOp(txscript.OP_1).AddData(schnorr.SerializePubKey(outputKey)).Script() - require.NoError(t, err) - - ctrlBlock := tapTree.LeafMerkleProofs[0].ToControlBlock(owner.PubKey()) - ctrlBlockBytes, err := ctrlBlock.ToBytes() - require.NoError(t, err) - - prevHash, err := chainhash.NewHashFromStr( - "0000000000000000000000000000000000000000000000000000000000000001") - require.NoError(t, err) - prevOut := wire.OutPoint{Hash: *prevHash, Index: 0} - unsigned := wire.NewMsgTx(2) - unsigned.AddTxIn(wire.NewTxIn(&prevOut, nil, nil)) - unsigned.AddTxOut(&wire.TxOut{Value: 900, PkScript: pkScript}) - - packet, err := psbt.NewFromUnsignedTx(unsigned) - require.NoError(t, err) - packet.Inputs[0].WitnessUtxo = &wire.TxOut{Value: 1000, PkScript: pkScript} - packet.Inputs[0].TaprootLeafScript = []*psbt.TaprootTapLeafScript{{ - ControlBlock: ctrlBlockBytes, - Script: leaf, - LeafVersion: txscript.BaseLeafVersion, - }} - b64, err := packet.B64Encode() - require.NoError(t, err) - return b64 -} - -// leafScript builds a multisig leaf embedding the signer's x-only pubkey. -func leafScript(t *testing.T, owner, signer *btcec.PublicKey) []byte { - t.Helper() - closure := &script.MultisigClosure{ - PubKeys: []*btcec.PublicKey{owner, signer}, - Type: script.MultisigTypeChecksig, - } - s, err := closure.Script() - require.NoError(t, err) - return s -} diff --git a/pkg/arkd-wallet/interface/grpc/handlers/wallet_handler.go b/pkg/arkd-wallet/interface/grpc/handlers/wallet_handler.go index 8b5fe9ed1..0ec591d4a 100644 --- a/pkg/arkd-wallet/interface/grpc/handlers/wallet_handler.go +++ b/pkg/arkd-wallet/interface/grpc/handlers/wallet_handler.go @@ -2,12 +2,10 @@ package handlers import ( "context" - "encoding/hex" "errors" arkwalletv1 "github.com/arkade-os/arkd/api-spec/protobuf/gen/arkwallet/v1" application "github.com/arkade-os/arkd/pkg/arkd-wallet/core/application" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/google/uuid" @@ -489,19 +487,9 @@ func (h *walletHandler) Withdraw( func (h *walletHandler) LoadSignerKey( ctx context.Context, req *arkwalletv1.LoadSignerKeyRequest, ) (*arkwalletv1.LoadSignerKeyResponse, error) { - key := req.GetPrivateKey() - if len(key) <= 0 { - return nil, status.Errorf(codes.InvalidArgument, "missing private key") - } - buf, err := hex.DecodeString(key) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid private key format, must be hex") - } - prvkey, _ := btcec.PrivKeyFromBytes(buf) - if err := h.wallet.LoadSignerKey(ctx, prvkey); err != nil { - return nil, err - } - return &arkwalletv1.LoadSignerKeyResponse{}, nil + return nil, status.Errorf( + codes.Unimplemented, "signer key is managed by arkd-signer, not arkd-wallet", + ) } func (h *walletHandler) RescanUtxos( ctx context.Context, req *arkwalletv1.RescanUtxosRequest, diff --git a/pkg/arkd-wallet/interface/grpc/service.go b/pkg/arkd-wallet/interface/grpc/service.go index 26fb9083d..42d36d782 100644 --- a/pkg/arkd-wallet/interface/grpc/service.go +++ b/pkg/arkd-wallet/interface/grpc/service.go @@ -9,7 +9,6 @@ import ( "time" arkwalletv1 "github.com/arkade-os/arkd/api-spec/protobuf/gen/arkwallet/v1" - signerv1 "github.com/arkade-os/arkd/api-spec/protobuf/gen/signer/v1" "github.com/arkade-os/arkd/pkg/arkd-wallet/config" "github.com/arkade-os/arkd/pkg/arkd-wallet/interface/grpc/handlers" "github.com/arkade-os/arkd/pkg/arkd-wallet/interface/grpc/interceptors" @@ -87,8 +86,6 @@ func (s *service) Start() error { ctx, cancel := context.WithCancel(context.Background()) walletHandler := handlers.NewWalletServiceHandler(ctx, s.cfg.WalletSvc, s.cfg.ScannerSvc) arkwalletv1.RegisterWalletServiceServer(grpcSrv, walletHandler) - signerHandler := handlers.NewSignerHandler(s.cfg.WalletSvc) - signerv1.RegisterSignerServiceServer(grpcSrv, signerHandler) healthHandler := handlers.NewHealthHandler() grpchealth.RegisterHealthServer(grpcSrv, healthHandler) @@ -108,7 +105,6 @@ func (s *service) Start() error { ) arkwalletv1.RegisterWalletServiceHandler(ctx, gwmux, conn) - signerv1.RegisterSignerServiceHandler(ctx, gwmux, conn) grpcGateway := http.Handler(gwmux) handler := router(grpcSrv, grpcGateway) diff --git a/scripts/build-arkd-signer b/scripts/build-arkd-signer new file mode 100644 index 000000000..3510acfa6 --- /dev/null +++ b/scripts/build-arkd-signer @@ -0,0 +1,36 @@ +#!/bin/bash +set -e + +# Get the parent directory path +PARENT_PATH=$(dirname $( + cd $(dirname $0) + pwd -P +)) + +# Set VERSION (you can modify this to get the version from a file or environment variable) +VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "unknown") + +# Set GOOS and GOARCH if not already set +if [[ -z $GOOS ]]; then + GOOS=$(go env GOOS) +fi +if [[ -z $GOARCH ]]; then + GOARCH=$(go env GOARCH) +fi + +echo "Building arkd-signer for $GOOS $GOARCH" +echo "Version: $VERSION" + +# Change to the parent directory +pushd $PARENT_PATH + +# Create build directory if it doesn't exist +mkdir -p build + +# Build the binary with version information +GO111MODULE=on go build -ldflags="-s -w -X 'main.Version=$VERSION'" -o build/arkd-signer-$GOOS-$GOARCH ./cmd/arkd-signer + +echo "Build complete: build/arkd-signer-$GOOS-$GOARCH" + +# Return to the original directory +popd