From 2dd399ca523888b368faf19dd887c464d0dec51d Mon Sep 17 00:00:00 2001 From: Conner Fromknecht Date: Tue, 30 Oct 2018 01:22:30 -0700 Subject: [PATCH 01/13] watchtower/blob/justice_kit: return DER signatures This commit fixes an issue with the witness stack construction for to-local and to-remote inputs, that would cause the justice kit to return signatures as fixed-size, 64-byte signatures. The correct behavior is to return DER-encoded signatures so that they will properly verify on the network, since the consensus rules won't be able to understand the fixed-size variant. --- watchtower/blob/justice_kit.go | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/watchtower/blob/justice_kit.go b/watchtower/blob/justice_kit.go index e436fdbb..3030356e 100644 --- a/watchtower/blob/justice_kit.go +++ b/watchtower/blob/justice_kit.go @@ -180,12 +180,18 @@ func (b *JusticeKit) CommitToLocalWitnessScript() ([]byte, error) { // CommitToLocalRevokeWitnessStack constructs a witness stack spending the // revocation clause of the commitment to-local output. // 1 -func (b *JusticeKit) CommitToLocalRevokeWitnessStack() [][]byte { +func (b *JusticeKit) CommitToLocalRevokeWitnessStack() ([][]byte, error) { + toLocalSig, err := b.CommitToLocalSig.ToSignature() + if err != nil { + return nil, err + } + witnessStack := make([][]byte, 2) - witnessStack[0] = append(b.CommitToLocalSig[:], byte(txscript.SigHashAll)) + witnessStack[0] = append(toLocalSig.Serialize(), + byte(txscript.SigHashAll)) witnessStack[1] = []byte{1} - return witnessStack + return witnessStack, nil } // HasCommitToRemoteOutput returns true if the blob contains a to-remote p2wkh @@ -207,11 +213,17 @@ func (b *JusticeKit) CommitToRemoteWitnessScript() ([]byte, error) { // CommitToRemoteWitnessStack returns a witness stack spending the commitment // to-remote output, which is a regular p2wkh. // -func (b *JusticeKit) CommitToRemoteWitnessStack() [][]byte { - witnessStack := make([][]byte, 1) - witnessStack[0] = append(b.CommitToRemoteSig[:], byte(txscript.SigHashAll)) +func (b *JusticeKit) CommitToRemoteWitnessStack() ([][]byte, error) { + toRemoteSig, err := b.CommitToRemoteSig.ToSignature() + if err != nil { + return nil, err + } - return witnessStack + witnessStack := make([][]byte, 1) + witnessStack[0] = append(toRemoteSig.Serialize(), + byte(txscript.SigHashAll)) + + return witnessStack, nil } // Encrypt encodes the blob of justice using encoding version, and then From c5eba3b6089ad9a37e59ee3e63503ed5402b3af2 Mon Sep 17 00:00:00 2001 From: Conner Fromknecht Date: Wed, 31 Oct 2018 20:42:01 -0700 Subject: [PATCH 02/13] watchtower/blob/justice_kit: use randomized 192-bit nonce This commit modifies the blob encryption scheme to use chacha20-poly1305 with a randomized 192-bit nonce. The previous approach used a deterministic nonce scheme, which is being replaced to simplify the requirements of a correct implementation. As a result, each payload gains an addtional 24-bytes prepended to the ciphertext. --- watchtower/blob/justice_kit.go | 59 ++++++++++++++++------------------ 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/watchtower/blob/justice_kit.go b/watchtower/blob/justice_kit.go index 3030356e..0eec7092 100644 --- a/watchtower/blob/justice_kit.go +++ b/watchtower/blob/justice_kit.go @@ -2,6 +2,7 @@ package blob import ( "bytes" + "crypto/rand" "encoding/binary" "errors" "fmt" @@ -22,8 +23,8 @@ const ( // MaxVersion is the maximumm blob version supported by this package. MaxVersion = 0 - // NonceSize is the length of a chacha20poly1305 nonce, 12 bytes. - NonceSize = chacha20poly1305.NonceSize + // NonceSize is the length of a chacha20poly1305 nonce, 24 bytes. + NonceSize = chacha20poly1305.NonceSizeX // KeySize is the length of a chacha20poly1305 key, 32 bytes. KeySize = chacha20poly1305.KeySize @@ -49,10 +50,11 @@ const ( ) // Size returns the size of the encoded-and-encrypted blob in bytes. -// enciphered plaintext: n bytes -// MAC: 16 bytes +// nonce: 24 bytes +// enciphered plaintext: n bytes +// MAC: 16 bytes func Size(ver uint16) int { - return PlaintextSize(ver) + CiphertextExpansion + return NonceSize + PlaintextSize(ver) + CiphertextExpansion } // PlaintextSize returns the size of the encoded-but-unencrypted blob in bytes. @@ -79,11 +81,6 @@ var ( "ciphertext is too small for chacha20poly1305", ) - // ErrNonceSize signals that the provided nonce is improperly sized. - ErrNonceSize = fmt.Errorf( - "chacha20poly1305 nonce must be %d bytes", NonceSize, - ) - // ErrKeySize signals that the provided key is improperly sized. ErrKeySize = fmt.Errorf( "chacha20poly1305 key size must be %d bytes", KeySize, @@ -232,15 +229,9 @@ func (b *JusticeKit) CommitToRemoteWitnessStack() ([][]byte, error) { // // NOTE: It is the caller's responsibility to ensure that this method is only // called once for a given (nonce, key) pair. -func (b *JusticeKit) Encrypt(nonce, key []byte, version uint16) ([]byte, error) { - switch { - - // Fail if the nonce is not 12-bytes. - case len(nonce) != NonceSize: - return nil, ErrNonceSize - +func (b *JusticeKit) Encrypt(key []byte, version uint16) ([]byte, error) { // Fail if the nonce is not 32-bytes. - case len(key) != KeySize: + if len(key) != KeySize { return nil, ErrKeySize } @@ -253,19 +244,25 @@ func (b *JusticeKit) Encrypt(nonce, key []byte, version uint16) ([]byte, error) } // Create a new chacha20poly1305 cipher, using a 32-byte key. - cipher, err := chacha20poly1305.New(key) + cipher, err := chacha20poly1305.NewX(key) if err != nil { return nil, err } - // Allocate the ciphertext, which will contain the encrypted plaintext - // and MAC. + // Allocate the ciphertext, which will contain the nonce, encrypted + // plaintext and MAC. plaintext := ptxtBuf.Bytes() - ciphertext := make([]byte, len(plaintext)+CiphertextExpansion) + ciphertext := make([]byte, Size(version)) + + // Generate a random 24-byte nonce in the ciphertext's prefix. + nonce := ciphertext[:NonceSize] + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } // Finally, encrypt the plaintext using the given nonce, storing the // result in the ciphertext buffer. - cipher.Seal(ciphertext[:0], nonce, plaintext, nil) + cipher.Seal(ciphertext[NonceSize:NonceSize], nonce, plaintext, nil) return ciphertext, nil } @@ -273,24 +270,21 @@ func (b *JusticeKit) Encrypt(nonce, key []byte, version uint16) ([]byte, error) // Decrypt unenciphers a blob of justice by decrypting the ciphertext using // chacha20poly1305 with the chosen (nonce, key) pair. The internal plaintext is // then deserialized using the given encoding version. -func Decrypt(nonce, key, ciphertext []byte, version uint16) (*JusticeKit, error) { +func Decrypt(key, ciphertext []byte, version uint16) (*JusticeKit, error) { switch { - // Fail if the blob's overall length is less than the expansion factor. - case len(ciphertext) < CiphertextExpansion: + // Fail if the blob's overall length is less than required for the nonce + // and expansion factor. + case len(ciphertext) < NonceSize+CiphertextExpansion: return nil, ErrCiphertextTooSmall - // Fail if the nonce is not 12-bytes. - case len(nonce) != NonceSize: - return nil, ErrNonceSize - // Fail if the key is not 32-bytes. case len(key) != KeySize: return nil, ErrKeySize } // Create a new chacha20poly1305 cipher, using a 32-byte key. - cipher, err := chacha20poly1305.New(key) + cipher, err := chacha20poly1305.NewX(key) if err != nil { return nil, err } @@ -302,7 +296,8 @@ func Decrypt(nonce, key, ciphertext []byte, version uint16) (*JusticeKit, error) // Decrypt the ciphertext, placing the resulting plaintext in our // plaintext buffer. - _, err = cipher.Open(plaintext[:0], nonce, ciphertext, nil) + nonce := ciphertext[:NonceSize] + _, err = cipher.Open(plaintext[:0], nonce, ciphertext[NonceSize:], nil) if err != nil { return nil, err } From 740e8fd686a203b080abf1d09d93e9aa74a4eb5c Mon Sep 17 00:00:00 2001 From: Conner Fromknecht Date: Wed, 31 Oct 2018 20:42:03 -0700 Subject: [PATCH 03/13] watchtower/blob/justice_kit_test: remove external nonce The nonce is now passed in as the prefix to the ciphertext, and is generated randomly in calls to Encrypt. --- watchtower/blob/justice_kit_test.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/watchtower/blob/justice_kit_test.go b/watchtower/blob/justice_kit_test.go index 606e43e5..ba36f134 100644 --- a/watchtower/blob/justice_kit_test.go +++ b/watchtower/blob/justice_kit_test.go @@ -163,15 +163,9 @@ func testBlobJusticeKitEncryptDecrypt(t *testing.T, test descriptorTest) { t.Fatalf("unable to generate blob encryption key: %v", err) } - nonce := make([]byte, blob.NonceSize) - _, err = io.ReadFull(rand.Reader, nonce) - if err != nil { - t.Fatalf("unable to generate nonce nonce: %v", err) - } - // Encrypt the blob plaintext using the generated key and // target version for this test. - ctxt, err := boj.Encrypt(nonce, key, test.encVersion) + ctxt, err := boj.Encrypt(key, test.encVersion) if err != test.encErr { t.Fatalf("unable to encrypt blob: %v", err) } else if test.encErr != nil { @@ -191,7 +185,7 @@ func testBlobJusticeKitEncryptDecrypt(t *testing.T, test descriptorTest) { // Decrypt the encrypted blob, reconstructing the original // blob plaintext from the decrypted contents. We use the target // decryption version specified by this test case. - boj2, err := blob.Decrypt(nonce, key, ctxt, test.decVersion) + boj2, err := blob.Decrypt(key, ctxt, test.decVersion) if err != test.decErr { t.Fatalf("unable to decrypt blob: %v", err) } else if test.decErr != nil { From 4e6dc3863b7a76bd21241ff22da5744a1aad7528 Mon Sep 17 00:00:00 2001 From: Conner Fromknecht Date: Wed, 31 Oct 2018 20:42:04 -0700 Subject: [PATCH 04/13] watchtower/wtdb/session_info: compute rewards outputs --- watchtower/wtdb/session_info.go | 61 +++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/watchtower/wtdb/session_info.go b/watchtower/wtdb/session_info.go index ddb4347d..23775fe6 100644 --- a/watchtower/wtdb/session_info.go +++ b/watchtower/wtdb/session_info.go @@ -3,6 +3,7 @@ package wtdb import ( "errors" + "github.com/btcsuite/btcutil" "github.com/lightningnetwork/lnd/lnwallet" ) @@ -34,6 +35,11 @@ var ( // number larger than the session's max number of updates. ErrSessionConsumed = errors.New("all session updates have been " + "consumed") + + // ErrFeeExceedsInputs signals that the total input value of breaching + // commitment txn is insufficient to cover the fees required to sweep + // it. + ErrFeeExceedsInputs = errors.New("sweep fee exceeds input values") ) // SessionInfo holds the negotiated session parameters for single session id, @@ -103,3 +109,58 @@ func (s *SessionInfo) AcceptUpdateSequence(seqNum, lastApplied uint16) error { return nil } + +// ComputeSweepOutputs splits the total funds in a breaching commitment +// transaction between the victim and the tower, according to the sweep fee rate +// and reward rate. The fees are first subtracted from the overall total, before +// splitting the remaining balance amongst the victim and tower. +func (s *SessionInfo) ComputeSweepOutputs(totalAmt btcutil.Amount, + txVSize int64) (btcutil.Amount, btcutil.Amount, error) { + + txFee := s.SweepFeeRate.FeeForWeight(txVSize) + if txFee > totalAmt { + return 0, 0, ErrFeeExceedsInputs + } + + totalAmt -= txFee + + // Apply the reward rate to the remaining total, specified in millionths + // of the available balance. + rewardAmt := (totalAmt*btcutil.Amount(s.RewardRate) + 999999) / 1000000 + sweepAmt := totalAmt - rewardAmt + + // TODO(conner): check dustiness + + return sweepAmt, rewardAmt, nil +} + +// Match is returned in response to a database query for a breach hints +// contained in a particular block. The match encapsulates all data required to +// properly decrypt a client's encrypted blob, and pursue action on behalf of +// the victim by reconstructing the justice transaction and broadcasting it to +// the network. +// +// NOTE: It is possible for a match to cause a false positive, since they are +// matched on a prefix of the txid. In such an event, the likely behavior is +// that the payload will fail to decrypt. +type Match struct { + // ID is the session id of the client who uploaded the state update. + ID SessionID + + // SeqNum is the session sequence number occupied by the client's state + // update. Together with ID, this allows the tower to derive the + // appropriate nonce for decryption. + SeqNum uint16 + + // Hint is the breach hint that triggered the match. + Hint BreachHint + + // EncryptedBlob is the encrypted payload containing the justice kit + // uploaded by the client. + EncryptedBlob []byte + + // SessionInfo is the contract negotiated between tower and client, that + // provides input parameters such as fee rate, reward rate, and reward + // address when attempting to reconstruct the justice transaction. + SessionInfo *SessionInfo +} From b79bab0920eef442cd713b4d828d3c3537613e0f Mon Sep 17 00:00:00 2001 From: Conner Fromknecht Date: Wed, 31 Oct 2018 20:42:06 -0700 Subject: [PATCH 05/13] watchtower/wtdb/mock: adds lookout-related mock functions --- watchtower/wtdb/mock.go | 63 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/watchtower/wtdb/mock.go b/watchtower/wtdb/mock.go index e7d71396..c47fe2b1 100644 --- a/watchtower/wtdb/mock.go +++ b/watchtower/wtdb/mock.go @@ -2,16 +2,23 @@ package wtdb -import "sync" +import ( + "sync" + + "github.com/lightningnetwork/lnd/chainntnfs" +) type MockDB struct { - mu sync.Mutex - sessions map[SessionID]*SessionInfo + mu sync.Mutex + lastEpoch *chainntnfs.BlockEpoch + sessions map[SessionID]*SessionInfo + blobs map[BreachHint]map[SessionID]*SessionStateUpdate } func NewMockDB() *MockDB { return &MockDB{ sessions: make(map[SessionID]*SessionInfo), + blobs: make(map[BreachHint]map[SessionID]*SessionStateUpdate), } } @@ -29,6 +36,13 @@ func (db *MockDB) InsertStateUpdate(update *SessionStateUpdate) (uint16, error) return info.LastApplied, err } + sessionsToUpdates, ok := db.blobs[update.Hint] + if !ok { + sessionsToUpdates = make(map[SessionID]*SessionStateUpdate) + db.blobs[update.Hint] = sessionsToUpdates + } + sessionsToUpdates[update.ID] = update + return info.LastApplied, nil } @@ -55,3 +69,46 @@ func (db *MockDB) InsertSessionInfo(info *SessionInfo) error { return nil } + +func (db *MockDB) GetLookoutTip() (*chainntnfs.BlockEpoch, error) { + db.mu.Lock() + defer db.mu.Unlock() + + return db.lastEpoch, nil +} + +func (db *MockDB) QueryMatches(breachHints []BreachHint) ([]Match, error) { + db.mu.Lock() + defer db.mu.Unlock() + + var matches []Match + for _, hint := range breachHints { + sessionsToUpdates, ok := db.blobs[hint] + if !ok { + continue + } + + for id, update := range sessionsToUpdates { + info, ok := db.sessions[id] + if !ok { + panic("session not found") + } + + match := Match{ + ID: id, + SeqNum: update.SeqNum, + Hint: hint, + EncryptedBlob: update.EncryptedBlob, + SessionInfo: info, + } + matches = append(matches, match) + } + } + + return matches, nil +} + +func (db *MockDB) SetLookoutTip(epoch *chainntnfs.BlockEpoch) error { + db.lastEpoch = epoch + return nil +} From 1d7e382097319b66a4e1716a5c6bc8b7886b1fe7 Mon Sep 17 00:00:00 2001 From: Conner Fromknecht Date: Wed, 31 Oct 2018 20:42:07 -0700 Subject: [PATCH 06/13] watchtower/lookout/log: adds lookout subsystem logger --- watchtower/lookout/log.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 watchtower/lookout/log.go diff --git a/watchtower/lookout/log.go b/watchtower/lookout/log.go new file mode 100644 index 00000000..b569b4ae --- /dev/null +++ b/watchtower/lookout/log.go @@ -0,0 +1,29 @@ +package lookout + +import ( + "github.com/btcsuite/btclog" + "github.com/lightningnetwork/lnd/build" +) + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the caller +// requests it. +var log btclog.Logger + +// The default amount of logging is none. +func init() { + UseLogger(build.NewSubLogger("WTWR", nil)) +} + +// DisableLog disables all library log output. Logging output is disabled +// by default until UseLogger is called. +func DisableLog() { + UseLogger(btclog.Disabled) +} + +// UseLogger uses a specified Logger to output package logging info. +// This should be used in preference to SetLogWriter if the caller is also +// using btclog. +func UseLogger(logger btclog.Logger) { + log = logger +} From bd5ed47ce4661fcdb12f211126492bfba2a03a04 Mon Sep 17 00:00:00 2001 From: Conner Fromknecht Date: Wed, 31 Oct 2018 20:42:08 -0700 Subject: [PATCH 07/13] watchtower/lookout/interface: adds primary lookout ifaces --- watchtower/lookout/interface.go | 68 +++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 watchtower/lookout/interface.go diff --git a/watchtower/lookout/interface.go b/watchtower/lookout/interface.go new file mode 100644 index 00000000..6a750c9f --- /dev/null +++ b/watchtower/lookout/interface.go @@ -0,0 +1,68 @@ +package lookout + +import ( + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/watchtower/wtdb" +) + +// Service abstracts the lookout functionality, supporting the ability to start +// and stop. All communication and actions are driven via the database or chain +// events. +type Service interface { + // Start safely starts up the Interface. + Start() error + + // Stop safely stops the Interface. + Stop() error +} + +// BlockFetcher supports the ability to fetch blocks from the backend or +// network. +type BlockFetcher interface { + // GetBlock fetches the block given the target block hash. + GetBlock(*chainhash.Hash) (*wire.MsgBlock, error) +} + +// DB abstracts the required persistent calls expected by the lookout. DB +// provides the ability to search for state updates that correspond to breach +// transactions confirmed in a particular block. +type DB interface { + // GetLookoutTip returns the last block epoch at which the tower + // performed a match. If no match has been done, a nil epoch will be + // returned. + GetLookoutTip() (*chainntnfs.BlockEpoch, error) + + // QueryMatches searches its database for any state updates matching the + // provided breach hints. If any matches are found, they will be + // returned along with encrypted blobs so that justice can be exacted. + QueryMatches([]wtdb.BreachHint) ([]wtdb.Match, error) + + // SetLookoutTip writes the best epoch for which the watchtower has + // queried for breach hints. + SetLookoutTip(*chainntnfs.BlockEpoch) error +} + +// EpochRegistrar supports the ability to register for events corresponding to +// newly created blocks. +type EpochRegistrar interface { + // RegisterBlockEpochNtfn registers for a new block epoch subscription. + // The implementation must support historical dispatch, starting from + // the provided chainntnfs.BlockEpoch when it is non-nil. The + // notifications should be delivered in-order, and deliver reorged + // blocks. + RegisterBlockEpochNtfn( + *chainntnfs.BlockEpoch) (*chainntnfs.BlockEpochEvent, error) +} + +// Punisher handles the construction and publication of justice transactions +// once they have been detected by the Service. +type Punisher interface { + // Punish accepts a JusticeDescriptor, constructs the justice + // transaction, and publishes the transaction to the network so it can + // be mined. The second parameter is a quit channel so that long-running + // operations required to track the confirmation of the transaction can + // be canceled on shutdown. + Punish(*JusticeDescriptor, <-chan struct{}) error +} From 0632520f40d8f7bbca3f6f290502b01b14db2e6e Mon Sep 17 00:00:00 2001 From: Conner Fromknecht Date: Wed, 31 Oct 2018 20:42:10 -0700 Subject: [PATCH 08/13] watchtower/lookout/justice_descriptor: adds justice txn creation --- watchtower/lookout/justice_descriptor.go | 299 +++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 watchtower/lookout/justice_descriptor.go diff --git a/watchtower/lookout/justice_descriptor.go b/watchtower/lookout/justice_descriptor.go new file mode 100644 index 00000000..9eca67409 --- /dev/null +++ b/watchtower/lookout/justice_descriptor.go @@ -0,0 +1,299 @@ +package lookout + +import ( + "errors" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/watchtower/blob" + "github.com/lightningnetwork/lnd/watchtower/wtdb" +) + +var ( + // ErrOutputNotFound signals that the breached output could not be found + // on the commitment transaction. + ErrOutputNotFound = errors.New("unable to find output on commit tx") + + // ErrUnknownSweepAddrType signals that client provided an output that + // was not p2wkh or p2wsh. + ErrUnknownSweepAddrType = errors.New("sweep addr is not p2wkh or p2wsh") +) + +// JusticeDescriptor contains the information required to sweep a breached +// channel on behalf of a victim. It supports the ability to create the justice +// transaction that sweeps the commitments and recover a cut of the channel for +// the watcher's eternal vigilance. +type JusticeDescriptor struct { + // BreachedCommitTx is the commitment transaction that caused the breach + // to be detected. + BreachedCommitTx *wire.MsgTx + + // SessionInfo contains the contract with the watchtower client and + // the prenegotiated terms they agreed to. + SessionInfo *wtdb.SessionInfo + + // JusticeKit contains the decrypted blob and information required to + // construct the transaction scripts and witnesses. + JusticeKit *blob.JusticeKit +} + +// breachedInput contains the required information to construct and spend +// breached outputs on a commitment transaction. +type breachedInput struct { + txOut *wire.TxOut + outPoint wire.OutPoint + witness [][]byte +} + +// commitToLocalInput extracts the information required to spend the commit +// to-local output. +func (p *JusticeDescriptor) commitToLocalInput() (*breachedInput, error) { + // Retrieve the to-local witness script from the justice kit. + toLocalScript, err := p.JusticeKit.CommitToLocalWitnessScript() + if err != nil { + return nil, err + } + + // Compute the witness script hash, which will be used to locate the + // input on the breaching commitment transaction. + toLocalWitnessHash, err := lnwallet.WitnessScriptHash(toLocalScript) + if err != nil { + return nil, err + } + + // Locate the to-local output on the breaching commitment transaction. + toLocalIndex, toLocalTxOut, err := findTxOutByPkScript( + p.BreachedCommitTx, toLocalWitnessHash, + ) + if err != nil { + return nil, err + } + + // Construct the to-local outpoint that will be spent in the justice + // transaction. + toLocalOutPoint := wire.OutPoint{ + Hash: p.BreachedCommitTx.TxHash(), + Index: toLocalIndex, + } + + // Retrieve to-local witness stack, which primarily includes a signature + // under the revocation pubkey. + witnessStack, err := p.JusticeKit.CommitToLocalRevokeWitnessStack() + if err != nil { + return nil, err + } + + return &breachedInput{ + txOut: toLocalTxOut, + outPoint: toLocalOutPoint, + witness: buildWitness(witnessStack, toLocalScript), + }, nil +} + +// commitToRemoteInput extracts the information required to spend the commit +// to-remote output. +func (p *JusticeDescriptor) commitToRemoteInput() (*breachedInput, error) { + // Retrieve the to-remote witness script from the justice kit. + toRemoteScript, err := p.JusticeKit.CommitToRemoteWitnessScript() + if err != nil { + return nil, err + } + + // Since the to-remote witness script should just be a regular p2wkh + // output, we'll parse it to retrieve the public key. + toRemotePubKey, err := btcec.ParsePubKey(toRemoteScript, btcec.S256()) + if err != nil { + return nil, err + } + + // Compute the witness script hash from the to-remote pubkey, which will + // be used to locate the input on the breach commitment transaction. + toRemoteScriptHash, err := lnwallet.CommitScriptUnencumbered( + toRemotePubKey, + ) + if err != nil { + return nil, err + } + + // Locate the to-remote output on the breaching commitment transaction. + toRemoteIndex, toRemoteTxOut, err := findTxOutByPkScript( + p.BreachedCommitTx, toRemoteScriptHash, + ) + if err != nil { + return nil, err + } + + // Construct the to-remote outpoint which will be spent in the justice + // transaction. + toRemoteOutPoint := wire.OutPoint{ + Hash: p.BreachedCommitTx.TxHash(), + Index: toRemoteIndex, + } + + // Retrieve the to-remote witness stack, which is just a signature under + // the to-remote pubkey. + witnessStack, err := p.JusticeKit.CommitToRemoteWitnessStack() + if err != nil { + return nil, err + } + + return &breachedInput{ + txOut: toRemoteTxOut, + outPoint: toRemoteOutPoint, + witness: buildWitness(witnessStack, toRemoteScript), + }, nil +} + +// assembleJusticeTxn accepts the breached inputs recovered from state update +// and attempts to construct the justice transaction that sweeps the victims +// funds to their wallet and claims the watchtower's reward. +func (p *JusticeDescriptor) assembleJusticeTxn(txWeight int64, + inputs ...*breachedInput) (*wire.MsgTx, error) { + + justiceTxn := wire.NewMsgTx(2) + + // First, construct add the breached inputs to our justice transaction + // and compute the total amount that will be swept. + var totalAmt btcutil.Amount + for _, input := range inputs { + totalAmt += btcutil.Amount(input.txOut.Value) + justiceTxn.AddTxIn(&wire.TxIn{ + PreviousOutPoint: input.outPoint, + }) + } + + // Using the total input amount and the transaction's weight, compute + // the sweep and reward amounts. This corresponds to the amount returned + // to the victim and the amount paid to the tower, respectively. To do + // so, the required transaction fee is subtracted from the total, and + // the remaining amount is divided according to the prenegotiated reward + // rate from the client's session info. + sweepAmt, rewardAmt, err := p.SessionInfo.ComputeSweepOutputs( + totalAmt, txWeight, + ) + if err != nil { + return nil, err + } + + // TODO(conner): abort/don't add if outputs are dusty + + // Add the sweep and reward outputs to the justice transaction. + justiceTxn.AddTxOut(&wire.TxOut{ + PkScript: p.JusticeKit.SweepAddress[:], + Value: int64(sweepAmt), + }) + justiceTxn.AddTxOut(&wire.TxOut{ + PkScript: p.SessionInfo.RewardAddress, + Value: int64(rewardAmt), + }) + + // TODO(conner): apply and handle BIP69 sort + + btx := btcutil.NewTx(justiceTxn) + if err := blockchain.CheckTransactionSanity(btx); err != nil { + return nil, err + } + + // Attach each of the provided witnesses to the transaction. + for i, input := range inputs { + justiceTxn.TxIn[i].Witness = input.witness + + // Validate the reconstructed witnesses to ensure they are valid + // for the breached inputs. + vm, err := txscript.NewEngine( + input.txOut.PkScript, justiceTxn, i, + txscript.StandardVerifyFlags, + nil, nil, input.txOut.Value, + ) + if err != nil { + return nil, err + } + if err := vm.Execute(); err != nil { + return nil, err + } + } + + return justiceTxn, nil +} + +// CreateJusticeTxn computes the justice transaction that sweeps a breaching +// commitment transaction. The justice transaction is constructed by assembling +// the witnesses using data provided by the client in a prior state update. +func (p *JusticeDescriptor) CreateJusticeTxn() (*wire.MsgTx, error) { + var ( + sweepInputs = make([]*breachedInput, 0, 2) + weightEstimate lnwallet.TxWeightEstimator + ) + + // Add our reward address to the weight estimate. + weightEstimate.AddP2WKHOutput() + + // Add the sweep address's contribution, depending on whether it is a + // p2wkh or p2wsh output. + switch len(p.JusticeKit.SweepAddress) { + case lnwallet.P2WPKHSize: + weightEstimate.AddP2WKHOutput() + + case lnwallet.P2WSHSize: + weightEstimate.AddP2WSHOutput() + + default: + return nil, ErrUnknownSweepAddrType + } + + // Assemble the breached to-local output from the justice descriptor and + // add it to our weight estimate. + toLocalInput, err := p.commitToLocalInput() + if err != nil { + return nil, err + } + weightEstimate.AddWitnessInput(lnwallet.ToLocalPenaltyWitnessSize) + sweepInputs = append(sweepInputs, toLocalInput) + + // If the justice kit specifies that we have to sweep the to-remote + // output, we'll also try to assemble the output and add it to weight + // estimate if successful. + if p.JusticeKit.HasCommitToRemoteOutput() { + toRemoteInput, err := p.commitToRemoteInput() + if err != nil { + return nil, err + } + weightEstimate.AddWitnessInput(lnwallet.P2WKHWitnessSize) + sweepInputs = append(sweepInputs, toRemoteInput) + } + + // TODO(conner): sweep htlc outputs + + txWeight := int64(weightEstimate.Weight()) + + return p.assembleJusticeTxn(txWeight, sweepInputs...) +} + +// findTxOutByPkScript searches the given transaction for an output whose +// pkscript matches the query. If one is found, the TxOut is returned along with +// the index. +// +// NOTE: The search stops after the first match is found. +func findTxOutByPkScript(txn *wire.MsgTx, + pkScript []byte) (uint32, *wire.TxOut, error) { + + found, index := lnwallet.FindScriptOutputIndex(txn, pkScript) + if !found { + return 0, nil, ErrOutputNotFound + } + + return index, txn.TxOut[index], nil +} + +// buildWitness appends the witness script to a given witness stack. +func buildWitness(witnessStack [][]byte, witnessScript []byte) [][]byte { + witness := make([][]byte, len(witnessStack)+1) + lastIdx := copy(witness, witnessStack) + witness[lastIdx] = witnessScript + + return witness +} From 3ab34f8426c65243a4f3bfb7b7a4977cf0a98066 Mon Sep 17 00:00:00 2001 From: Conner Fromknecht Date: Wed, 31 Oct 2018 20:42:11 -0700 Subject: [PATCH 09/13] watchtower/lookout/punisher: adds Punisher craft+bcast justice txn --- watchtower/lookout/punisher.go | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 watchtower/lookout/punisher.go diff --git a/watchtower/lookout/punisher.go b/watchtower/lookout/punisher.go new file mode 100644 index 00000000..f695ef6e --- /dev/null +++ b/watchtower/lookout/punisher.go @@ -0,0 +1,58 @@ +package lookout + +import ( + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/lnwallet" +) + +// PunisherConfig houses the resources required by the Punisher. +type PunisherConfig struct { + // PublishTx provides the ability to send a signed transaction to the + // network. + PublishTx func(*wire.MsgTx) error + + // TODO(conner) add DB tracking and spend ntfn registration to see if + // ours confirmed or not +} + +// BreachPunisher handles the responsibility of constructing and broadcasting +// justice transactions. Justice transactions are constructed from previously +// accepted state updates uploaded by the watchtower's clients. +type BreachPunisher struct { + cfg *PunisherConfig +} + +// NewBreachPunisher constructs a new BreachPunisher given a PunisherConfig. +func NewBreachPunisher(cfg *PunisherConfig) *BreachPunisher { + return &BreachPunisher{ + cfg: cfg, + } +} + +// Punish constructs a justice transaction given a JusticeDescriptor and +// publishes is it to the network. +func (p *BreachPunisher) Punish(desc *JusticeDescriptor, quit <-chan struct{}) error { + justiceTxn, err := desc.CreateJusticeTxn() + if err != nil { + log.Errorf("Unable to create justice txn for "+ + "client=%s with breach-txid=%x: %v", + desc.SessionInfo.ID, desc.BreachedCommitTx.TxHash(), err) + return err + } + + log.Infof("Publishing justice transaction txid=%x for client=%s", + justiceTxn.TxHash(), desc.SessionInfo.ID) + + err = p.cfg.PublishTx(justiceTxn) + if err != nil && err != lnwallet.ErrDoubleSpend { + log.Errorf("Unable to publish justice txn for client=%s", + "with breach-txid=%x: %v", + desc.SessionInfo.ID, desc.BreachedCommitTx.TxHash(), err) + return err + } + + // TODO(conner): register for spend and remove from db after + // confirmation + + return nil +} From 475ab01442757c1ebc12da738274959ee65f4824 Mon Sep 17 00:00:00 2001 From: Conner Fromknecht Date: Wed, 31 Oct 2018 20:42:12 -0700 Subject: [PATCH 10/13] watchtower/lookout/lookout: adds Lookout --- watchtower/lookout/lookout.go | 272 ++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 watchtower/lookout/lookout.go diff --git a/watchtower/lookout/lookout.go b/watchtower/lookout/lookout.go new file mode 100644 index 00000000..6cda3c3d --- /dev/null +++ b/watchtower/lookout/lookout.go @@ -0,0 +1,272 @@ +package lookout + +import ( + "sync" + "sync/atomic" + + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/watchtower/blob" + "github.com/lightningnetwork/lnd/watchtower/wtdb" +) + +// Config houses the Lookout's required resources to properly fulfill it's duty, +// including block fetching, querying accepted state updates, and construction +// and publication of justice transactions. +type Config struct { + // DB provides persistent access to the watchtower's accepted state + // updates such that they can be queried as new blocks arrive from the + // network. + DB DB + + // EpochRegistrar supports the ability to register for events corresponding to + // newly created blocks. + EpochRegistrar EpochRegistrar + + // BlockFetcher supports the ability to fetch blocks from the backend or + // network. + BlockFetcher BlockFetcher + + // Punisher handles the responsibility of crafting and broadcasting + // justice transaction for any breached transactions. + Punisher Punisher +} + +// Lookout will check any incoming blocks against the transactions found in the +// database, and in case of matches send the information needed to create a +// penalty transaction to the punisher. +type Lookout struct { + started int32 // atomic + shutdown int32 // atomic + + cfg *Config + + wg sync.WaitGroup + quit chan struct{} +} + +// New constructs a new Lookout from the given LookoutConfig. +func New(cfg *Config) *Lookout { + return &Lookout{ + cfg: cfg, + quit: make(chan struct{}), + } +} + +// Start safely spins up the Lookout and begins monitoring for breaches. +func (l *Lookout) Start() error { + if !atomic.CompareAndSwapInt32(&l.started, 0, 1) { + return nil + } + + log.Infof("Starting lookout") + + startEpoch, err := l.cfg.DB.GetLookoutTip() + if err != nil { + return err + } + + if startEpoch == nil { + log.Infof("Starting lookout from chain tip") + } else { + log.Infof("Starting lookout from epoch(height=%d hash=%x)", + startEpoch.Height, startEpoch.Hash) + } + + events, err := l.cfg.EpochRegistrar.RegisterBlockEpochNtfn(startEpoch) + if err != nil { + log.Errorf("Unable to register for block epochs: %v", err) + return err + } + + l.wg.Add(1) + go l.watchBlocks(events) + + log.Infof("Lookout started successfully") + + return nil +} + +// Stop safely shuts down the Lookout. +func (l *Lookout) Stop() error { + if !atomic.CompareAndSwapInt32(&l.shutdown, 0, 1) { + return nil + } + + log.Infof("Stopping lookout") + + close(l.quit) + l.wg.Wait() + + log.Infof("Lookout stopped successfully") + + return nil +} + +// watchBlocks serially pulls incoming epochs from the epoch source and searches +// our accepted state updates for any breached transactions. If any are found, +// we will attempt to decrypt the state updates' encrypted blobs and exact +// justice for the victim. +// +// This method MUST be run as a goroutine. +func (l *Lookout) watchBlocks(epochs *chainntnfs.BlockEpochEvent) { + defer l.wg.Done() + defer epochs.Cancel() + + for { + select { + case epoch := <-epochs.Epochs: + log.Debugf("Fetching block for (height=%d, hash=%x)", + epoch.Height, epoch.Hash) + + // Fetch the full block from the backend corresponding + // to the newly arriving epoch. + block, err := l.cfg.BlockFetcher.GetBlock(epoch.Hash) + if err != nil { + // TODO(conner): add retry logic? + log.Errorf("Unable to fetch block for "+ + "(height=%x, hash=%x): %v", + epoch.Height, epoch.Hash, err) + continue + } + + // Process the block to see if it contains any breaches + // that we are monitoring on behalf of our clients. + err = l.processEpoch(epoch, block) + if err != nil { + log.Errorf("Unable to process %s: %v", + epoch, err) + } + + case <-l.quit: + return + } + } +} + +// processEpoch accepts an Epoch and queries the database for any matching state +// updates for the confirmed transactions. If any are found, the lookout +// responds by attempting to decrypt the encrypted blob and publishing the +// justice transaction. +func (l *Lookout) processEpoch(epoch *chainntnfs.BlockEpoch, + block *wire.MsgBlock) error { + + numTxnsInBlock := len(block.Transactions) + + log.Debugf("Scanning %d transaction in block (height=%d, hash=%x) "+ + "for breaches", numTxnsInBlock, epoch.Height, epoch.Hash) + + // Iterate over the transactions contained in the block, deriving a + // breach hint for each transaction and constructing an index mapping + // the hint back to it's original transaction. + hintToTx := make(map[wtdb.BreachHint]*wire.MsgTx, numTxnsInBlock) + txHints := make([]wtdb.BreachHint, 0, numTxnsInBlock) + for _, tx := range block.Transactions { + hash := tx.TxHash() + hint := wtdb.NewBreachHintFromHash(&hash) + + txHints = append(txHints, hint) + hintToTx[hint] = tx + } + + // Query the database to see if any of the breach hints cause a match + // with any of our accepted state updates. + matches, err := l.cfg.DB.QueryMatches(txHints) + if err != nil { + return err + } + + // No matches were found, we are done. + if len(matches) == 0 { + log.Debugf("No breaches found in (height=%d, hash=%x)", + epoch.Height, epoch.Hash) + return nil + } + + breachCountStr := "breach" + if len(matches) > 1 { + breachCountStr = "breaches" + } + + log.Infof("Found %d %s in (height=%d, hash=%x)", + len(matches), breachCountStr, epoch.Height, epoch.Hash) + + // For each match, use our index to retrieve the original transaction, + // which corresponds to the breaching commitment transaction. If the + // decryption succeeds, we will accumlate the assembled justice + // descriptors in a single slice + var successes []*JusticeDescriptor + for _, match := range matches { + commitTx := hintToTx[match.Hint] + log.Infof("Dispatching punisher for client %s, breach-txid=%s", + match.ID, commitTx.TxHash().String()) + + // The decryption key for the state update should be the full + // txid of the breaching commitment transaction. + commitTxID := commitTx.TxHash() + + // Now, decrypt the blob of justice that we received in the + // state update. This will contain all information required to + // sweep the breached commitment outputs. + justiceKit, err := blob.Decrypt( + commitTxID[:], match.EncryptedBlob, + match.SessionInfo.Version, + ) + if err != nil { + // If the decryption fails, this implies either that the + // client sent an invalid blob, or that the breach hint + // caused a match on the txid, but this isn't actually + // the right transaction. + log.Debugf("Unable to decrypt blob for client %s, "+ + "breach-txid %s: %v", match.ID, + commitTx.TxHash().String(), err) + continue + } + + justiceDesc := &JusticeDescriptor{ + BreachedCommitTx: commitTx, + SessionInfo: match.SessionInfo, + JusticeKit: justiceKit, + } + successes = append(successes, justiceDesc) + } + + // TODO(conner): mark successfully decrypted blob so that we can + // reliably rebroadcast on startup + + // Now, we'll dispatch a punishment for each successful match in + // parallel. This will assemble the justice transaction for each and + // watch for their confirmation on chain. + for _, justiceDesc := range successes { + l.wg.Add(1) + go l.dispatchPunisher(justiceDesc) + } + + return l.cfg.DB.SetLookoutTip(epoch) +} + +// dispatchPunisher accepts a justice descriptor corresponding to a successfully +// decrypted blob. The punisher will then construct the witness scripts and +// witness stacks for the breached outputs. If construction of the justice +// transaction is successful, it will be published to the network to retrieve +// the funds and claim the watchtower's reward. +// +// This method MUST be run as a goroutine. +func (l *Lookout) dispatchPunisher(desc *JusticeDescriptor) { + defer l.wg.Done() + + // Give the justice descriptor to the punisher to construct and publish + // the justice transaction. The lookout's quit channel is provided so + // that long-running tasks that watch for on-chain events can be + // canceled during shutdown since this method is waitgrouped. + err := l.cfg.Punisher.Punish(desc, l.quit) + if err != nil { + log.Errorf("Unable to punish breach-txid %s for %x: %v", + desc.SessionInfo.ID, + desc.BreachedCommitTx.TxHash().String(), err) + return + } + + log.Infof("Punishment for client %s with breach-txid=%s dispatched", + desc.SessionInfo.ID, desc.BreachedCommitTx.TxHash().String()) +} From 671be697435212b3194491ca9e0d35bcc5b4dd6d Mon Sep 17 00:00:00 2001 From: Conner Fromknecht Date: Wed, 31 Oct 2018 20:42:14 -0700 Subject: [PATCH 11/13] watchtower/lookout/mock: adds mock backend --- watchtower/lookout/mock.go | 61 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 watchtower/lookout/mock.go diff --git a/watchtower/lookout/mock.go b/watchtower/lookout/mock.go new file mode 100644 index 00000000..c5d8839a --- /dev/null +++ b/watchtower/lookout/mock.go @@ -0,0 +1,61 @@ +// +build dev + +package lookout + +import ( + "fmt" + "sync" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/chainntnfs" +) + +type MockBackend struct { + mu sync.RWMutex + + blocks chan *chainntnfs.BlockEpoch + epochs map[chainhash.Hash]*wire.MsgBlock + quit chan struct{} +} + +func NewMockBackend() *MockBackend { + return &MockBackend{ + blocks: make(chan *chainntnfs.BlockEpoch), + epochs: make(map[chainhash.Hash]*wire.MsgBlock), + quit: make(chan struct{}), + } +} + +func (m *MockBackend) RegisterBlockEpochNtfn(*chainntnfs.BlockEpoch) ( + *chainntnfs.BlockEpochEvent, error) { + + return &chainntnfs.BlockEpochEvent{ + Epochs: m.blocks, + }, nil +} + +func (m *MockBackend) GetBlock(hash *chainhash.Hash) (*wire.MsgBlock, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + block, ok := m.epochs[*hash] + if !ok { + return nil, fmt.Errorf("unknown block for hash %x", hash) + } + + return block, nil +} + +func (m *MockBackend) ConnectEpoch(epoch *chainntnfs.BlockEpoch, + block *wire.MsgBlock) { + + m.mu.Lock() + m.epochs[*epoch.Hash] = block + m.mu.Unlock() + + select { + case m.blocks <- epoch: + case <-m.quit: + } +} From fa63ba2e37c5474824237128dbe6e1391ae9d7ba Mon Sep 17 00:00:00 2001 From: Conner Fromknecht Date: Wed, 31 Oct 2018 20:42:15 -0700 Subject: [PATCH 12/13] watchtower/lookout/lookout_test: adds simple lookout tests --- watchtower/lookout/lookout_test.go | 249 +++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 watchtower/lookout/lookout_test.go diff --git a/watchtower/lookout/lookout_test.go b/watchtower/lookout/lookout_test.go new file mode 100644 index 00000000..60681325 --- /dev/null +++ b/watchtower/lookout/lookout_test.go @@ -0,0 +1,249 @@ +// +build dev + +package lookout_test + +import ( + "bytes" + "crypto/rand" + "encoding/binary" + "io" + "testing" + "time" + + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/watchtower/blob" + "github.com/lightningnetwork/lnd/watchtower/lookout" + "github.com/lightningnetwork/lnd/watchtower/wtdb" +) + +type mockPunisher struct { + matches chan *lookout.JusticeDescriptor +} + +func (p *mockPunisher) Punish( + info *lookout.JusticeDescriptor, quit <-chan struct{}) error { + + p.matches <- info + return nil +} + +func makeArray32(i uint64) [32]byte { + var arr [32]byte + binary.BigEndian.PutUint64(arr[:], i) + return arr +} + +func makeArray33(i uint64) [33]byte { + var arr [33]byte + binary.BigEndian.PutUint64(arr[:], i) + return arr +} + +func makePubKey(i uint64) [33]byte { + var arr [33]byte + arr[0] = 0x02 + if i%2 == 1 { + arr[0] |= 0x01 + } + binary.BigEndian.PutUint64(arr[1:], i) + return arr +} + +func makeArray64(i uint64) [64]byte { + var arr [64]byte + binary.BigEndian.PutUint64(arr[:], i) + return arr +} + +func makeAddrSlice(size int) []byte { + addr := make([]byte, size) + if _, err := io.ReadFull(rand.Reader, addr); err != nil { + panic("cannot make addr") + } + return addr +} + +func TestLookoutBreachMatching(t *testing.T) { + db := wtdb.NewMockDB() + + // Initialize an mock backend to feed the lookout blocks. + backend := lookout.NewMockBackend() + + // Initialize a punisher that will feed any successfully constructed + // justice descriptors across the matches channel. + matches := make(chan *lookout.JusticeDescriptor) + punisher := &mockPunisher{matches: matches} + + // With the resources in place, initialize and start our watcher. + watcher := lookout.New(&lookout.Config{ + BlockFetcher: backend, + DB: db, + EpochRegistrar: backend, + Punisher: punisher, + }) + if err := watcher.Start(); err != nil { + t.Fatalf("unable to start watcher: %v", err) + } + + // Create two sessions, representing two distinct clients. + sessionInfo1 := &wtdb.SessionInfo{ + ID: makeArray33(1), + MaxUpdates: 10, + RewardAddress: makeAddrSlice(22), + } + sessionInfo2 := &wtdb.SessionInfo{ + ID: makeArray33(2), + MaxUpdates: 10, + RewardAddress: makeAddrSlice(22), + } + + // Insert both sessions into the watchtower's database. + err := db.InsertSessionInfo(sessionInfo1) + if err != nil { + t.Fatalf("unable to insert session info: %v", err) + } + err = db.InsertSessionInfo(sessionInfo2) + if err != nil { + t.Fatalf("unable to insert session info: %v", err) + } + + // Construct two distinct transactions, that will be used to test the + // breach hint matching. + tx := wire.NewMsgTx(wire.TxVersion) + hash1 := tx.TxHash() + + tx2 := wire.NewMsgTx(wire.TxVersion + 1) + hash2 := tx2.TxHash() + + if bytes.Equal(hash1[:], hash2[:]) { + t.Fatalf("breach txids should be different") + } + + // Construct a justice kit for each possible breach transaction. + blob1 := &blob.JusticeKit{ + SweepAddress: makeAddrSlice(22), + RevocationPubKey: makePubKey(1), + LocalDelayPubKey: makePubKey(1), + CSVDelay: 144, + CommitToLocalSig: makeArray64(1), + } + blob2 := &blob.JusticeKit{ + SweepAddress: makeAddrSlice(22), + RevocationPubKey: makePubKey(2), + LocalDelayPubKey: makePubKey(2), + CSVDelay: 144, + CommitToLocalSig: makeArray64(2), + } + + // Encrypt the first justice kit under the txid of the first txn. + encBlob1, err := blob1.Encrypt(hash1[:], 0) + if err != nil { + t.Fatalf("unable to encrypt sweep detail 1: %v", err) + } + + // Encrypt the second justice kit under the txid of the second txn. + encBlob2, err := blob2.Encrypt(hash2[:], 0) + if err != nil { + t.Fatalf("unable to encrypt sweep detail 2: %v", err) + } + + // Add both state updates to the tower's database. + txBlob1 := &wtdb.SessionStateUpdate{ + ID: makeArray33(1), + Hint: wtdb.NewBreachHintFromHash(&hash1), + EncryptedBlob: encBlob1, + SeqNum: 1, + } + txBlob2 := &wtdb.SessionStateUpdate{ + ID: makeArray33(2), + Hint: wtdb.NewBreachHintFromHash(&hash2), + EncryptedBlob: encBlob2, + SeqNum: 1, + } + if _, err := db.InsertStateUpdate(txBlob1); err != nil { + t.Fatalf("unable to add tx to db: %v", err) + } + if _, err := db.InsertStateUpdate(txBlob2); err != nil { + t.Fatalf("unable to add tx to db: %v", err) + } + + // Create a block containing the first transaction, connecting this + // block should match the first state update's breach hint. + block := &wire.MsgBlock{ + Header: wire.BlockHeader{ + Nonce: 1, + }, + Transactions: []*wire.MsgTx{tx}, + } + blockHash := block.BlockHash() + epoch := &chainntnfs.BlockEpoch{ + Hash: &blockHash, + Height: 1, + } + + // Connect the block via our mock backend. + backend.ConnectEpoch(epoch, block) + + // This should trigger dispatch of the justice kit for the first tx. + select { + case match := <-matches: + txid := match.BreachedCommitTx.TxHash() + if !bytes.Equal(txid[:], hash1[:]) { + t.Fatalf("matched breach did not match tx1's txid") + } + case <-time.After(5 * time.Second): + t.Fatalf("breach tx1 was not matched") + } + + // Ensure that at most one txn was matched as a result of connecting the + // first block. + select { + case <-matches: + t.Fatalf("only one txn should have been matched") + case <-time.After(50 * time.Millisecond): + } + + // Now, construct a second block containing the second breach + // transaction. + block2 := &wire.MsgBlock{ + Header: wire.BlockHeader{ + Nonce: 2, + }, + Transactions: []*wire.MsgTx{tx2}, + } + blockHash2 := block2.BlockHash() + epoch2 := &chainntnfs.BlockEpoch{ + Hash: &blockHash2, + Height: 2, + } + + // Verify that the block hashes do no collide, otherwise the mock + // backend may not function properly. + if bytes.Equal(blockHash[:], blockHash2[:]) { + t.Fatalf("block hashes should be different") + } + + // Connect the second block, such that the block is delivered via the + // epoch stream. + backend.ConnectEpoch(epoch2, block2) + + // This should trigger dispatch of the justice kit for the second txn. + select { + case match := <-matches: + txid := match.BreachedCommitTx.TxHash() + if !bytes.Equal(txid[:], hash2[:]) { + t.Fatalf("received breach did not match tx2's txid") + } + case <-time.After(5 * time.Second): + t.Fatalf("tx was not matched") + } + + // Ensure that at most one txn was matched as a result of connecting the + // second block. + select { + case <-matches: + t.Fatalf("only one txn should have been matched") + case <-time.After(50 * time.Millisecond): + } +} From 8bc896427d277268c979d5c2f3fa6683d028afb0 Mon Sep 17 00:00:00 2001 From: Conner Fromknecht Date: Wed, 31 Oct 2018 20:42:17 -0700 Subject: [PATCH 13/13] watchtower/lookout/justice_descriptor_test: add create txn test --- watchtower/lookout/justice_descriptor_test.go | 348 ++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 watchtower/lookout/justice_descriptor_test.go diff --git a/watchtower/lookout/justice_descriptor_test.go b/watchtower/lookout/justice_descriptor_test.go new file mode 100644 index 00000000..d0c49e59 --- /dev/null +++ b/watchtower/lookout/justice_descriptor_test.go @@ -0,0 +1,348 @@ +// +build dev + +package lookout_test + +import ( + "reflect" + "testing" + "time" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil" + "github.com/davecgh/go-spew/spew" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/watchtower/blob" + "github.com/lightningnetwork/lnd/watchtower/lookout" + "github.com/lightningnetwork/lnd/watchtower/wtdb" +) + +const csvDelay uint32 = 144 + +var ( + revPrivBytes = []byte{ + 0x8f, 0x4b, 0x51, 0x83, 0xa9, 0x34, 0xbd, 0x5f, + 0x74, 0x6c, 0x9d, 0x5c, 0xae, 0x88, 0x2d, 0x31, + 0x06, 0x90, 0xdd, 0x8c, 0x9b, 0x31, 0xbc, 0xd1, + 0x78, 0x91, 0x88, 0x2a, 0xf9, 0x74, 0xa0, 0xef, + } + + toLocalPrivBytes = []byte{ + 0xde, 0x17, 0xc1, 0x2f, 0xdc, 0x1b, 0xc0, 0xc6, + 0x59, 0x5d, 0xf9, 0xc1, 0x3e, 0x89, 0xbc, 0x6f, + 0x01, 0x85, 0x45, 0x76, 0x26, 0xce, 0x9c, 0x55, + 0x3b, 0xc9, 0xec, 0x3d, 0xd8, 0x8b, 0xac, 0xa8, + } + + toRemotePrivBytes = []byte{ + 0x28, 0x59, 0x6f, 0x36, 0xb8, 0x9f, 0x19, 0x5d, + 0xcb, 0x07, 0x48, 0x8a, 0xe5, 0x89, 0x71, 0x74, + 0x70, 0x4c, 0xff, 0x1e, 0x9c, 0x00, 0x93, 0xbe, + 0xe2, 0x2e, 0x68, 0x08, 0x4c, 0xb4, 0x0f, 0x4f, + } +) + +type mockSigner struct { + index uint32 + keys map[keychain.KeyLocator]*btcec.PrivateKey +} + +func newMockSigner() *mockSigner { + return &mockSigner{ + keys: make(map[keychain.KeyLocator]*btcec.PrivateKey), + } +} + +func (s *mockSigner) SignOutputRaw(tx *wire.MsgTx, + signDesc *lnwallet.SignDescriptor) ([]byte, error) { + + witnessScript := signDesc.WitnessScript + amt := signDesc.Output.Value + + privKey, ok := s.keys[signDesc.KeyDesc.KeyLocator] + if !ok { + panic("cannot sign w/ unknown key") + } + + sig, err := txscript.RawTxInWitnessSignature( + tx, signDesc.SigHashes, signDesc.InputIndex, amt, + witnessScript, signDesc.HashType, privKey, + ) + if err != nil { + return nil, err + } + + return sig[:len(sig)-1], nil +} + +func (s *mockSigner) ComputeInputScript(tx *wire.MsgTx, + signDesc *lnwallet.SignDescriptor) (*lnwallet.InputScript, error) { + return nil, nil +} + +func (s *mockSigner) addPrivKey(privKey *btcec.PrivateKey) keychain.KeyLocator { + keyLoc := keychain.KeyLocator{ + Index: s.index, + } + s.index++ + + s.keys[keyLoc] = privKey + + return keyLoc +} + +func TestJusticeDescriptor(t *testing.T) { + const ( + localAmount = btcutil.Amount(100000) + remoteAmount = btcutil.Amount(200000) + totalAmount = localAmount + remoteAmount + ) + + // Parse the key pairs for all keys used in the test. + revSK, revPK := btcec.PrivKeyFromBytes( + btcec.S256(), revPrivBytes, + ) + _, toLocalPK := btcec.PrivKeyFromBytes( + btcec.S256(), toLocalPrivBytes, + ) + toRemoteSK, toRemotePK := btcec.PrivKeyFromBytes( + btcec.S256(), toRemotePrivBytes, + ) + + // Create the signer, and add the revocation and to-remote privkeys. + signer := newMockSigner() + var ( + revKeyLoc = signer.addPrivKey(revSK) + toRemoteKeyLoc = signer.addPrivKey(toRemoteSK) + ) + + // Construct the to-local witness script. + toLocalScript, err := lnwallet.CommitScriptToSelf( + csvDelay, toLocalPK, revPK, + ) + if err != nil { + t.Fatalf("unable to create to-local script: %v", err) + } + + // Compute the to-local witness script hash. + toLocalScriptHash, err := lnwallet.WitnessScriptHash(toLocalScript) + if err != nil { + t.Fatalf("unable to create to-local witness script hash: %v", err) + } + + // Compute the to-remote witness script hash. + toRemoteScriptHash, err := lnwallet.CommitScriptUnencumbered(toRemotePK) + if err != nil { + t.Fatalf("unable to create to-remote script: %v", err) + } + + // Construct the breaching commitment txn, containing the to-local and + // to-remote outputs. We don't need any inputs for this test. + breachTxn := &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{}, + TxOut: []*wire.TxOut{ + { + Value: int64(localAmount), + PkScript: toLocalScriptHash, + }, + { + Value: int64(remoteAmount), + PkScript: toRemoteScriptHash, + }, + }, + } + breachTxID := breachTxn.TxHash() + + // Compute the weight estimate for our justice transaction. + var weightEstimate lnwallet.TxWeightEstimator + weightEstimate.AddP2WKHOutput() + weightEstimate.AddP2WKHOutput() + weightEstimate.AddWitnessInput(lnwallet.ToLocalPenaltyWitnessSize) + weightEstimate.AddWitnessInput(lnwallet.P2WKHWitnessSize) + txWeight := weightEstimate.Weight() + + // Create a session info so that simulate agreement of the sweep + // parameters that should be used in constructing the justice + // transaction. + sessionInfo := &wtdb.SessionInfo{ + SweepFeeRate: 2000, + RewardRate: 900000, + RewardAddress: makeAddrSlice(22), + } + + // Given the total input amount and the weight estimate, compute the + // amount that should be swept for the victim and the amount taken as a + // reward by the watchtower. + sweepAmt, rewardAmt, err := sessionInfo.ComputeSweepOutputs( + totalAmount, int64(txWeight), + ) + if err != nil { + t.Fatalf("unable to compute sweep outputs: %v", err) + } + + // Begin to assemble the justice kit, starting with the sweep address, + // pubkeys, and csv delay. + justiceKit := &blob.JusticeKit{ + SweepAddress: makeAddrSlice(22), + CSVDelay: csvDelay, + } + copy(justiceKit.RevocationPubKey[:], revPK.SerializeCompressed()) + copy(justiceKit.LocalDelayPubKey[:], toLocalPK.SerializeCompressed()) + copy(justiceKit.CommitToRemotePubKey[:], toRemotePK.SerializeCompressed()) + + // Create a transaction spending from the outputs of the breach + // transaction created earlier. The inputs are always ordered w/ + // to-local and then to-remote. The outputs are always added as the + // sweep address then reward address. + justiceTxn := &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: wire.OutPoint{ + Hash: breachTxID, + Index: 0, + }, + }, + { + PreviousOutPoint: wire.OutPoint{ + Hash: breachTxID, + Index: 1, + }, + }, + }, + TxOut: []*wire.TxOut{ + { + + Value: int64(sweepAmt), + PkScript: justiceKit.SweepAddress, + }, + { + + Value: int64(rewardAmt), + PkScript: sessionInfo.RewardAddress, + }, + }, + } + + hashCache := txscript.NewTxSigHashes(justiceTxn) + + // Create the sign descriptor used to sign for the to-local input. + toLocalSignDesc := &lnwallet.SignDescriptor{ + KeyDesc: keychain.KeyDescriptor{ + KeyLocator: revKeyLoc, + }, + WitnessScript: toLocalScript, + Output: breachTxn.TxOut[0], + SigHashes: hashCache, + InputIndex: 0, + HashType: txscript.SigHashAll, + } + + // Create the sign descriptor used to sign for the to-remote input. + toRemoteSignDesc := &lnwallet.SignDescriptor{ + KeyDesc: keychain.KeyDescriptor{ + KeyLocator: toRemoteKeyLoc, + PubKey: toRemotePK, + }, + WitnessScript: toRemoteScriptHash, + Output: breachTxn.TxOut[1], + SigHashes: hashCache, + InputIndex: 1, + HashType: txscript.SigHashAll, + } + + // Verify that our test justice transaction is sane. + btx := btcutil.NewTx(justiceTxn) + if err := blockchain.CheckTransactionSanity(btx); err != nil { + t.Fatalf("justice txn is not sane: %v", err) + } + + // Compute a DER-encoded signature for the to-local input. + toLocalSigRaw, err := signer.SignOutputRaw(justiceTxn, toLocalSignDesc) + if err != nil { + t.Fatalf("unable to sign to-local input: %v", err) + } + + // Compute the witness for the to-remote input. The first element is a + // DER-encoded signature under the to-remote pubkey. The sighash flag is + // also present, so we trim it. + toRemoteWitness, err := lnwallet.CommitSpendNoDelay( + signer, toRemoteSignDesc, justiceTxn, + ) + if err != nil { + t.Fatalf("unable to sign to-remote input: %v", err) + } + toRemoteSigRaw := toRemoteWitness[0][:len(toRemoteWitness[0])-1] + + // Convert the DER to-local sig into a fixed-size signature. + toLocalSig, err := lnwire.NewSigFromRawSignature(toLocalSigRaw) + if err != nil { + t.Fatalf("unable to parse to-local signature: %v", err) + } + + // Convert the DER to-remote sig into a fixed-size signature. + toRemoteSig, err := lnwire.NewSigFromRawSignature(toRemoteSigRaw) + if err != nil { + t.Fatalf("unable to parse to-remote signature: %v", err) + } + + // Complete our justice kit by copying the signatures into the payload. + copy(justiceKit.CommitToLocalSig[:], toLocalSig[:]) + copy(justiceKit.CommitToRemoteSig[:], toRemoteSig[:]) + + justiceDesc := &lookout.JusticeDescriptor{ + BreachedCommitTx: breachTxn, + SessionInfo: sessionInfo, + JusticeKit: justiceKit, + } + + // Construct a breach punisher that will feed published transactions + // over the buffered channel. + publications := make(chan *wire.MsgTx, 1) + punisher := lookout.NewBreachPunisher(&lookout.PunisherConfig{ + PublishTx: func(tx *wire.MsgTx) error { + publications <- tx + return nil + }, + }) + + // Exact retribution on the offender. If no error is returned, we expect + // the justice transaction to be published via the channel. + err = punisher.Punish(justiceDesc, nil) + if err != nil { + t.Fatalf("unable to punish breach: %v", err) + } + + // Retrieve the published justice transaction. + var wtJusticeTxn *wire.MsgTx + select { + case wtJusticeTxn = <-publications: + case <-time.After(50 * time.Millisecond): + t.Fatalf("punisher did not publish justice txn") + } + + // Construct the test's to-local witness. + justiceTxn.TxIn[0].Witness = make([][]byte, 3) + justiceTxn.TxIn[0].Witness[0] = append(toLocalSigRaw, + byte(txscript.SigHashAll)) + justiceTxn.TxIn[0].Witness[1] = []byte{1} + justiceTxn.TxIn[0].Witness[2] = toLocalScript + + // Construct the test's to-remote witness. + justiceTxn.TxIn[1].Witness = make([][]byte, 2) + justiceTxn.TxIn[1].Witness[0] = append(toRemoteSigRaw, + byte(txscript.SigHashAll)) + justiceTxn.TxIn[1].Witness[1] = toRemotePK.SerializeCompressed() + + // Assert that the watchtower derives the same justice txn. + if !reflect.DeepEqual(justiceTxn, wtJusticeTxn) { + t.Fatalf("expected justice txn: %v\ngot %v", + spew.Sdump(justiceTxn), + spew.Sdump(wtJusticeTxn)) + } +}