diff --git a/.github/workflows/docker-test.yml b/.github/workflows/docker-test.yml new file mode 100644 index 0000000..5b7cdc1 --- /dev/null +++ b/.github/workflows/docker-test.yml @@ -0,0 +1,18 @@ +name: Dockerized Tests + +on: + push: + branches: [ "master", "main" ] + pull_request: + branches: [ "**" ] + +jobs: + all-tests: + runs-on: ubuntu-latest + + steps: + - name: Check out repository code + uses: actions/checkout@v3 + + - name: Run tests in docker container + run: make docker-test-all diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6db4f07 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +ARG gover=1.19.5 + +FROM golang:$gover + +ARG goplatform=amd64 +ARG cppplatform=x86_64 +ARG lnd=v0.15.5-beta +ARG bitcoind=24.0.1 +ARG vault=1.12.2 + +RUN apt update && apt-get install -y zip + +RUN echo $bitcoind + +RUN cd /root && \ + wget https://bitcoincore.org/bin/bitcoin-core-$bitcoind/bitcoin-${bitcoind}-${cppplatform}-linux-gnu.tar.gz && \ + tar xfz bitcoin-$bitcoind-$cppplatform-linux-gnu.tar.gz && \ + mv bitcoin-$bitcoind/bin/* /usr/local/bin/ && \ + wget https://github.com/lightningnetwork/lnd/releases/download/$lnd/lnd-linux-$goplatform-$lnd.tar.gz && \ + tar xfz lnd-linux-$goplatform-$lnd.tar.gz && \ + mv lnd-linux-$goplatform-$lnd/* /usr/local/bin/ && \ + wget https://releases.hashicorp.com/vault/$vault/vault_${vault}_linux_${goplatform}.zip && \ + unzip vault_${vault}_linux_${goplatform}.zip && \ + mv vault /usr/local/bin/ && \ + go install github.com/go-delve/delve/cmd/dlv@latest && \ + echo "export PATH='$PATH:/usr/local/go/bin:/root/go/bin'" >> .bashrc + +VOLUME [ "/app" ] + +WORKDIR /app diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6d96bbe --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +.PHONY: docker docker-itest docker-test docker-test-all docker-check docker-shell itest test test-all + +IMG_NAME := lndsigner-builder + +GOVER := 1.19.5 +GOPLATFORM := amd64 +CPPPLATFORM := x86_64 +LND := v0.15.5-beta +BITCOIND := 24.0.1 +VAULT := 1.12.2 + +STAMPPREIMAGE := $(GOVER)$(GOPLATFORM)$(CPPPLATFORM)$(LND)$(BITCOIND)$(VAULT) + +BUILDERSTAMP != echo $(STAMPPREIMAGE) | sha256sum | cut -d " " -f 1 +IMAGE_BUILT != docker image ls $(IMG_NAME):$(BUILDERSTAMP) | grep $(BUILDERSTAMP) | wc -l + +# docker-check checks if an image with the builderstamp already exists. If not, +# it builds one. +docker-check: +ifeq ($(IMAGE_BUILT), 0) + docker build -t $(IMG_NAME):$(BUILDERSTAMP) . +endif + +# docker just tags the latest image to the builderstamp, in case the +# dependencies have been changed and a new image was built. +docker: docker-check + docker image tag $(IMG_NAME):$(BUILDERSTAMP) $(IMG_NAME):latest + +# docker-itest runs itests in a docker container, then removes the container. +docker-itest: docker + docker run -t --rm \ + --mount type=bind,source=$(CURDIR),target=/app $(IMG_NAME):latest \ + make itest + +# docker-test runs unit tests in a docker container, then removes the container. +docker-test: docker + docker run -t --rm \ + --mount type=bind,source=$(CURDIR),target=/app $(IMG_NAME):latest \ + make test + +# docker-test-all runs unit and integration tests in a docker container, then +# removes the container. +docker-test-all: docker + docker run -t --rm \ + --mount type=bind,source=$(CURDIR),target=/app $(IMG_NAME):latest \ + make test-all + +# docker-shell opens a shell to a dockerized environment with all dependencies +# and also dlv installed for easy debugging, then removes the container. +docker-shell: docker + docker run -it --rm \ + --mount type=bind,source=$(CURDIR),target=/app $(IMG_NAME):latest \ + bash -l + +itest: + go install -race ./cmd/... && go test -v -count=1 -race -tags=itest -cover ./itest + +test: + go test -v -count=1 -race -cover ./... + +test-all: + go install -race ./cmd/... && go test -v -count=1 -race -tags=itest -cover ./... diff --git a/README.md b/README.md index 7e0594b..a9b8374 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ - [x] sign messages for network announcements - [x] derive shared keys for peer connections - [x] sign PSBTs for on-chain transactions, channel openings/closes, HTLC updates, etc. -- [ ] run itests +- [x] run itests - [ ] do automated builds - [ ] do reproducible builds - [ ] perform musig2 ops @@ -159,3 +159,20 @@ node 03c7926302ac72f51ef009dc169561734414b3c6bfd9fb0dc42cac93101c3c25bf ``` Now you can use the imported key as before. + +## Testing +You can run unit tests and integration tests, together or separately, in Docker or on your host system. To run tests inside Docker, from the project directory, run one of: + + * `$ make docker-test` for unit tests + * `$ make docker-itest` for integration tests + * `$ make docker-test-all` for integration and unit tests + +To run tests directly on your development machine, you can use: + + * `$ make test` for unit tests + * `$ make itest` for integration tests + * `$ make test-all` for integration and unit tests + +Before running integration tests on your development machine, ensure you have all the required binaries (bitcoind, bitcoin-cli, lnd, lncli, vault). + +To get a shell on a container that can run tests, you can use `make docker-shell`. Then, you can `make test`, `make itest`, or `make test-all` inside the container, just like you would directly on the host system. diff --git a/itest/gen_protos.sh b/itest/gen_protos.sh new file mode 100755 index 0000000..585020c --- /dev/null +++ b/itest/gen_protos.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# +# Copyright (C) 2015-2022 Lightning Labs and The Lightning Network Developers +# Copyright (C) 2022 Bottlepay and The Lightning Network Developers + +set -e + +# generate compiles the *.pb.go stubs from the *.proto files. +function generate() { + echo "Generating root gRPC server protos" + + PROTOS="walletunlocker.proto" + + # For each of the sub-servers, we then generate their protos, but a restricted + # set as they don't yet require REST proxies, or swagger docs. + for file in $PROTOS; do + DIRECTORY=$(dirname "${file}") + echo "Generating protos from ${file}, into ${DIRECTORY}" + + # Generate the protos. + protoc -I/usr/local/include -I. \ + --go_out . --go_opt paths=source_relative \ + --go-grpc_out . --go-grpc_opt paths=source_relative \ + "${file}" + done +} + +# format formats the *.proto files with the clang-format utility. +function format() { + find . -name "*.proto" -print0 | xargs -0 clang-format --style=file -i +} + +# Compile and format the itest package. +pushd itest +format +generate +popd diff --git a/itest/lndsigner_test.go b/itest/lndsigner_test.go new file mode 100644 index 0000000..7085737 --- /dev/null +++ b/itest/lndsigner_test.go @@ -0,0 +1,840 @@ +//go:build itest +// +build itest + +package itest_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/fs" + "net" + "os" + "os/exec" + "path" + "sync" + "testing" + "time" + + "github.com/bottlepay/lndsigner" + "github.com/bottlepay/lndsigner/itest" + "github.com/hashicorp/vault/api" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +const ( + lndCreatePath = "lndsigner/lnd-nodes" + lndImportPath = "lndsigner/lnd-nodes/import" +) + +// TestIntegration function runs end-to-end tests using all of the required +// binaries. +// +// This assumes we've got `lnd`, `lncli`, `vault`, `bitcoind`, `bitcoin-cli`, +// and the binaries produced by this package installed and available in the +// executable path. These are installed in CI by the GitHub workflow, but +// for now need to be installed manually in the dev environment. +// +// TODO(aakselrod): add Dockerfile to dockerize itests locally. +func TestIntegration(t *testing.T) { + ctx := context.Background() + + tctx := newTestContext(t, ctx) + defer tctx.Close() + + // Create our nodes in parallel to save time. + var wg sync.WaitGroup + wg.Add(3) + + // Create a randomly-initialized node for which nobody's ever seen the + // keys. + go func() { + defer wg.Done() + + _ = tctx.addNode(lndCreatePath, map[string]interface{}{ + "network": "regtest", + }, false) + }() + + // Import node without passphrase. + go func() { + defer wg.Done() + + lnd2PK := "023cf344b017a3c91bdb2c9c076da267555f0c0748099418ea1f558a624ced1ac6" + require.Equal(t, tctx.addNode(lndImportPath, map[string]interface{}{ + "network": "regtest", + "seedphrase": "absent walnut slam olive squeeze cluster blame express asthma gym force warfare physical stuff unusual tiny endless patient again sound deny identify fall guard", + "passphrase": "", + "node": lnd2PK, + }, false), lnd2PK) + }() + + // Import node with passphrase. + go func() { + defer wg.Done() + + lnd3PK := "03c7926302ac72f51ef009dc169561734414b3c6bfd9fb0dc42cac93101c3c25bf" + require.Equal(t, tctx.addNode(lndImportPath, map[string]interface{}{ + "network": "testnet", + "seedphrase": "abstract inch live custom just tray hockey enroll upon friend mass author filter desert parrot network finger uniform alley artefact path palace chicken diet", + "passphrase": "weks1234", + "node": lnd3PK, + }, false), lnd3PK) + }() + + wg.Wait() + + time.Sleep(500 * time.Millisecond) + tctx.waitForSync() + + t.Run("fund each lnd with a p2tr address", tctx.testFundLnds) + + time.Sleep(500 * time.Millisecond) + tctx.bitcoinCli("-generate") + + tctx.waitForSync() + + t.Run("sweep p2tr to p2wkh address", tctx.testSweepToP2WKH) + + time.Sleep(500 * time.Millisecond) + tctx.bitcoinCli("-generate") + + tctx.waitForSync() + + t.Run("sweep p2wkh to np2wkh address", tctx.testSweepToNP2WKH) + + time.Sleep(500 * time.Millisecond) + tctx.bitcoinCli("-generate") + + tctx.waitForSync() + + t.Run("sweep np2wkh to p2tr address", tctx.testSweepToP2TR) + + time.Sleep(500 * time.Millisecond) + tctx.bitcoinCli("-generate") + + tctx.waitForSync() + + t.Run("open channel lnd1 to lnd2", func(t *testing.T) { + _ = tctx.lnds[0].Lncli("connect", + tctx.lnds[1].idPubKey+"@127.0.0.1:"+tctx.lnds[1].p2p) + + resp := tctx.lnds[0].Lncli("openchannel", tctx.lnds[1].idPubKey, + "10000000", "5000000") + require.Equal(t, 64, len(resp["funding_txid"].(string))) + }) + + t.Run("open channel lnd2 to lnd3", func(t *testing.T) { + _ = tctx.lnds[1].Lncli("connect", + tctx.lnds[2].idPubKey+"@127.0.0.1:"+tctx.lnds[2].p2p) + + resp := tctx.lnds[1].Lncli("openchannel", tctx.lnds[2].idPubKey, + "10000000", "5000000") + require.Equal(t, 64, len(resp["funding_txid"].(string))) + }) + + // Confirm our channels. + time.Sleep(500 * time.Millisecond) + tctx.bitcoinCli("-generate", "5") + tctx.waitForSync() + tctx.bitcoinCli("-generate", "5") + tctx.waitForSync() + tctx.waitForGraphSync() + time.Sleep(3 * time.Second) + + t.Run("sign and verify messages", tctx.testEachSignVerifyEachOther) + + t.Run("each lnd pays every other lnd", tctx.testEachPaysEachOther) +} + +// testFundLnds funds each lnd instance in the test context with 1 BTC into +// a new P2TR address. +func (tctx *testContext) testFundLnds(t *testing.T) { + tctx.lndMtx.RLock() + defer tctx.lndMtx.RUnlock() + + for _, lnd := range tctx.lnds { + resp := lnd.Lncli("newaddress", "p2tr") + address := resp["address"].(string) + + tctx.bitcoinCli("-named", "sendtoaddress", + "address="+address, "amount=1", "fee_rate=25") + } +} + +// testSweepToP2WKH sweeps all of the nodes' on-chain funds into P2WKH +// addresses +func (tctx *testContext) testSweepToP2WKH(t *testing.T) { + tctx.lndMtx.RLock() + defer tctx.lndMtx.RUnlock() + + for _, lnd := range tctx.lnds { + resp := lnd.Lncli("newaddress", "p2wkh") + address := resp["address"].(string) + + resp = lnd.Lncli("sendcoins", "--sweepall", + address) + require.Equal(t, 64, len(resp["txid"].(string))) + + t.Logf("%s", lnd.idPubKey) + } +} + +// testSweepToNP2WKH sweeps all of the nodes' on-chain funds into NP2WKH +// addresses +func (tctx *testContext) testSweepToNP2WKH(t *testing.T) { + tctx.lndMtx.RLock() + defer tctx.lndMtx.RUnlock() + + for _, lnd := range tctx.lnds { + resp := lnd.Lncli("newaddress", "np2wkh") + address := resp["address"].(string) + + resp = lnd.Lncli("sendcoins", "--sweepall", + address) + require.Equal(t, 64, len(resp["txid"].(string))) + + t.Logf("%s", lnd.idPubKey) + } +} + +// testSweepToP2TR sweeps all of the nodes' on-chain funds into P2TR +// addresses +func (tctx *testContext) testSweepToP2TR(t *testing.T) { + tctx.lndMtx.RLock() + defer tctx.lndMtx.RUnlock() + + for _, lnd := range tctx.lnds { + resp := lnd.Lncli("newaddress", "p2tr") + address := resp["address"].(string) + + resp = lnd.Lncli("sendcoins", "--sweepall", + address) + require.Equal(t, 64, len(resp["txid"].(string))) + + t.Logf("%s", lnd.idPubKey) + } +} + +// testEachPaysEachOther sends LN payments from each LND to each other LND, +// testing both direct and chained payments. +func (tctx *testContext) testEachPaysEachOther(t *testing.T) { + tctx.testEachPair(func(lnd1, lnd2 *lndHarness) { + resp := lnd1.Lncli("addinvoice", "5000") + invoice := resp["payment_request"].(string) + + resp = lnd2.Lncli("payinvoice", "--timeout=10s", "--json", + "-f", invoice) + require.Equal(t, resp["status"].(string), "SUCCEEDED") + + t.Logf("%s paid %s", lnd2.idPubKey, + lnd1.idPubKey) + }) +} + +// testEachSignVerifyEachOther signs a message from each LND to each other LND, +// verifying the message on the second LND. +func (tctx *testContext) testEachSignVerifyEachOther(t *testing.T) { + tctx.testEachPair(func(lnd1, lnd2 *lndHarness) { + message := lnd1.idPubKey + " to " + lnd2.idPubKey + + resp := lnd1.Lncli("signmessage", message) + sig := resp["signature"].(string) + + resp = lnd2.Lncli("verifymessage", message, sig) + require.True(t, resp["valid"].(bool)) + + t.Logf(message) + }) +} + +// testContext manages the test environment. +type testContext struct { + t *testing.T + ctx context.Context + cancel context.CancelFunc + + tmpRoot string + + vaultPort string + vaultCmd *exec.Cmd + vaultMtx sync.RWMutex + vaultClient *api.Logical + + bitcoinDir string + bitcoinRPC string + bitcoinZB *net.TCPAddr + bitcoinZT *net.TCPAddr + bitcoindCmd *exec.Cmd + bitcoindMtx sync.RWMutex + bitcoindClient *api.Logical + + lndPath string + lndSignerPath string + lncliPath string + bitcoincliPath string + + lndMtx sync.RWMutex + lnds []*lndHarness +} + +//newTestContext creates a new test context. +func newTestContext(t *testing.T, ctx context.Context) *testContext { + t.Helper() + + tctx := &testContext{ + t: t, + lnds: make([]*lndHarness, 0, 3), + } + + cctx, cancel := context.WithCancel(ctx) + tctx.ctx = cctx + tctx.cancel = cancel + + // Create temp directory for test context. + tmpRoot, err := os.MkdirTemp("", "lndsigner-itest") + require.NoError(t, err) + tctx.tmpRoot = tmpRoot + + // Get binary paths + bitcoindPath, err := exec.LookPath("bitcoind") + require.NoError(t, err) + + tctx.lndPath, err = exec.LookPath("lnd") + require.NoError(tctx.t, err) + + tctx.lndSignerPath, err = exec.LookPath("lndsignerd") + require.NoError(tctx.t, err) + + tctx.lncliPath, err = exec.LookPath("lncli") + require.NoError(tctx.t, err) + + tctx.bitcoincliPath, err = exec.LookPath("bitcoin-cli") + require.NoError(tctx.t, err) + + // Start bitcoind + tctx.bitcoinDir = path.Join(tctx.tmpRoot, "bitcoin") + err = os.Mkdir(tctx.bitcoinDir, fs.ModeDir|0700) + require.NoError(t, err) + + tctx.bitcoinRPC = newPortString() + tctx.bitcoinZB = newPort() + tctx.bitcoinZT = newPort() + + tctx.bitcoindMtx.Lock() + defer tctx.bitcoindMtx.Unlock() + + tctx.bitcoindCmd = exec.CommandContext(ctx, bitcoindPath, "-server=1", + "-datadir="+tctx.bitcoinDir, "-listen=0", "-txindex=1", + "-regtest=1", "-rpcuser=user", "-rpcpassword=password", + "-rpcport="+tctx.bitcoinRPC, + "-zmqpubrawblock=tcp://"+tctx.bitcoinZB.String(), + "-zmqpubrawtx=tcp://"+tctx.bitcoinZT.String()) + + tctx.bitcoindCmd.Stderr = bytes.NewBuffer(make([]byte, 0)) + + err = tctx.bitcoindCmd.Start() + require.NoError(t, err) + go waitProc(t, tctx.bitcoindCmd, tctx.bitcoindMtx) + + // TODO(aakselrod): eliminate this + time.Sleep(3 * time.Second) + + // Mine blocks to give us funds and activate soft forks. + go func() { + tctx.bitcoinCli("createwallet", "default") + tctx.bitcoinCli("-generate", "1000") + }() + + // Start vault. + vaultPath, err := exec.LookPath("vault") + require.NoError(t, err) + + pluginPath, err := exec.LookPath("vault-plugin-lndsigner") + require.NoError(t, err) + + pluginDir := path.Join(tmpRoot, "vault_plugins") + err = os.Mkdir(pluginDir, fs.ModeDir|0700) + require.NoError(t, err) + + mustCopyFile(pluginPath, path.Join(pluginDir, "vault-plugin-lndsigner"), + 0700) + + tctx.vaultMtx.Lock() + defer tctx.vaultMtx.Unlock() + + tctx.vaultPort = newPortString() + tctx.vaultCmd = exec.CommandContext(cctx, vaultPath, "server", "-dev", + "-dev-root-token-id=root", "-dev-plugin-dir="+pluginDir, + "-dev-listen-address=127.0.0.1:"+tctx.vaultPort) + + tctx.vaultCmd.Stderr = bytes.NewBuffer(make([]byte, 0)) + + err = tctx.vaultCmd.Start() + require.NoError(t, err) + go waitProc(t, tctx.vaultCmd, tctx.vaultMtx) + + vaultClientConf := api.DefaultConfig() + vaultClientConf.Address = "http://127.0.0.1:" + tctx.vaultPort + + vaultClient, err := api.NewClient(vaultClientConf) + require.NoError(t, err) + + vaultClient.SetToken("root") + + tctx.vaultClient = vaultClient.Logical() + + vaultSys := vaultClient.Sys() + err = vaultSys.Mount("lndsigner", &api.MountInput{ + Type: "vault-plugin-lndsigner", + }) + require.NoError(t, err) + + return tctx +} + +// bitcoinCli sends a command to the test context's bitcoind. +func (tctx *testContext) bitcoinCli(args ...string) map[string]interface{} { + tctx.t.Helper() + + bitcoinCliCmd := exec.CommandContext(tctx.ctx, tctx.bitcoincliPath, + append([]string{"-datadir=" + tctx.bitcoinDir, + "-rpcport=" + tctx.bitcoinRPC, "-rpcuser=user", + "-rpcpassword=password", "-rpcwaittimeout=5"}, + args...)...) + + stdErrBuf := bytes.NewBuffer(make([]byte, 0)) + bitcoinCliCmd.Stderr = stdErrBuf + + stdOutBuf := bytes.NewBuffer(make([]byte, 0)) + bitcoinCliCmd.Stdout = stdOutBuf + + err := bitcoinCliCmd.Start() + require.NoError(tctx.t, err) + + // If there's an error on exit, show stderr. + err = bitcoinCliCmd.Wait() + require.NoError(tctx.t, err, string(stdErrBuf.Bytes())) + + stdout := string(stdOutBuf.Bytes()) + + // sendtoaddress only returns a txid on success. In this case, the + // first argument is "-named". + if len(args) > 1 && args[1] == "sendtoaddress" { + return map[string]interface{}{ + "txid": stdout[:64], + } + } + + // If there's an error parsing the JSON, show stdout to see the issue. + resp := make(map[string]interface{}) + err = json.Unmarshal([]byte(stdout), &resp) + require.NoError(tctx.t, err, stdout) + + return resp +} + +// Close cleans up the test context. +func (tctx *testContext) Close() { + tctx.t.Helper() + + for _, lnd := range tctx.lnds { + lnd.Close() + } + + _ = tctx.bitcoindCmd.Process.Signal(os.Interrupt) + _ = tctx.vaultCmd.Process.Signal(os.Interrupt) + + tctx.cancel() + + <-tctx.ctx.Done() + + // os.RemoveAll(tctx.tmpRoot) +} + +// addNode adds a new LND node to the test context, complete with its own +// lndsignerd. reqPath can be used to specify create or import, reqData must +// have a network and optional seed/passphrase, and unixSocket may be used to +// specify that a UNIX socket should be used to communicate between LND and +// lndsignerd. +func (tctx *testContext) addNode(reqPath string, + reqData map[string]interface{}, unixSocket bool) string { + + tctx.t.Helper() + + resp, err := tctx.vaultClient.Write(reqPath, reqData) + require.NoError(tctx.t, err) + + pubKey, ok := resp.Data["node"].(string) + require.True(tctx.t, ok) + require.Equal(tctx.t, 66, len(pubKey)) + + lnd := &lndHarness{ + tctx: tctx, + idPubKey: pubKey, + unixSocket: unixSocket, + } + + lnd.Start() + + tctx.lndMtx.Lock() + tctx.lnds = append(tctx.lnds, lnd) + tctx.lndMtx.Unlock() + + return pubKey +} + +// waitForSync ensures that each LND has caught up to the blocks that have been +// mined on bitcoind. +func (tctx *testContext) waitForSync() { + tctx.t.Helper() + + var ( + blocks, synced int + resp map[string]interface{} + ) + + for blocks < 1000 { + resp = tctx.bitcoinCli("getblockchaininfo") + blocks = int(resp["blocks"].(float64)) + } + + tctx.lndMtx.RLock() + defer tctx.lndMtx.RUnlock() + + for _, lnd := range tctx.lnds { + synced = 0 + + for synced != blocks { + time.Sleep(100 * time.Millisecond) + + resp = lnd.Lncli("getinfo") + require.Equal(tctx.t, resp["identity_pubkey"].(string), + lnd.idPubKey) + + synced = int(resp["block_height"].(float64)) + } + } +} + +// waitForGraphSync ensures that each LND has a synchronized graph. +func (tctx *testContext) waitForGraphSync() { + tctx.t.Helper() + + var ( + nodes, chans int + synced bool + resp map[string]interface{} + ) + + tctx.lndMtx.RLock() + defer tctx.lndMtx.RUnlock() + + for !synced { + synced = true + + for _, lnd := range tctx.lnds { + + resp = lnd.Lncli("getnetworkinfo") + + gotNodes := int(resp["num_nodes"].(float64)) + if gotNodes != nodes { + synced = false + + if gotNodes > nodes { + nodes = gotNodes + } + } + + gotChans := int(resp["num_channels"].(float64)) + if gotChans != chans { + synced = false + + if gotChans > chans { + chans = gotChans + } + } + } + + time.Sleep(500 * time.Millisecond) + } +} + +// testEach runs a function for each LND instance, in parallel. +func (tctx *testContext) testEach(test func(lnd *lndHarness)) { + tctx.t.Helper() + + tctx.lndMtx.RLock() + defer tctx.lndMtx.RUnlock() + + var wg sync.WaitGroup + wg.Add(len(tctx.lnds)) + + for _, lnd := range tctx.lnds { + innerLnd := lnd + + go func() { + defer wg.Done() + + test(innerLnd) + }() + } + + wg.Wait() +} + +// testEachPair runs a function for each pair of LNDs in parallel, avoiding +// testing an LND instance with itself. +func (tctx *testContext) testEachPair(test func(lnd1, lnd2 *lndHarness)) { + tctx.t.Helper() + + tctx.lndMtx.RLock() + defer tctx.lndMtx.RUnlock() + + var wg sync.WaitGroup + wg.Add(len(tctx.lnds)*len(tctx.lnds) - len(tctx.lnds)) + + for i, lnd1 := range tctx.lnds { + for j, lnd2 := range tctx.lnds { + innerLnd1 := lnd1 + innerLnd2 := lnd2 + + if i == j { + continue + } + + go func() { + defer wg.Done() + + test(innerLnd1, innerLnd2) + }() + } + } + + wg.Wait() +} + +// lndHarness manages a single lndsignerd-backed instance of LND. +type lndHarness struct { + tctx *testContext + idPubKey string + + unixSocket bool + + lndSignerCmd *exec.Cmd + lndSignerMtx sync.RWMutex + + lndDir string + lncliPath string + rpc string + p2p string + lndCmd *exec.Cmd + lndMtx sync.RWMutex +} + +// Start takes the initial configuration (tctx, idPubKey, and unixSocket) and +// starts lndsignerd and LND. +func (l *lndHarness) Start() { + l.tctx.t.Helper() + + // Start lndsignerd. + l.lndDir = path.Join(l.tctx.tmpRoot, fmt.Sprintf("lnd%s", l.idPubKey)) + err := os.Mkdir(l.lndDir, fs.ModeDir|0700) + require.NoError(l.tctx.t, err) + + signerAddr := "127.0.0.1:" + newPortString() + fullSignerAddr := "tcp://" + signerAddr + + if l.unixSocket { + signerAddr := path.Join(l.lndDir, "signer.socket") + fullSignerAddr = "unix://" + signerAddr + } + + l.lndSignerMtx.Lock() + defer l.lndSignerMtx.Unlock() + + l.lndSignerCmd = exec.CommandContext(l.tctx.ctx, l.tctx.lndSignerPath, + "--rpclisten="+fullSignerAddr, "--nodepubkey="+l.idPubKey, + "--tlscertpath=./testdata/tls.cert", + "--tlskeypath=./testdata/tls.key", "--network=regtest", + ) + + l.lndSignerCmd.Env = append(l.lndSignerCmd.Env, + "VAULT_ADDR=http://127.0.0.1:"+l.tctx.vaultPort, + "VAULT_TOKEN=root", + ) + + l.lndSignerCmd.Stderr = bytes.NewBuffer(make([]byte, 0)) + + err = l.lndSignerCmd.Start() + require.NoError(l.tctx.t, err) + go waitProc(l.tctx.t, l.lndSignerCmd, l.lndSignerMtx) + + // Start lnd. + acctsResp, err := l.tctx.vaultClient.ReadWithData( + "lndsigner/lnd-nodes/accounts", + map[string][]string{ + "node": []string{l.idPubKey}, + }, + ) + require.NoError(l.tctx.t, err) + + acctList, ok := acctsResp.Data["acctList"].(string) + require.True(l.tctx.t, ok) + + accounts, err := lndsigner.GetAccounts(acctList) + require.NoError(l.tctx.t, err) + + grpcAccounts := make([]*itest.WatchOnlyAccount, 0, + len(accounts)) + + for derPath, xPub := range accounts { + grpcAccounts = append(grpcAccounts, + &itest.WatchOnlyAccount{ + Purpose: derPath[0], + CoinType: derPath[1], + Account: derPath[2], + Xpub: xPub, + }) + } + + l.rpc = newPortString() + l.p2p = newPortString() + + l.lndMtx.Lock() + defer l.lndMtx.Unlock() + + l.lndCmd = exec.CommandContext(l.tctx.ctx, l.tctx.lndPath, + "--lnddir="+l.lndDir, "--norest", "--listen="+l.p2p, + "--rpclisten="+l.rpc, "--trickledelay=1", "--bitcoin.active", + "--bitcoin.regtest", "--bitcoin.node=bitcoind", + "--bitcoind.rpcuser=user", "--bitcoind.rpcpass=password", + "--bitcoind.rpchost=127.0.0.1:"+l.tctx.bitcoinRPC, + "--bitcoind.zmqpubrawblock=tcp://"+l.tctx.bitcoinZB.String(), + "--bitcoind.zmqpubrawtx=tcp://"+l.tctx.bitcoinZT.String(), + "--remotesigner.enable", + "--remotesigner.rpchost="+signerAddr, + "--remotesigner.tlscertpath=./testdata/tls.cert", + "--remotesigner.macaroonpath=./testdata/signer.custom.macaroon", + ) + + l.lndCmd.Stderr = bytes.NewBuffer(make([]byte, 0)) + + err = l.lndCmd.Start() + require.NoError(l.tctx.t, err) + go waitProc(l.tctx.t, l.lndCmd, l.lndMtx) + + // TODO(aakselrod): eliminate this + time.Sleep(3 * time.Second) + + // Initialize with the accounts information. + tlsCreds, err := credentials.NewClientTLSFromFile( + path.Join(l.lndDir, "tls.cert"), "") + require.NoError(l.tctx.t, err) + + tlsCredsOption := grpc.WithTransportCredentials(tlsCreds) + unlockerConn, err := grpc.Dial("127.0.0.1:"+l.rpc, tlsCredsOption) + require.NoError(l.tctx.t, err) + + unlocker := itest.NewWalletUnlockerClient(unlockerConn) + _, err = unlocker.InitWallet(l.tctx.ctx, &itest.InitWalletRequest{ + WalletPassword: []byte("weks1234"), + WatchOnly: &itest.WatchOnly{ + Accounts: grpcAccounts, + }, + }) + require.NoError(l.tctx.t, err) + + // TODO(aakselrod): eliminate this + time.Sleep(3 * time.Second) +} + +// CLose cleans up LND and lndsignerd. +func (l *lndHarness) Close() { + l.tctx.t.Helper() + + _ = l.lndCmd.Process.Signal(os.Interrupt) + _ = l.lndSignerCmd.Process.Signal(os.Interrupt) +} + +// LnCli calls lncli against the harness' LND instance. +func (l *lndHarness) Lncli(args ...string) map[string]interface{} { + l.tctx.t.Helper() + + lnCliCmd := exec.CommandContext(l.tctx.ctx, l.tctx.lncliPath, + append([]string{"--lnddir=" + l.lndDir, + "--rpcserver=127.0.0.1:" + l.rpc, + "--network=regtest", + "--tlscertpath=./testdata/tls.cert"}, args...)...) + + outBuf := bytes.NewBuffer(make([]byte, 0)) + lnCliCmd.Stdout = outBuf + + errBuf := bytes.NewBuffer(make([]byte, 0)) + lnCliCmd.Stderr = errBuf + + err := lnCliCmd.Start() + require.NoError(l.tctx.t, err) + + err = lnCliCmd.Wait() + require.NoError(l.tctx.t, err, + fmt.Sprintf("lncli (args %+v) failed:\n%s\n%s", args, + errBuf.Bytes(), outBuf.Bytes())) + + stdout := string(outBuf.Bytes()) + + resp := make(map[string]interface{}) + err = json.Unmarshal([]byte(stdout), &resp) + require.NoError(l.tctx.t, err) + + return resp +} + +// waitProc should be run in a goroutine to wait for a long-running program, +// such as vault, bitcoind, lndsignerd, or lnd, to stop. If the program returns +// an exit error, the program's entire stderr is logged. +func waitProc(t *testing.T, cmd *exec.Cmd, cmdMtx sync.RWMutex) { + t.Helper() + + cmdMtx.RLock() + defer cmdMtx.RUnlock() + + err := cmd.Wait() + + if err != nil && cmd.Stderr != nil { + t.Logf("Command %s stderr:\n%s", cmd.Path, + string(cmd.Stderr.(*bytes.Buffer).Bytes())) + } +} + +// newPort finds an open TCP port to listen on. +func newPort() *net.TCPAddr { + lis, err := net.Listen("tcp", "127.0.0.1:") + if err != nil { + panic(err) + } + defer lis.Close() + return lis.Addr().(*net.TCPAddr) +} + +// newPortString finds an open TCP port to listen on and returns the port +// number as a string. +func newPortString() string { + return fmt.Sprintf("%d", newPort().Port) +} + +// mustCopyFile copies a file and panics on error. +func mustCopyFile(src, dst string, mode os.FileMode) { + fileBytes, err := os.ReadFile(src) + if err != nil { + panic(err) + } + + err = os.WriteFile(dst, fileBytes, mode) + if err != nil { + panic(err) + } +} diff --git a/itest/testdata/signer.custom.macaroon b/itest/testdata/signer.custom.macaroon new file mode 100644 index 0000000..a027242 Binary files /dev/null and b/itest/testdata/signer.custom.macaroon differ diff --git a/itest/testdata/tls.cert b/itest/testdata/tls.cert new file mode 100644 index 0000000..80f767d --- /dev/null +++ b/itest/testdata/tls.cert @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICNzCCAd2gAwIBAgIRANHlunBL3zQaJ78MO4EIIeAwCgYIKoZIzj0EAwIwMjEf +MB0GA1UEChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEPMA0GA1UEAxMGcG9wLW9z +MB4XDTIyMTIyMzAwNDAwNloXDTQ5MTIzMTIzNTk1OVowMjEfMB0GA1UEChMWbG5k +IGF1dG9nZW5lcmF0ZWQgY2VydDEPMA0GA1UEAxMGcG9wLW9zMFkwEwYHKoZIzj0C +AQYIKoZIzj0DAQcDQgAEhSIOTl8x7ow1ZY3qbp0Kmhkxzg5jpJGaUuK6f14Ah9eW +qNImaFHUhyeRBIf4By8+89bncMgf7MOO/sG/rbiag6OB0zCB0DAOBgNVHQ8BAf8E +BAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQUxS3oNTCQN5YaPn5DjvRJDupW0B4weQYDVR0RBHIwcIIGcG9wLW9zggls +b2NhbGhvc3SCBHVuaXiCCnVuaXhwYWNrZXSCB2J1ZmNvbm6HBH8AAAGHEAAAAAAA +AAAAAAAAAAAAAAGHBMCoRHSHBMCoegGHBAoAAgKHBKwRAAGHEP6AAAAAAAAA7Gx/ +z7i+5+4wCgYIKoZIzj0EAwIDSAAwRQIgMxROY+FoZyWSj5aaz1v/BgkhaJ2nor5M +XtbbkeYmp8YCIQCpqaZjWIfVxnkW+2a/d57okniB5es2vI0XgUOzVqgVwg== +-----END CERTIFICATE----- diff --git a/itest/testdata/tls.key b/itest/testdata/tls.key new file mode 100644 index 0000000..0e5a389 --- /dev/null +++ b/itest/testdata/tls.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAYdQ6uMUdLhMFc6aW1YGko0w6iTyrKO+OQwmB5V+yQ3oAoGCCqGSM49 +AwEHoUQDQgAEhSIOTl8x7ow1ZY3qbp0Kmhkxzg5jpJGaUuK6f14Ah9eWqNImaFHU +hyeRBIf4By8+89bncMgf7MOO/sG/rbiagw== +-----END EC PRIVATE KEY----- diff --git a/itest/walletunlocker.pb.go b/itest/walletunlocker.pb.go new file mode 100644 index 0000000..0202fb9 --- /dev/null +++ b/itest/walletunlocker.pb.go @@ -0,0 +1,423 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.25.0-devel +// protoc v3.14.0 +// source: walletunlocker.proto + +package itest + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type InitWalletRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // + //wallet_password is the passphrase that should be used to encrypt the + //wallet. This MUST be at least 8 chars in length. After creation, this + //password is required to unlock the daemon. When using REST, this field + //must be encoded as base64. + WalletPassword []byte `protobuf:"bytes,1,opt,name=wallet_password,json=walletPassword,proto3" json:"wallet_password,omitempty"` + // + //watch_only is the third option of initializing a wallet: by importing + //account xpubs only and therefore creating a watch-only wallet that does not + //contain any private keys. That means the wallet won't be able to sign for + //any of the keys and _needs_ to be run with a remote signer that has the + //corresponding private keys and can serve signing RPC requests. + WatchOnly *WatchOnly `protobuf:"bytes,9,opt,name=watch_only,json=watchOnly,proto3" json:"watch_only,omitempty"` +} + +func (x *InitWalletRequest) Reset() { + *x = InitWalletRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_walletunlocker_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *InitWalletRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InitWalletRequest) ProtoMessage() {} + +func (x *InitWalletRequest) ProtoReflect() protoreflect.Message { + mi := &file_walletunlocker_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InitWalletRequest.ProtoReflect.Descriptor instead. +func (*InitWalletRequest) Descriptor() ([]byte, []int) { + return file_walletunlocker_proto_rawDescGZIP(), []int{0} +} + +func (x *InitWalletRequest) GetWalletPassword() []byte { + if x != nil { + return x.WalletPassword + } + return nil +} + +func (x *InitWalletRequest) GetWatchOnly() *WatchOnly { + if x != nil { + return x.WatchOnly + } + return nil +} + +type InitWalletResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // + //The binary serialized admin macaroon that can be used to access the daemon + //after creating the wallet. If the stateless_init parameter was set to true, + //this is the ONLY copy of the macaroon and MUST be stored safely by the + //caller. Otherwise a copy of this macaroon is also persisted on disk by the + //daemon, together with other macaroon files. + AdminMacaroon []byte `protobuf:"bytes,1,opt,name=admin_macaroon,json=adminMacaroon,proto3" json:"admin_macaroon,omitempty"` +} + +func (x *InitWalletResponse) Reset() { + *x = InitWalletResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_walletunlocker_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *InitWalletResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InitWalletResponse) ProtoMessage() {} + +func (x *InitWalletResponse) ProtoReflect() protoreflect.Message { + mi := &file_walletunlocker_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InitWalletResponse.ProtoReflect.Descriptor instead. +func (*InitWalletResponse) Descriptor() ([]byte, []int) { + return file_walletunlocker_proto_rawDescGZIP(), []int{1} +} + +func (x *InitWalletResponse) GetAdminMacaroon() []byte { + if x != nil { + return x.AdminMacaroon + } + return nil +} + +type WatchOnly struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // + //The list of accounts to import. There _must_ be an account for all of lnd's + //main key scopes: BIP49/BIP84 (m/49'/0'/0', m/84'/0'/0', note that the + //coin type is always 0, even for testnet/regtest) and lnd's internal key + //scope (m/1017'/'/'), where account is the key family as + //defined in `keychain/derivation.go` (currently indices 0 to 9). + Accounts []*WatchOnlyAccount `protobuf:"bytes,3,rep,name=accounts,proto3" json:"accounts,omitempty"` +} + +func (x *WatchOnly) Reset() { + *x = WatchOnly{} + if protoimpl.UnsafeEnabled { + mi := &file_walletunlocker_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WatchOnly) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchOnly) ProtoMessage() {} + +func (x *WatchOnly) ProtoReflect() protoreflect.Message { + mi := &file_walletunlocker_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchOnly.ProtoReflect.Descriptor instead. +func (*WatchOnly) Descriptor() ([]byte, []int) { + return file_walletunlocker_proto_rawDescGZIP(), []int{2} +} + +func (x *WatchOnly) GetAccounts() []*WatchOnlyAccount { + if x != nil { + return x.Accounts + } + return nil +} + +type WatchOnlyAccount struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // + //Purpose is the first number in the derivation path, must be either 49, 84 + //or 1017. + Purpose uint32 `protobuf:"varint,1,opt,name=purpose,proto3" json:"purpose,omitempty"` + // + //Coin type is the second number in the derivation path, this is _always_ 0 + //for purposes 49 and 84. It only needs to be set to 1 for purpose 1017 on + //testnet or regtest. + CoinType uint32 `protobuf:"varint,2,opt,name=coin_type,json=coinType,proto3" json:"coin_type,omitempty"` + // + //Account is the third number in the derivation path. For purposes 49 and 84 + //at least the default account (index 0) needs to be created but optional + //additional accounts are allowed. For purpose 1017 there needs to be exactly + //one account for each of the key families defined in `keychain/derivation.go` + //(currently indices 0 to 9) + Account uint32 `protobuf:"varint,3,opt,name=account,proto3" json:"account,omitempty"` + // + //The extended public key at depth 3 for the given account. + Xpub string `protobuf:"bytes,4,opt,name=xpub,proto3" json:"xpub,omitempty"` +} + +func (x *WatchOnlyAccount) Reset() { + *x = WatchOnlyAccount{} + if protoimpl.UnsafeEnabled { + mi := &file_walletunlocker_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WatchOnlyAccount) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchOnlyAccount) ProtoMessage() {} + +func (x *WatchOnlyAccount) ProtoReflect() protoreflect.Message { + mi := &file_walletunlocker_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchOnlyAccount.ProtoReflect.Descriptor instead. +func (*WatchOnlyAccount) Descriptor() ([]byte, []int) { + return file_walletunlocker_proto_rawDescGZIP(), []int{3} +} + +func (x *WatchOnlyAccount) GetPurpose() uint32 { + if x != nil { + return x.Purpose + } + return 0 +} + +func (x *WatchOnlyAccount) GetCoinType() uint32 { + if x != nil { + return x.CoinType + } + return 0 +} + +func (x *WatchOnlyAccount) GetAccount() uint32 { + if x != nil { + return x.Account + } + return 0 +} + +func (x *WatchOnlyAccount) GetXpub() string { + if x != nil { + return x.Xpub + } + return "" +} + +var File_walletunlocker_proto protoreflect.FileDescriptor + +var file_walletunlocker_proto_rawDesc = []byte{ + 0x0a, 0x14, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x75, 0x6e, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x72, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x22, 0x6d, 0x0a, + 0x11, 0x49, 0x6e, 0x69, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x5f, 0x70, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x77, 0x61, 0x6c, + 0x6c, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2f, 0x0a, 0x0a, 0x77, + 0x61, 0x74, 0x63, 0x68, 0x5f, 0x6f, 0x6e, 0x6c, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x10, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x4f, 0x6e, 0x6c, + 0x79, 0x52, 0x09, 0x77, 0x61, 0x74, 0x63, 0x68, 0x4f, 0x6e, 0x6c, 0x79, 0x22, 0x3b, 0x0a, 0x12, + 0x49, 0x6e, 0x69, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x5f, 0x6d, 0x61, 0x63, 0x61, + 0x72, 0x6f, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0d, 0x61, 0x64, 0x6d, 0x69, + 0x6e, 0x4d, 0x61, 0x63, 0x61, 0x72, 0x6f, 0x6f, 0x6e, 0x22, 0x40, 0x0a, 0x09, 0x57, 0x61, 0x74, + 0x63, 0x68, 0x4f, 0x6e, 0x6c, 0x79, 0x12, 0x33, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, + 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x4f, 0x6e, 0x6c, 0x79, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x52, 0x08, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x22, 0x77, 0x0a, 0x10, 0x57, + 0x61, 0x74, 0x63, 0x68, 0x4f, 0x6e, 0x6c, 0x79, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, + 0x18, 0x0a, 0x07, 0x70, 0x75, 0x72, 0x70, 0x6f, 0x73, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x07, 0x70, 0x75, 0x72, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, 0x69, + 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x63, 0x6f, + 0x69, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x12, 0x12, 0x0a, 0x04, 0x78, 0x70, 0x75, 0x62, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x78, 0x70, 0x75, 0x62, 0x32, 0x53, 0x0a, 0x0e, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x55, 0x6e, + 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x12, 0x41, 0x0a, 0x0a, 0x49, 0x6e, 0x69, 0x74, 0x57, 0x61, + 0x6c, 0x6c, 0x65, 0x74, 0x12, 0x18, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x49, 0x6e, 0x69, + 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, + 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, + 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x26, 0x5a, 0x24, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x62, 0x6f, 0x74, 0x74, 0x6c, 0x65, 0x70, 0x61, + 0x79, 0x2f, 0x6c, 0x6e, 0x64, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x72, 0x2f, 0x69, 0x74, 0x65, 0x73, + 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_walletunlocker_proto_rawDescOnce sync.Once + file_walletunlocker_proto_rawDescData = file_walletunlocker_proto_rawDesc +) + +func file_walletunlocker_proto_rawDescGZIP() []byte { + file_walletunlocker_proto_rawDescOnce.Do(func() { + file_walletunlocker_proto_rawDescData = protoimpl.X.CompressGZIP(file_walletunlocker_proto_rawDescData) + }) + return file_walletunlocker_proto_rawDescData +} + +var file_walletunlocker_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_walletunlocker_proto_goTypes = []interface{}{ + (*InitWalletRequest)(nil), // 0: lnrpc.InitWalletRequest + (*InitWalletResponse)(nil), // 1: lnrpc.InitWalletResponse + (*WatchOnly)(nil), // 2: lnrpc.WatchOnly + (*WatchOnlyAccount)(nil), // 3: lnrpc.WatchOnlyAccount +} +var file_walletunlocker_proto_depIdxs = []int32{ + 2, // 0: lnrpc.InitWalletRequest.watch_only:type_name -> lnrpc.WatchOnly + 3, // 1: lnrpc.WatchOnly.accounts:type_name -> lnrpc.WatchOnlyAccount + 0, // 2: lnrpc.WalletUnlocker.InitWallet:input_type -> lnrpc.InitWalletRequest + 1, // 3: lnrpc.WalletUnlocker.InitWallet:output_type -> lnrpc.InitWalletResponse + 3, // [3:4] is the sub-list for method output_type + 2, // [2:3] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_walletunlocker_proto_init() } +func file_walletunlocker_proto_init() { + if File_walletunlocker_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_walletunlocker_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*InitWalletRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_walletunlocker_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*InitWalletResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_walletunlocker_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*WatchOnly); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_walletunlocker_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*WatchOnlyAccount); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_walletunlocker_proto_rawDesc, + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_walletunlocker_proto_goTypes, + DependencyIndexes: file_walletunlocker_proto_depIdxs, + MessageInfos: file_walletunlocker_proto_msgTypes, + }.Build() + File_walletunlocker_proto = out.File + file_walletunlocker_proto_rawDesc = nil + file_walletunlocker_proto_goTypes = nil + file_walletunlocker_proto_depIdxs = nil +} diff --git a/itest/walletunlocker.proto b/itest/walletunlocker.proto new file mode 100644 index 0000000..eeb86af --- /dev/null +++ b/itest/walletunlocker.proto @@ -0,0 +1,112 @@ +syntax = "proto3"; + +package lnrpc; + +option go_package = "github.com/bottlepay/lndsigner/itest"; + +/* + * Comments in this file will be directly parsed into the API + * Documentation as descriptions of the associated method, message, or field. + * These descriptions should go right above the definition of the object, and + * can be in either block or // comment format. + * + * An RPC method can be matched to an lncli command by placing a line in the + * beginning of the description in exactly the following format: + * lncli: `methodname` + * + * Failure to specify the exact name of the command will cause documentation + * generation to fail. + * + * More information on how exactly the gRPC documentation is generated from + * this proto file can be found here: + * https://github.com/lightninglabs/lightning-api + */ + +// WalletUnlocker is a service that is used to set up a wallet password for +// lnd at first startup, and unlock a previously set up wallet. +service WalletUnlocker { + /* + InitWallet is used when lnd is starting up for the first time to fully + initialize the daemon and its internal wallet. At the very least a wallet + password must be provided. This will be used to encrypt sensitive material + on disk. + + In the case of a recovery scenario, the user can also specify their aezeed + mnemonic and passphrase. If set, then the daemon will use this prior state + to initialize its internal wallet. + + Alternatively, this can be used along with the GenSeed RPC to obtain a + seed, then present it to the user. Once it has been verified by the user, + the seed can be fed into this RPC in order to commit the new wallet. + */ + rpc InitWallet(InitWalletRequest) returns (InitWalletResponse); +} + +message InitWalletRequest { + /* + wallet_password is the passphrase that should be used to encrypt the + wallet. This MUST be at least 8 chars in length. After creation, this + password is required to unlock the daemon. When using REST, this field + must be encoded as base64. + */ + bytes wallet_password = 1; + + /* + watch_only is the third option of initializing a wallet: by importing + account xpubs only and therefore creating a watch-only wallet that does not + contain any private keys. That means the wallet won't be able to sign for + any of the keys and _needs_ to be run with a remote signer that has the + corresponding private keys and can serve signing RPC requests. + */ + WatchOnly watch_only = 9; +} +message InitWalletResponse { + /* + The binary serialized admin macaroon that can be used to access the daemon + after creating the wallet. If the stateless_init parameter was set to true, + this is the ONLY copy of the macaroon and MUST be stored safely by the + caller. Otherwise a copy of this macaroon is also persisted on disk by the + daemon, together with other macaroon files. + */ + bytes admin_macaroon = 1; +} + +message WatchOnly { + /* + The list of accounts to import. There _must_ be an account for all of lnd's + main key scopes: BIP49/BIP84 (m/49'/0'/0', m/84'/0'/0', note that the + coin type is always 0, even for testnet/regtest) and lnd's internal key + scope (m/1017'/'/'), where account is the key family as + defined in `keychain/derivation.go` (currently indices 0 to 9). + */ + repeated WatchOnlyAccount accounts = 3; +} + +message WatchOnlyAccount { + /* + Purpose is the first number in the derivation path, must be either 49, 84 + or 1017. + */ + uint32 purpose = 1; + + /* + Coin type is the second number in the derivation path, this is _always_ 0 + for purposes 49 and 84. It only needs to be set to 1 for purpose 1017 on + testnet or regtest. + */ + uint32 coin_type = 2; + + /* + Account is the third number in the derivation path. For purposes 49 and 84 + at least the default account (index 0) needs to be created but optional + additional accounts are allowed. For purpose 1017 there needs to be exactly + one account for each of the key families defined in `keychain/derivation.go` + (currently indices 0 to 9) + */ + uint32 account = 3; + + /* + The extended public key at depth 3 for the given account. + */ + string xpub = 4; +} diff --git a/itest/walletunlocker_grpc.pb.go b/itest/walletunlocker_grpc.pb.go new file mode 100644 index 0000000..da38cfc --- /dev/null +++ b/itest/walletunlocker_grpc.pb.go @@ -0,0 +1,127 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. + +package itest + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +// WalletUnlockerClient is the client API for WalletUnlocker service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type WalletUnlockerClient interface { + // + //InitWallet is used when lnd is starting up for the first time to fully + //initialize the daemon and its internal wallet. At the very least a wallet + //password must be provided. This will be used to encrypt sensitive material + //on disk. + // + //In the case of a recovery scenario, the user can also specify their aezeed + //mnemonic and passphrase. If set, then the daemon will use this prior state + //to initialize its internal wallet. + // + //Alternatively, this can be used along with the GenSeed RPC to obtain a + //seed, then present it to the user. Once it has been verified by the user, + //the seed can be fed into this RPC in order to commit the new wallet. + InitWallet(ctx context.Context, in *InitWalletRequest, opts ...grpc.CallOption) (*InitWalletResponse, error) +} + +type walletUnlockerClient struct { + cc grpc.ClientConnInterface +} + +func NewWalletUnlockerClient(cc grpc.ClientConnInterface) WalletUnlockerClient { + return &walletUnlockerClient{cc} +} + +func (c *walletUnlockerClient) InitWallet(ctx context.Context, in *InitWalletRequest, opts ...grpc.CallOption) (*InitWalletResponse, error) { + out := new(InitWalletResponse) + err := c.cc.Invoke(ctx, "/lnrpc.WalletUnlocker/InitWallet", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// WalletUnlockerServer is the server API for WalletUnlocker service. +// All implementations must embed UnimplementedWalletUnlockerServer +// for forward compatibility +type WalletUnlockerServer interface { + // + //InitWallet is used when lnd is starting up for the first time to fully + //initialize the daemon and its internal wallet. At the very least a wallet + //password must be provided. This will be used to encrypt sensitive material + //on disk. + // + //In the case of a recovery scenario, the user can also specify their aezeed + //mnemonic and passphrase. If set, then the daemon will use this prior state + //to initialize its internal wallet. + // + //Alternatively, this can be used along with the GenSeed RPC to obtain a + //seed, then present it to the user. Once it has been verified by the user, + //the seed can be fed into this RPC in order to commit the new wallet. + InitWallet(context.Context, *InitWalletRequest) (*InitWalletResponse, error) + mustEmbedUnimplementedWalletUnlockerServer() +} + +// UnimplementedWalletUnlockerServer must be embedded to have forward compatible implementations. +type UnimplementedWalletUnlockerServer struct { +} + +func (UnimplementedWalletUnlockerServer) InitWallet(context.Context, *InitWalletRequest) (*InitWalletResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method InitWallet not implemented") +} +func (UnimplementedWalletUnlockerServer) mustEmbedUnimplementedWalletUnlockerServer() {} + +// UnsafeWalletUnlockerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to WalletUnlockerServer will +// result in compilation errors. +type UnsafeWalletUnlockerServer interface { + mustEmbedUnimplementedWalletUnlockerServer() +} + +func RegisterWalletUnlockerServer(s grpc.ServiceRegistrar, srv WalletUnlockerServer) { + s.RegisterService(&WalletUnlocker_ServiceDesc, srv) +} + +func _WalletUnlocker_InitWallet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(InitWalletRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletUnlockerServer).InitWallet(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lnrpc.WalletUnlocker/InitWallet", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletUnlockerServer).InitWallet(ctx, req.(*InitWalletRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// WalletUnlocker_ServiceDesc is the grpc.ServiceDesc for WalletUnlocker service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var WalletUnlocker_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "lnrpc.WalletUnlocker", + HandlerType: (*WalletUnlockerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "InitWallet", + Handler: _WalletUnlocker_InitWallet_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "walletunlocker.proto", +} diff --git a/lndsigner.go b/lndsigner.go index 6889e1b..cad0bfe 100644 --- a/lndsigner.go +++ b/lndsigner.go @@ -9,10 +9,13 @@ import ( "context" "crypto/tls" "crypto/x509" + "encoding/json" "fmt" "net" "os" "os/signal" + "strconv" + "strings" "sync" "syscall" @@ -228,3 +231,60 @@ func parseNetwork(addr net.Addr) string { func ListenOnAddress(addr net.Addr) (net.Listener, error) { return net.Listen(parseNetwork(addr), addr.String()) } + +func GetAccounts(acctList string) (map[[3]uint32]string, error) { + accounts := make(map[[3]uint32]string) + + elements := make(map[string]interface{}) + + err := json.Unmarshal([]byte(acctList), &elements) + if err != nil { + return nil, err + } + + acctElements, ok := elements["accounts"].([]interface{}) + if !ok { + return nil, fmt.Errorf("no accounts returned in JSON") + } + + for _, interEl := range acctElements { + acctEl, ok := interEl.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("account is not an object") + } + + strKey, ok := acctEl["extended_public_key"].(string) + if !ok { + return nil, fmt.Errorf("account has no extended pubkey") + } + + strDerPath, ok := acctEl["derivation_path"].(string) + if !ok { + return nil, fmt.Errorf("account has no derivation path") + } + + pathEls := strings.Split(strDerPath, "/") + if len(pathEls) != 4 || pathEls[0] != "m" { + return nil, fmt.Errorf("invalid derivation path") + } + + var derPath [3]uint32 + for idx, el := range pathEls[1:] { + if !strings.HasSuffix(el, "'") { + return nil, fmt.Errorf("acct derivation path "+ + "element %d not hardened", idx) + } + + intEl, err := strconv.ParseUint(el[:len(el)-1], 10, 32) + if err != nil { + return nil, err + } + + derPath[idx] = uint32(intEl) + } + + accounts[derPath] = strKey + } + + return accounts, nil +}