Merge pull request #2124 from cfromknecht/wtlookout

[watchtower/lookout]: on-chain breach monitoring
This commit is contained in:
Olaoluwa Osuntokun 2018-11-13 19:53:07 -08:00 committed by GitHub
commit 2f0bc5c370
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1553 additions and 50 deletions

@ -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,
@ -180,12 +177,18 @@ func (b *JusticeKit) CommitToLocalWitnessScript() ([]byte, error) {
// CommitToLocalRevokeWitnessStack constructs a witness stack spending the
// revocation clause of the commitment to-local output.
// <revocation-sig> 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 +210,17 @@ func (b *JusticeKit) CommitToRemoteWitnessScript() ([]byte, error) {
// CommitToRemoteWitnessStack returns a witness stack spending the commitment
// to-remote output, which is a regular p2wkh.
// <to-remote-sig>
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
@ -220,15 +229,9 @@ func (b *JusticeKit) CommitToRemoteWitnessStack() [][]byte {
//
// 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
}
@ -241,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
}
@ -261,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
}
@ -290,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
}

@ -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 {

@ -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
}

@ -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
}

@ -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))
}
}

29
watchtower/lookout/log.go Normal file

@ -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
}

@ -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())
}

@ -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):
}
}

@ -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:
}
}

@ -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
}

@ -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
}

@ -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
}