diff --git a/bolt12/decode_test.go b/bolt12/decode_test.go new file mode 100644 index 00000000000..d1ff88d0670 --- /dev/null +++ b/bolt12/decode_test.go @@ -0,0 +1,121 @@ +package bolt12 + +import ( + "bytes" + "testing" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// appendRawRecord writes a single TLV record (type, length, value) to buf. +func appendRawRecord(t *testing.T, buf *bytes.Buffer, typ uint64, + value []byte) { + + t.Helper() + + var scratch [8]byte + require.NoError(t, tlv.WriteVarInt(buf, typ, &scratch)) + require.NoError(t, tlv.WriteVarInt(buf, uint64(len(value)), &scratch)) + _, err := buf.Write(value) + require.NoError(t, err) +} + +// TestDecodeRejectsNonMinimalFeatures tests that a non-minimally encoded +// feature vector is rejected at decode, so the canonical re-encode of an +// accepted message always reproduces the wire bytes. +func TestDecodeRejectsNonMinimalFeatures(t *testing.T) { + t.Parallel() + + // A feature vector holding only bit 0 encodes minimally as 0x01. The + // two-byte form pads it with a leading zero byte. + padded := []byte{0x00, 0x01} + + tests := []struct { + name string + typ uint64 + decode func([]byte) error + }{ + { + name: "offer_features", + typ: 12, + decode: func(b []byte) error { + _, err := decodeOffer(b) + return err + }, + }, + { + name: "invreq_features", + typ: 84, + decode: func(b []byte) error { + _, err := DecodeInvoiceRequest(b) + return err + }, + }, + { + name: "invoice_features", + typ: 174, + decode: func(b []byte) error { + _, err := DecodeInvoice(b) + return err + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + appendRawRecord(t, &buf, tc.typ, padded) + + err := tc.decode(buf.Bytes()) + require.ErrorIs(t, err, ErrNonMinimalFeatures) + + // The minimal encoding of the same bit set is accepted. + var minimalBuf bytes.Buffer + appendRawRecord(t, &minimalBuf, tc.typ, []byte{0x01}) + require.NoError(t, tc.decode(minimalBuf.Bytes())) + }) + } +} + +// TestDecodeRejectsNonMinimalAmount tests that a non-minimally encoded +// amount is rejected at decode, so the canonical re-encode of an accepted +// message always reproduces the wire bytes. +func TestDecodeRejectsNonMinimalAmount(t *testing.T) { + t.Parallel() + + // invreq_amount (type 82) holding the value 1 in two bytes: the + // minimal tu64 encoding of 1 is a single byte. + var buf bytes.Buffer + appendRawRecord(t, &buf, 82, []byte{0x00, 0x01}) + + _, err := DecodeInvoiceRequest(buf.Bytes()) + require.ErrorIs(t, err, tlv.ErrTUintNotMinimal) +} + +// TestUnknownOddTLVRoundTripByteExact tests that unknown odd TLV types in the +// signed range are preserved on decode and re-encode, so that the canonical +// re-encode of an accepted message always reproduces the wire bytes. +func TestUnknownOddTLVRoundTripByteExact(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + + // invreq_metadata (type 0), then two unknown odd types in the signed + // range: one with a value, one zero-length. + appendRawRecord(t, &buf, 0, []byte("meta")) + appendRawRecord(t, &buf, 93, []byte("xyz")) + appendRawRecord(t, &buf, 95, nil) + + wire := buf.Bytes() + + ir, err := DecodeInvoiceRequest(wire) + require.NoError(t, err) + + var out bytes.Buffer + require.NoError(t, lnwire.EncodePureTLVMessage(ir, &out)) + require.Equal(t, wire, out.Bytes()) +} diff --git a/bolt12/helpers_test.go b/bolt12/helpers_test.go index f1291c37087..17469cd2d14 100644 --- a/bolt12/helpers_test.go +++ b/bolt12/helpers_test.go @@ -3,12 +3,15 @@ package bolt12 import ( "bytes" "encoding/json" + "io" "os" "sync" "testing" "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) @@ -115,3 +118,146 @@ func loadOffersVectors(t *testing.T) []offersTestVector { return vectors } + +// streamToRecords parses an arbitrary TLV byte stream into tlv.Record values +// whose Encode method reproduces the original wire bytes, without going +// through a typed message decoder. +func streamToRecords(t *testing.T, data []byte) []tlv.Record { + t.Helper() + + stream, err := tlv.NewStream() + require.NoError(t, err) + + typeMap, err := stream.DecodeWithParsedTypesP2P(bytes.NewReader(data)) + require.NoError(t, err) + + return lnwire.TlvMapToRecords(typeMap) +} + +// payerIDFromStream returns the invreq_payer_id public key carried by a raw +// BOLT 12 TLV stream. It reads the field straight from the parsed type map so +// callers stay independent of the typed message decoders. +func payerIDFromStream(t *testing.T, data []byte) *btcec.PublicKey { + t.Helper() + + stream, err := tlv.NewStream() + require.NoError(t, err) + + typeMap, err := stream.DecodeWithParsedTypesP2P(bytes.NewReader(data)) + require.NoError(t, err) + + raw, ok := typeMap[invreqPayerIDType] + require.True(t, ok, "stream carries no invreq_payer_id") + + pubKey, err := btcec.ParsePubKey(raw) + require.NoError(t, err) + + return pubKey +} + +// recordFromWireBytes builds a single tlv.Record whose encoding is the +// supplied full TLV byte slice. The slice must be a complete +// type+length+value sequence. Inputs are trusted spec fixtures, so the +// length prefix is allocated without a bound. +func recordFromWireBytes(t *testing.T, full []byte) tlv.Record { + t.Helper() + + var buf [8]byte + r := bytes.NewReader(full) + + typ, err := tlv.ReadVarInt(r, &buf) + require.NoError(t, err) + + length, err := tlv.ReadVarInt(r, &buf) + require.NoError(t, err) + + value := make([]byte, length) + _, err = io.ReadFull(r, value) + require.NoError(t, err) + + return tlv.MakePrimitiveRecord(tlv.Type(typ), &value) +} + +// sigTestVector represents a test case from signature-test.json. +type sigTestVector struct { + Comment string `json:"comment"` + TLV string `json:"tlv"` + Bolt12 string `json:"bolt12"` + + //nolint:tagliatelle // BOLT 12 spec vector key. + FirstTLV string `json:"first-tlv"` + Leaves []json.RawMessage `json:"leaves"` + Branches []json.RawMessage `json:"branches"` + Merkle string `json:"merkle"` + + SignatureTag string `json:"signature_tag"` + Signature string `json:"signature"` +} + +// readSignatureDataOnce reads signature-test.json once so the file is +// parsed only once per test process. +var readSignatureDataOnce = sync.OnceValues(func() ([]byte, error) { + return os.ReadFile("test-vectors/signature-test.json") +}) + +// loadSignatureVectorsOnce parses signature-test.json into typed +// sigTestVectors. The raw-JSON loader is separate because the JSON +// contains a key ("H(signature_tag,merkle)") that cannot be expressed +// via Go struct tags. +var loadSignatureVectorsOnce = sync.OnceValues( + func() ([]sigTestVector, error) { + data, err := readSignatureDataOnce() + if err != nil { + return nil, err + } + + var vectors []sigTestVector + if err := json.Unmarshal(data, &vectors); err != nil { + return nil, err + } + + return vectors, nil + }, +) + +// loadSignatureVectors returns the parsed sigTestVector slice, failing the +// test if signature-test.json is unreadable or malformed. +func loadSignatureVectors(t *testing.T) []sigTestVector { + t.Helper() + + vectors, err := loadSignatureVectorsOnce() + require.NoError(t, err) + + return vectors +} + +// loadSignatureRawOnce parses signature-test.json as a slice of raw +// json.RawMessage so callers can index into keys whose names cannot be +// expressed via struct tags. +var loadSignatureRawOnce = sync.OnceValues( + func() ([]json.RawMessage, error) { + data, err := readSignatureDataOnce() + if err != nil { + return nil, err + } + + var raw []json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + return raw, nil + }, +) + +// loadSignatureRawVectors returns the raw json.RawMessage view of +// signature-test.json, failing the test if the file is unreadable or +// malformed. +func loadSignatureRawVectors(t *testing.T) []json.RawMessage { + t.Helper() + + raw, err := loadSignatureRawOnce() + require.NoError(t, err) + + return raw +} diff --git a/bolt12/invoice.go b/bolt12/invoice.go index 6a04c012006..6f8d316d471 100644 --- a/bolt12/invoice.go +++ b/bolt12/invoice.go @@ -338,14 +338,16 @@ func DecodeInvoice(data []byte) (*Invoice, error) { data, invreqMetadata.Record(), chains.Record(), offerMeta.Record(), currency.Record(), offerAmt.Record(), desc.Record(), - offerFeat.Record(), expiry.Record(), offerPaths.Record(), + strictFeaturesRecord(&offerFeat), expiry.Record(), + offerPaths.Record(), issuer.Record(), qtyMax.Record(), issuerID.Record(), - invreqChain.Record(), invreqAmt.Record(), invreqFeat.Record(), + invreqChain.Record(), invreqAmt.Record(), + strictFeaturesRecord(&invreqFeat), invreqQty.Record(), payerID.Record(), payerNote.Record(), invreqPaths.Record(), bip353.Record(), invPaths.Record(), blindedPay.Record(), createdAt.Record(), relExp.Record(), payHash.Record(), invAmt.Record(), fallbacks.Record(), - invFeat.Record(), nodeID.Record(), sig.Record(), + strictFeaturesRecord(&invFeat), nodeID.Record(), sig.Record(), ) if err != nil { return nil, fmt.Errorf("decode invoice: %w", err) diff --git a/bolt12/invoice_request.go b/bolt12/invoice_request.go index 0093e4a670f..d7af1c11c16 100644 --- a/bolt12/invoice_request.go +++ b/bolt12/invoice_request.go @@ -198,10 +198,11 @@ func DecodeInvoiceRequest(data []byte) (*InvoiceRequest, error) { tm, err := decodeStream( data, invreqMetadata.Record(), chains.Record(), metadata.Record(), currency.Record(), amount.Record(), - desc.Record(), features.Record(), expiry.Record(), + desc.Record(), strictFeaturesRecord(&features), expiry.Record(), paths.Record(), issuer.Record(), qtyMax.Record(), issuerID.Record(), invreqChain.Record(), invreqAmount.Record(), - invreqFeatures.Record(), invreqQty.Record(), payerID.Record(), + strictFeaturesRecord(&invreqFeatures), invreqQty.Record(), + payerID.Record(), payerNote.Record(), invreqPaths.Record(), bip353.Record(), sig.Record(), ) diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go index f15e1a75324..56f2c74bdc6 100644 --- a/bolt12/invoice_test.go +++ b/bolt12/invoice_test.go @@ -185,8 +185,14 @@ func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { t.Parallel() inv := validInvoice(t) + + // Sign with the fixture's node id (Bob) so the read path's signature + // check accepts the invoice. + priv, _ := bobKey() + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) inv.Signature = tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte]([64]byte{}), + tlv.NewPrimitiveRecord[tlv.TlvType240](sig), ) encoded, err := inv.Encode() diff --git a/bolt12/merkle.go b/bolt12/merkle.go new file mode 100644 index 00000000000..43925349eea --- /dev/null +++ b/bolt12/merkle.go @@ -0,0 +1,163 @@ +package bolt12 + +import ( + "bytes" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" +) + +// ErrEmptyMerkleInput is returned by merkleRoot when the input contains no +// TLVs. merkleRoot never returns the all-zero digest for an empty input. A +// verifier must reject a signature over an all-zero digest. +var ErrEmptyMerkleInput = errors.New("cannot compute Merkle root over " + + "empty TLV set") + +// ErrUnsortedMerkleInput is returned by merkleRoot when the input records are +// not in strictly ascending type order. The leaves must be processed in +// ascending TLV order per the spec, so an unsorted or duplicated type would +// otherwise produce an incorrect root silently. +var ErrUnsortedMerkleInput = errors.New("TLV records not in strictly " + + "ascending type order") + +// taggedHash computes SHA256(SHA256(tag) || SHA256(tag) || msg) per the BIP-340 +// tagged hash convention. +func taggedHash(tag string, msg []byte) [32]byte { + return *chainhash.TaggedHash([]byte(tag), msg) +} + +// leafHash computes H("LnLeaf", fullTLVBytes) for a single TLV field. +func leafHash(fullTLVBytes []byte) [32]byte { + return taggedHash("LnLeaf", fullTLVBytes) +} + +// nonceHash computes H("LnNonce" || firstTLV, tlvTypeBigSize) for a single TLV +// field. The tag includes the raw bytes of the first TLV in the stream. The +// message is the BigSize-encoded type of the current TLV field. +// +// The tag is the literal byte concatenation of "LnNonce" and the first TLV. Go +// converts []byte to string as a byte-faithful copy. The spec defines the tag +// as byte concatenation, not UTF-8 joining. The caller builds the tag once per +// tree and passes it in, since it is invariant across the records. +func nonceHash(tag string, tlvType tlv.Type) [32]byte { + var buf [8]byte + var typeBuf bytes.Buffer + + // WriteVarInt only fails on a Writer error. bytes.Buffer.Write is + // documented to never return one, so the discard is safe. + _ = tlv.WriteVarInt(&typeBuf, uint64(tlvType), &buf) + + return taggedHash(tag, typeBuf.Bytes()) +} + +// branchHash computes H("LnBranch", lesser || greater) where the two child +// hashes are sorted lexicographically with the lesser hash first. +func branchHash(a, b [32]byte) [32]byte { + if bytes.Compare(a[:], b[:]) > 0 { + a, b = b, a + } + + var msg [64]byte + copy(msg[:32], a[:]) + copy(msg[32:], b[:]) + + return taggedHash("LnBranch", msg[:]) +} + +// signableTLVs returns the subset of records that contribute to the signature's +// Merkle root. Everything outside the inclusive range [240, 1000] is included. +// Types 240-1000 are reserved by the BOLT 12 spec for the signature TLV (type +// 240) and similar non-content fields the signer must not commit to. The +// reserved range covers more than just signature, so the filter is symmetric on +// both ends rather than a single-type exclusion. +func signableTLVs(records []tlv.Record) []tlv.Record { + out := make([]tlv.Record, 0, len(records)) + for _, r := range records { + if !bolt12InUnsignedRange(r.Type()) { + out = append(out, r) + } + } + + return out +} + +// merkleRoot computes the Merkle root of the given TLV records. Each record is +// encoded in isolation via its TLV stream form to derive the per-leaf full +// type+length+value bytes that feed both the LnLeaf and LnNonce digests. The +// records must be in canonical order (ascending by type, no duplicates); +// merkleRoot enforces the precondition and returns ErrUnsortedMerkleInput. +// +// An empty input returns ErrEmptyMerkleInput. Signing or verifying an empty +// stream would collide with the all-zero digest. +func merkleRoot(records []tlv.Record) ([32]byte, error) { + if len(records) == 0 { + return [32]byte{}, ErrEmptyMerkleInput + } + + // Encode each record on its own to recover the same per-field + // type+length+value bytes the original wire stream contained. The + // spec's nonce tag binds to the bytes of the first TLV, so the + // per-record encoding must match what the producer signed. + // + // The re-encoding is byte-exact over the signed range for every + // message the codec accepts: decode enforces minimal BigSize + // prefixes, minimal truncated integers, canonical record order, and + // minimal feature vectors, and preserves unknown TLV values verbatim. + // signableTLVs strips types 240-1000 before merkleRoot runs, and + // unknown records in that stripped range do not survive re-encode. + encoded := make([][]byte, len(records)) + var prevType tlv.Type + for i, r := range records { + // Check for strictly ascending order, not just non-descending, + // to avoid silently dropping duplicates. + if i > 0 && r.Type() <= prevType { + return [32]byte{}, fmt.Errorf("%w: type %d after %d", + ErrUnsortedMerkleInput, r.Type(), prevType) + } + prevType = r.Type() + + buf, err := lnwire.EncodeRecords([]tlv.Record{r}) + if err != nil { + return [32]byte{}, fmt.Errorf("encode record %d (type "+ + "%d): %w", i, r.Type(), err) + } + encoded[i] = buf + } + + firstTLV := encoded[0] + + // The nonce tag binds the first TLV and is invariant across the tree, + // so it is built once rather than per record. chainhash.TaggedHash + // re-hashes the tag on each call; at BOLT 12 message sizes that cost + // is negligible, and keeping the library construction avoids owning a + // copy of the BIP-340 tagged hash. + nonceTag := "LnNonce" + string(firstTLV) + + branches := make([][32]byte, len(records)) + for i, r := range records { + leaf := leafHash(encoded[i]) + nonce := nonceHash(nonceTag, r.Type()) + branches[i] = branchHash(leaf, nonce) + } + + // Combine branches pairwise until a single root remains. + for len(branches) > 1 { + var next [][32]byte + for i := 0; i < len(branches); i += 2 { + if i+1 >= len(branches) { + // Odd element is promoted unchanged. + next = append(next, branches[i]) + continue + } + + combined := branchHash(branches[i], branches[i+1]) + next = append(next, combined) + } + branches = next + } + + return branches[0], nil +} diff --git a/bolt12/merkle_test.go b/bolt12/merkle_test.go new file mode 100644 index 00000000000..c66e778e8b7 --- /dev/null +++ b/bolt12/merkle_test.go @@ -0,0 +1,506 @@ +package bolt12 + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "testing" + + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// TestMerkleRootVectors verifies the Merkle root computation against every test +// case in signature-test.json. +func TestMerkleRootVectors(t *testing.T) { + t.Parallel() + + vectors := loadSignatureVectors(t) + require.NotEmpty(t, vectors) + + for _, tc := range vectors { + t.Run(tc.Comment, func(t *testing.T) { + t.Parallel() + + var records []tlv.Record + + switch { + case tc.Bolt12 != "": + // Decode the bech32 string to get TLV bytes, + // then convert into the record view merkleRoot + // consumes. + _, tlvBytes, err := Decode(tc.Bolt12) + require.NoError(t, err) + + records = streamToRecords(t, tlvBytes) + + case tc.TLV == "n1": + // Build records from the leaf descriptions. The + // n1 namespace is synthetic. There is no bech32 + // representation, so we recover each record + // from its hex prefix. + records = buildN1Records(t, tc) + + default: + t.Fatalf("vector %q: neither bolt12 nor "+ + "n1: refusing to assume the "+ + "wrong synthesis path", tc.Comment) + } + + // Keep only the records that participate in the + // signature root. + filtered := signableTLVs(records) + + root, err := merkleRoot(filtered) + require.NoError(t, err) + + expectedRoot, err := hex.DecodeString(tc.Merkle) + require.NoError(t, err) + require.Equal( + t, expectedRoot, root[:], + "merkle root mismatch", + ) + }) + } +} + +// buildN1Records constructs tlv.Record entries for the simple n1 test vectors +// by parsing the leaf hex values from the test JSON. +func buildN1Records(t *testing.T, tc sigTestVector) []tlv.Record { + t.Helper() + + var result []tlv.Record + + for _, leafJSON := range tc.Leaves { + var leafMap map[string]string + require.NoError(t, json.Unmarshal(leafJSON, &leafMap)) + + // Find the LnLeaf key to extract the TLV bytes. + // Key format: H(`LnLeaf`,) + prefix := "H(`LnLeaf`," + for key := range leafMap { + if len(key) <= len(prefix) || + key[:len(prefix)] != prefix { + + continue + } + + // Extract hex between the comma and closing + // paren. + hexStr := key[len(prefix) : len(key)-1] + fullBytes, err := hex.DecodeString(hexStr) + require.NoError(t, err) + + result = append( + result, + recordFromWireBytes(t, fullBytes), + ) + + break + } + } + + return result +} + +// TestLeafHash verifies individual leaf hash computations from the test +// vectors. +func TestLeafHash(t *testing.T) { + t.Parallel() + + const ( + // From the first test vector: H("LnLeaf", 010203e8). + inputStr = "010203e8" + expectedStr = "67a2a995433890d8fe0c18a1765ad19e98f1fc" + + "feff14c13a45bbc80964a78cf7" + ) + + input, err := hex.DecodeString(inputStr) + require.NoError(t, err) + + expected, err := hex.DecodeString(expectedStr) + require.NoError(t, err) + + got := leafHash(input) + require.Equal(t, expected, got[:]) +} + +// TestNonceHash verifies the nonce hash computation. The type 1001 case pins +// the multi-byte BigSize encoding of the type, which none of the vendored +// vectors exercise. +func TestNonceHash(t *testing.T) { + t.Parallel() + + firstTLV, err := hex.DecodeString("010203e8") + require.NoError(t, err) + + tests := []struct { + name string + tlvType tlv.Type + expected string + }{ + { + name: "type 1 nonce", + tlvType: 1, + expected: "255a95f5b6b3c6997e2838dc4d9348807fb6da" + + "8eb7bbc02d30662d144718b6aa", + }, + { + name: "type 2 nonce", + tlvType: 2, + expected: "12bc15565410d8e3251a6fb1c53a2d360f39a9" + + "f65afb8403ef875016e34ff678", + }, + { + name: "type 1001 nonce multi-byte bigsize", + tlvType: 1001, + expected: "793dc046489a1260fd133c5048591f6b59f192" + + "8cbb7f9190219beeabc2b45f4d", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + expected, err := hex.DecodeString(tc.expected) + require.NoError(t, err) + + got := nonceHash("LnNonce"+string(firstTLV), tc.tlvType) + require.Equal(t, expected, got[:]) + }) + } +} + +// TestBranchHash verifies the branch hash computation. +func TestBranchHash(t *testing.T) { + t.Parallel() + + const ( + // From test vector 2: combining the tlv1+nonce and + // tlv2+nonce branches. + aStr = "19d6ecfa3be88d29c30e56167f58526d7695df" + + "ac9cb95e1256deb222c92db4d0" + bStr = "b013756c8fee86503a0b4abdab4cddeb1af5d3" + + "44ca6fc2fa8b6c08938caa6f93" + expectedStr = "c3774abbf4815aa54ccaa026bff6581f01f3be" + + "5fe814c620a252534f434bc0d1" + ) + + a, err := hex.DecodeString(aStr) + require.NoError(t, err) + b, err := hex.DecodeString(bStr) + require.NoError(t, err) + + var aArr, bArr [32]byte + copy(aArr[:], a) + copy(bArr[:], b) + + expected, err := hex.DecodeString(expectedStr) + require.NoError(t, err) + + got := branchHash(aArr, bArr) + require.Equal(t, expected, got[:]) +} + +// TestMerkleVectorIntermediateHashes asserts every named LnLeaf, LnNonce, and +// LnBranch entry from each signature-test.json vector matches the hash this +// implementation produces. The root test alone cannot distinguish an encoding +// bug from a hash-construction bug. Feeding the primitives the spec-stated +// bytes directly localizes a vector failure to a single pipeline stage. +func TestMerkleVectorIntermediateHashes(t *testing.T) { + t.Parallel() + + for _, tc := range loadSignatureVectors(t) { + // The n1 vectors are synthesised. The pubkey-bearing + // invoice_request leaves are recoverable from the bech32 + // string. In both cases the leaf hex appears in the JSON + // `H('LnLeaf', )` keys, so we walk those directly. + t.Run(tc.Comment, func(t *testing.T) { + t.Parallel() + + firstTLV, err := hex.DecodeString(tc.FirstTLV) + require.NoError(t, err) + + for i, leafJSON := range tc.Leaves { + assertLeafEntry(t, leafJSON, firstTLV, i) + } + + // Branch entries each carry exactly one + // H('LnBranch', ) key. + for i, branchJSON := range tc.Branches { + assertBranchEntry(t, branchJSON, i) + } + }) + } +} + +// assertLeafEntry checks the hashes a vector leaf records for a single TLV +// against the values this implementation derives from the leaf bytes and the +// stream's first TLV. +func assertLeafEntry(t *testing.T, leafJSON json.RawMessage, firstTLV []byte, + idx int) { + + t.Helper() + + var entries map[string]string + require.NoError(t, json.Unmarshal(leafJSON, &entries)) + + const ( + leafPrefix = "H(`LnLeaf`," + noncePrefix = "H(`LnNonce`|first-tlv," + branchPrefix = "H(`LnBranch`," + ) + + var ( + leafKey, leafExpected string + nonceKey, nonceExpected string + branchKey, branchExpected string + ) + for k, v := range entries { + switch { + case len(k) > len(leafPrefix) && + k[:len(leafPrefix)] == leafPrefix: + leafKey, leafExpected = k, v + case len(k) > len(noncePrefix) && + k[:len(noncePrefix)] == noncePrefix: + nonceKey, nonceExpected = k, v + case len(k) > len(branchPrefix) && + k[:len(branchPrefix)] == branchPrefix: + branchKey, branchExpected = k, v + } + } + require.NotEmpty(t, leafKey, + "leaf %d: missing LnLeaf key", idx) + require.NotEmpty(t, nonceKey, + "leaf %d: missing LnNonce key", idx) + require.NotEmpty(t, branchKey, + "leaf %d: missing LnBranch key", idx) + + leafHex := leafKey[len(leafPrefix) : len(leafKey)-1] + leafBytes, err := hex.DecodeString(leafHex) + require.NoError(t, err) + + gotLeaf := leafHash(leafBytes) + wantLeaf, err := hex.DecodeString(leafExpected) + require.NoError(t, err) + require.Equal( + t, wantLeaf, gotLeaf[:], "leaf %d: LnLeaf hash mismatch", idx, + ) + + // The nonce key encodes a per-leaf type identifier as the final segment + // after the comma. For older vectors the segment is the type name + // ("tlv1-type"). Newer ones use a raw type number ("1"). We extract the + // leaf's leading TLV type from its hex prefix and use that. The spec + // says the nonce binds to the first TLV plus the leaf's own type. + leafType := leafTypeFromHex(t, leafBytes) + gotNonce := nonceHash("LnNonce"+string(firstTLV), leafType) + wantNonce, err := hex.DecodeString(nonceExpected) + require.NoError(t, err) + require.Equal(t, wantNonce, gotNonce[:], + "leaf %d: LnNonce hash mismatch", idx) + + gotBranch := branchHash(gotLeaf, gotNonce) + wantBranch, err := hex.DecodeString(branchExpected) + require.NoError(t, err) + require.Equal(t, wantBranch, gotBranch[:], + "leaf %d: LnBranch hash mismatch", idx) +} + +// assertBranchEntry validates the branch hash for one entry in the vector's +// `branches` array. Each entry's H('LnBranch', ) key carries the +// two child hashes concatenated. The value is the expected combined hash. +func assertBranchEntry(t *testing.T, branchJSON json.RawMessage, idx int) { + t.Helper() + + var entries map[string]string + require.NoError(t, json.Unmarshal(branchJSON, &entries)) + + const branchPrefix = "H(`LnBranch`," + + var key, expected string + for k, v := range entries { + if len(k) > len(branchPrefix) && + k[:len(branchPrefix)] == branchPrefix { + + key, expected = k, v + } + } + require.NotEmpty(t, key, "branch %d: missing LnBranch key", idx) + + hexConcat := key[len(branchPrefix) : len(key)-1] + concat, err := hex.DecodeString(hexConcat) + require.NoError(t, err) + require.Equal( + t, 64, len(concat), "branch %d: expected 64 bytes of "+ + "child hashes", idx, + ) + + var a, b [32]byte + copy(a[:], concat[:32]) + copy(b[:], concat[32:]) + + got := branchHash(a, b) + want, err := hex.DecodeString(expected) + require.NoError(t, err) + require.Equal(t, want, got[:], "branch %d: LnBranch hash mismatch", idx) +} + +// leafTypeFromHex parses the leading varint of a TLV-encoded leaf to recover +// its type number. The signature-test.json LnNonce entries bind the nonce to +// this type, so we must reproduce the parse here to compute the same nonce +// hash. +func leafTypeFromHex(t *testing.T, leafBytes []byte) tlv.Type { + t.Helper() + + var buf [8]byte + r := bytes.NewReader(leafBytes) + typ, err := tlv.ReadVarInt(r, &buf) + require.NoError(t, err) + + return tlv.Type(typ) +} + +// TestPropertyMerkleOrderSensitivity asserts that for any non-trivial raw TLV +// sequence the Merkle tree rejects a permuted input. The receiver-to-sender +// invoice flow signs a tree built over a type-sorted stream. If a permutation +// were accepted, an attacker could permute fields without invalidating the +// signature. +func TestPropertyMerkleOrderSensitivity(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + // Need at least two leaves with distinct types. Types are + // tagged into the nonce hash, so identical types would produce + // identical leaves and a swap would be a no-op. + n := rapid.IntRange(2, 8).Draw(t, "leafCount") + records := make([]tlv.Record, n) + for i := range n { + v := drawTLVValue(t) + records[i] = tlv.MakePrimitiveRecord(tlv.Type(i+1), &v) + } + + _, err := merkleRoot(records) + require.NoError(t, err) + + swapped := make([]tlv.Record, len(records)) + copy(swapped, records) + swapped[0], swapped[1] = swapped[1], swapped[0] + + _, err = merkleRoot(swapped) + require.ErrorIs(t, err, ErrUnsortedMerkleInput, + "swapping two distinct leaves did not reject the input") + }) +} + +// drawTLVValue synthesises the value-side payload for a single TLV record. Used +// by the Merkle order-sensitivity property to build leaves that the hash +// functions can ingest. +func drawTLVValue(t *rapid.T) []byte { + payloadLen := rapid.IntRange(1, 8).Draw(t, "payloadLen") + + return rapid.SliceOfN(rapid.Byte(), payloadLen, payloadLen). + Draw(t, "payload") +} + +// TestMerkleRootEmptyInput pins the contract for an empty leaf set: merkleRoot +// returns ErrEmptyMerkleInput, never the all-zero digest. The all-zero hash is +// a valid SHA-256 output that could collide with a legitimately computed root, +// so a verifier accepting it could be tricked by a forged-but-empty message. +func TestMerkleRootEmptyInput(t *testing.T) { + t.Parallel() + + t.Run("nil slice", func(t *testing.T) { + t.Parallel() + + root, err := merkleRoot(nil) + require.ErrorIs(t, err, ErrEmptyMerkleInput) + require.Equal(t, [32]byte{}, root) + }) + + t.Run("empty slice", func(t *testing.T) { + t.Parallel() + + root, err := merkleRoot([]tlv.Record{}) + require.ErrorIs(t, err, ErrEmptyMerkleInput) + require.Equal(t, [32]byte{}, root) + }) +} + +// TestMerkleRootUnsortedInput pins the ordering precondition: merkleRoot +// returns ErrUnsortedMerkleInput for unsorted or duplicated input instead of +// producing an incorrect root silently. +func TestMerkleRootUnsortedInput(t *testing.T) { + t.Parallel() + + newRecord := func(typ tlv.Type) tlv.Record { + v := []byte{0x01} + + return tlv.MakePrimitiveRecord(typ, &v) + } + + t.Run("unsorted", func(t *testing.T) { + t.Parallel() + + _, err := merkleRoot([]tlv.Record{newRecord(2), newRecord(1)}) + require.ErrorIs(t, err, ErrUnsortedMerkleInput) + }) + + t.Run("duplicate", func(t *testing.T) { + t.Parallel() + + _, err := merkleRoot([]tlv.Record{newRecord(1), newRecord(1)}) + require.ErrorIs(t, err, ErrUnsortedMerkleInput) + }) + + t.Run("sorted accepted", func(t *testing.T) { + t.Parallel() + + _, err := merkleRoot([]tlv.Record{newRecord(1), newRecord(2)}) + require.NoError(t, err) + }) +} + +// TestSignableTLVsFilteringBoundaries pins the inclusion rule for the Merkle +// input. The spec excludes types in [240, 1000]. Everything outside that range +// contributes. Drift here would either include type 240 (the signature itself, +// breaking commit-to-tree-root semantics) or exclude experimental types > 1000 +// (silently dropping fields the writer expected to commit to). +func TestSignableTLVsFilteringBoundaries(t *testing.T) { + t.Parallel() + + tests := []struct { + typ tlv.Type + included bool + }{ + {typ: 0, included: true}, + {typ: 239, included: true}, + {typ: 240, included: false}, + {typ: 500, included: false}, + {typ: 1000, included: false}, + {typ: 1001, included: true}, + {typ: 1_000_000_000, included: true}, + } + + records := make([]tlv.Record, 0, len(tests)) + for _, tc := range tests { + // An empty value blob is enough. The filter only inspects each + // record's Type. + var v []byte + records = append(records, tlv.MakePrimitiveRecord(tc.typ, &v)) + } + + got := signableTLVs(records) + gotTypes := make(map[tlv.Type]bool, len(got)) + for _, r := range got { + gotTypes[r.Type()] = true + } + + for _, tc := range tests { + require.Equal( + t, tc.included, gotTypes[tc.typ], + "type %d inclusion mismatch", tc.typ, + ) + } +} diff --git a/bolt12/offer.go b/bolt12/offer.go index dfe8cd60f4e..3b8eadc2ca4 100644 --- a/bolt12/offer.go +++ b/bolt12/offer.go @@ -138,7 +138,7 @@ func decodeOffer(data []byte) (*Offer, error) { currency.Record(), amount.Record(), desc.Record(), - features.Record(), + strictFeaturesRecord(&features), expiry.Record(), paths.Record(), issuer.Record(), diff --git a/bolt12/signature.go b/bolt12/signature.go new file mode 100644 index 00000000000..c4dda72418f --- /dev/null +++ b/bolt12/signature.go @@ -0,0 +1,171 @@ +package bolt12 + +import ( + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" +) + +const ( + // signatureTagPrefix is the literal prefix for all BOLT 12 signature + // tags. + signatureTagPrefix = "lightning" + + // tagMsgInvoiceRequest is the messagename for invoice_request + // signatures. + tagMsgInvoiceRequest = "invoice_request" + + // tagMsgInvoice is the messagename for invoice signatures. + tagMsgInvoice = "invoice" + + // tagFieldSignature is the fieldname of the TLV field containing the + // signature. + tagFieldSignature = "signature" +) + +// ErrInvalidSignature is returned by VerifyInvoice and VerifyInvoiceRequest +// when the BIP-340 Schnorr signature does not validate against the message's +// Merkle root and signing key. +var ErrInvalidSignature = errors.New("BOLT 12 signature is invalid") + +// ErrNilPrivateKey is returned by the sign entry paths when the signing key is +// nil. +var ErrNilPrivateKey = errors.New("BOLT 12 signing key is nil") + +// signMessage creates a BIP-340 Schnorr signature over the Merkle root of a +// BOLT 12 message. The tag is "lightning" || messageName || fieldName. +func signMessage(messageName, fieldName string, root [32]byte, + privKey *btcec.PrivateKey) ([64]byte, error) { + + if privKey == nil { + return [64]byte{}, ErrNilPrivateKey + } + + tag := signatureTagPrefix + messageName + fieldName + digest := taggedHash(tag, root[:]) + + sig, err := schnorr.Sign(privKey, digest[:]) + if err != nil { + return [64]byte{}, fmt.Errorf("sign: %w", err) + } + + var result [64]byte + copy(result[:], sig.Serialize()) + + return result, nil +} + +// verifySignature verifies a BIP-340 Schnorr signature over the Merkle root of +// a BOLT 12 message. +func verifySignature(messageName, fieldName string, root [32]byte, sig [64]byte, + pubKey *btcec.PublicKey) error { + + if pubKey == nil { + return ErrNilPublicKey + } + + tag := signatureTagPrefix + messageName + fieldName + digest := taggedHash(tag, root[:]) + + parsedSig, err := schnorr.ParseSignature(sig[:]) + if err != nil { + return fmt.Errorf("%w: parse signature: %w", + ErrInvalidSignature, err) + } + + if !parsedSig.Verify(digest[:], pubKey) { + return ErrInvalidSignature + } + + return nil +} + +// SignInvoiceRequest computes the Merkle root of an invoice request and +// generates a Schnorr signature using the provided private key. The root is +// computed over the signable subset of AllRecords(). +func SignInvoiceRequest(ir *InvoiceRequest, privKey *btcec.PrivateKey) ( + [64]byte, error) { + + if privKey == nil { + return [64]byte{}, ErrNilPrivateKey + } + + root, err := merkleRoot(signableTLVs(ir.AllRecords())) + if err != nil { + return [64]byte{}, err + } + + return signMessage( + tagMsgInvoiceRequest, tagFieldSignature, root, privKey, + ) +} + +// VerifyInvoiceRequest verifies the signature on an invoice request using its +// invreq_payer_id public key. +func VerifyInvoiceRequest(ir *InvoiceRequest) error { + pubKey, err := ir.InvreqPayerID.UnwrapOrErrV(ErrMissingPayerID) + if err != nil { + return err + } + if pubKey == nil { + return fmt.Errorf("%w: invreq_payer_id", ErrNilPublicKey) + } + + sig, err := ir.Signature.UnwrapOrErrV(ErrMissingSignature) + if err != nil { + return err + } + + root, err := merkleRoot(signableTLVs(ir.AllRecords())) + if err != nil { + return err + } + + return verifySignature( + tagMsgInvoiceRequest, tagFieldSignature, root, sig, pubKey, + ) +} + +// SignInvoice computes the Merkle root of an invoice and generates a Schnorr +// signature using the provided private key. The root is computed over the +// signable subset of AllRecords(). +func SignInvoice(inv *Invoice, privKey *btcec.PrivateKey) ([64]byte, error) { + if privKey == nil { + return [64]byte{}, ErrNilPrivateKey + } + + root, err := merkleRoot(signableTLVs(inv.AllRecords())) + if err != nil { + return [64]byte{}, err + } + + return signMessage(tagMsgInvoice, tagFieldSignature, root, privKey) +} + +// VerifyInvoice verifies the signature on an invoice using its invoice_node_id +// public key. +func VerifyInvoice(inv *Invoice) error { + pubKey, err := inv.InvoiceNodeID.UnwrapOrErrV(ErrMissingNodeID) + if err != nil { + return err + } + if pubKey == nil { + return fmt.Errorf("%w: invoice_node_id", ErrNilPublicKey) + } + + sig, err := inv.Signature.UnwrapOrErrV(ErrMissingSignature) + if err != nil { + return err + } + + root, err := merkleRoot(signableTLVs(inv.AllRecords())) + if err != nil { + return err + } + + return verifySignature( + tagMsgInvoice, tagFieldSignature, root, sig, pubKey, + ) +} diff --git a/bolt12/signature_test.go b/bolt12/signature_test.go new file mode 100644 index 00000000000..f7f438e6b95 --- /dev/null +++ b/bolt12/signature_test.go @@ -0,0 +1,488 @@ +package bolt12 + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestSignatureVerifyVector verifies every signed invoice_request vector in +// signature-test.json against the Merkle root, tagged digest, and signature +// the spec records for it. Each match runs as its own subtest, so a vector +// added to the file later is exercised rather than skipped. +func TestSignatureVerifyVector(t *testing.T) { + t.Parallel() + + vectors := loadSignatureVectors(t) + + // The raw view parses the same file into the same order, so index i + // holds the untyped form of vectors[i]. It supplies the + // "H(signature_tag,merkle)" key, whose comma cannot be expressed as a + // struct tag. + rawVectors := loadSignatureRawVectors(t) + require.Len(t, rawVectors, len(vectors)) + + var signed int + for i, tc := range vectors { + if tc.TLV != "invoice_request" || tc.Bolt12 == "" { + continue + } + signed++ + + // The vector's own comment is a paragraph of spec prose, so + // the file index names the subtest instead. + t.Run(fmt.Sprintf("vector %d", i), func(t *testing.T) { + t.Parallel() + + verifyInvoiceRequestSigVector(t, tc, rawVectors[i]) + }) + } + + require.NotZero(t, signed, "no signed invoice_request vector found") +} + +// verifyInvoiceRequestSigVector checks one signed invoice_request vector. The +// records feeding the root come from the raw stream rather than the typed +// decoder, so a decoder defect cannot mask a Merkle or signature defect. +func verifyInvoiceRequestSigVector(t *testing.T, tc sigTestVector, + raw json.RawMessage) { + + // Decode the bech32 string and convert the TLV bytes into the record + // view merkleRoot consumes. + _, tlvBytes, err := Decode(tc.Bolt12) + require.NoError(t, err) + + records := streamToRecords(t, tlvBytes) + + // Keep only the records that participate in the signature root. + unsigned := signableTLVs(records) + + root, err := merkleRoot(unsigned) + require.NoError(t, err) + + expectedRoot, err := hex.DecodeString(tc.Merkle) + require.NoError(t, err) + require.Equal(t, expectedRoot, root[:]) + + // The tag the package builds from its own constants must be the tag the + // vector was signed under. + require.Equal( + t, tc.SignatureTag, + signatureTagPrefix+tagMsgInvoiceRequest+tagFieldSignature, + ) + + sigDigest := taggedHash(tc.SignatureTag, root[:]) + + var rawMap map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &rawMap)) + + var expectedDigestHex string + require.NoError(t, json.Unmarshal( + rawMap["H(signature_tag,merkle)"], &expectedDigestHex, + )) + + expectedDigest, err := hex.DecodeString(expectedDigestHex) + require.NoError(t, err) + require.Equal(t, expectedDigest, sigDigest[:]) + + // The vector carries no key of its own, so the signer is the + // invreq_payer_id the message names. Reading it from the stream keeps + // the check independent of the typed decoder, and lets a vector signed + // by another key verify against that key instead of failing. + payerID := payerIDFromStream(t, tlvBytes) + + sigBytes, err := hex.DecodeString(tc.Signature) + require.NoError(t, err) + require.Len(t, sigBytes, 64, "vector signature is not 64 bytes") + + var sig [64]byte + copy(sig[:], sigBytes) + + require.NoError(t, verifySignature( + tagMsgInvoiceRequest, tagFieldSignature, root, sig, payerID, + )) +} + +// TestVerifyInvoiceRequestVector drives the typed verify path with the spec's +// signed invoice_request: the wire form is decoded, the vector's signature +// attached, and the result verified against the invreq_payer_id the request +// carries. This pins the tag choice, key extraction, and signable-range filter +// of the public API against the spec. +func TestVerifyInvoiceRequestVector(t *testing.T) { + t.Parallel() + + vectors := loadSignatureVectors(t) + + // Locate the invoice_request vector. + var tc sigTestVector + for _, v := range vectors { + if v.Bolt12 != "" && v.TLV == "invoice_request" { + tc = v + break + } + } + require.NotEmpty(t, tc.Bolt12) + + hrp, tlvBytes, err := Decode(tc.Bolt12) + require.NoError(t, err) + require.Equal(t, "lnr", hrp) + + ir, err := DecodeInvoiceRequest(tlvBytes) + require.NoError(t, err) + + sigBytes, err := hex.DecodeString(tc.Signature) + require.NoError(t, err) + + var sig [64]byte + copy(sig[:], sigBytes) + + ir.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240](sig), + ) + + require.NoError(t, VerifyInvoiceRequest(ir)) +} + +// TestSignatureVerifyRejectsTampering asserts that every way a malicious +// mediator can tamper with a signed message fails verification, so the +// tree-of-fields guarantee cannot collapse. +func TestSignatureVerifyRejectsTampering(t *testing.T) { + t.Parallel() + + bobPriv, bobPub := bobKey() + + var msg [32]byte + for i := range msg { + msg[i] = byte(i + 1) + } + sig, err := signMessage("invoice_request", "signature", msg, bobPriv) + require.NoError(t, err) + + // Sanity: untouched signature still verifies. + require.NoError(t, verifySignature( + "invoice_request", "signature", msg, sig, bobPub, + )) + + t.Run("tampered root", func(t *testing.T) { + t.Parallel() + + tampered := msg + tampered[0] ^= 0x01 + require.ErrorIs( + t, verifySignature( + "invoice_request", "signature", + tampered, sig, bobPub, + ), + ErrInvalidSignature, + ) + }) + + t.Run("tampered signature byte", func(t *testing.T) { + t.Parallel() + + tamperedSig := sig + tamperedSig[0] ^= 0xff + require.ErrorIs(t, + verifySignature( + "invoice_request", "signature", + msg, tamperedSig, bobPub, + ), + ErrInvalidSignature, + ) + }) + + t.Run("wrong public key", func(t *testing.T) { + t.Parallel() + + _, alicePub := aliceKey() + require.ErrorIs(t, + verifySignature( + "invoice_request", "signature", + msg, sig, alicePub, + ), + ErrInvalidSignature, + ) + }) + + t.Run("cross-tag replay rejected", func(t *testing.T) { + t.Parallel() + + // Same root, same signature, but verify under the + // invoice tag instead of invoice_request. + require.ErrorIs(t, + verifySignature( + "invoice", "signature", + msg, sig, bobPub, + ), + ErrInvalidSignature, + ) + }) + + t.Run("malformed 64-byte signature", func(t *testing.T) { + t.Parallel() + + // r = 2^256 - 1 exceeds the secp256k1 field prime, so + // ParseSignature rejects the encoding before Verify runs. + // An all-zero signature would parse cleanly and fail at + // Verify instead, never exercising the parse branch. + var malformed [64]byte + for i := range malformed[:32] { + malformed[i] = 0xff + } + + err := verifySignature( + "invoice_request", "signature", + msg, malformed, bobPub, + ) + require.ErrorIs(t, err, ErrInvalidSignature) + require.ErrorContains(t, err, "parse signature") + }) +} + +// TestNilKeyGuards pins the cryptographic key guards in the API. +func TestNilKeyGuards(t *testing.T) { + t.Parallel() + + var ( + root [32]byte + sig [64]byte + ) + + tests := []struct { + name string + call func(t *testing.T) error + want error + }{ + { + name: "sign message nil key", + call: func(t *testing.T) error { + _, err := signMessage( + "invoice_request", "signature", root, + nil, + ) + + return err + }, + want: ErrNilPrivateKey, + }, + { + name: "sign invoice request nil key", + call: func(t *testing.T) error { + _, err := SignInvoiceRequest( + validInvoiceRequest(t), nil, + ) + + return err + }, + want: ErrNilPrivateKey, + }, + { + name: "sign invoice nil key", + call: func(t *testing.T) error { + _, err := SignInvoice(validInvoice(t), nil) + + return err + }, + want: ErrNilPrivateKey, + }, + { + name: "verify signature nil key", + call: func(t *testing.T) error { + return verifySignature( + "invoice_request", "signature", + root, sig, nil, + ) + }, + want: ErrNilPublicKey, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.ErrorIs(t, tc.call(t), tc.want) + }) + } +} + +// TestVerifyInvoiceDirect drives VerifyInvoice end to end using a minimal valid +// Invoice constructed via validInvoice. +func TestVerifyInvoiceDirect(t *testing.T) { + t.Parallel() + + priv, pub := bobKey() + + tests := []struct { + name string + + // mutate adjusts the valid fixture to isolate the case under + // test, signing when the case expects success. + mutate func(t *testing.T, inv *Invoice) + + // wantErr is nil for the happy path. wantContains pins the + // field context in wrapped errors. + wantErr error + wantContains string + }{ + { + name: "valid round-trip verifies", + mutate: func(t *testing.T, inv *Invoice) { + _, err := inv.Encode() + require.NoError(t, err) + + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240]( + sig, + ), + ) + }, + }, + { + name: "missing invoice_node_id", + mutate: func(t *testing.T, inv *Invoice) { + inv.InvoiceNodeID = tlv.OptionalRecordT[ + tlv.TlvType176, *btcec.PublicKey, + ]{} + }, + wantErr: ErrMissingNodeID, + }, + { + // A present-but-nil invoice_node_id passes the + // presence check but has no key to verify + // against. + name: "nil invoice_node_id", + mutate: func(t *testing.T, inv *Invoice) { + inv.InvoiceNodeID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176]( + (*btcec.PublicKey)(nil), + ), + ) + }, + wantErr: ErrNilPublicKey, + wantContains: "invoice_node_id", + }, + { + name: "missing signature", + mutate: func(t *testing.T, inv *Invoice) { + // The fixture carries no signature. + }, + wantErr: ErrMissingSignature, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + inv.InvoiceNodeID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176](pub), + ) + tc.mutate(t, inv) + + err := VerifyInvoice(inv) + require.ErrorIs(t, err, tc.wantErr) + if tc.wantContains != "" { + require.Contains( + t, err.Error(), tc.wantContains, + ) + } + }) + } +} + +// TestVerifyInvoiceRequestDirect drives VerifyInvoiceRequest end to end using a +// minimal valid InvoiceRequest constructed via validInvoiceRequest. +func TestVerifyInvoiceRequestDirect(t *testing.T) { + t.Parallel() + + priv, pub := bobKey() + + tests := []struct { + name string + + // mutate adjusts the valid fixture to isolate the case + // under test, signing when the case expects success. + mutate func(t *testing.T, ir *InvoiceRequest) + + // wantErr is nil for the happy path. wantContains pins + // the field context in wrapped errors. + wantErr error + wantContains string + }{ + { + name: "valid round-trip verifies", + mutate: func(t *testing.T, ir *InvoiceRequest) { + sig, err := SignInvoiceRequest(ir, priv) + require.NoError(t, err) + + ir.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240]( + sig, + ), + ) + }, + }, + { + name: "missing invreq_payer_id", + mutate: func(t *testing.T, ir *InvoiceRequest) { + ir.InvreqPayerID = tlv.OptionalRecordT[ + tlv.TlvType88, *btcec.PublicKey, + ]{} + }, + wantErr: ErrMissingPayerID, + }, + { + // A present-but-nil invreq_payer_id passes the presence + // check but has no key to verify against. + name: "nil invreq_payer_id", + mutate: func(t *testing.T, ir *InvoiceRequest) { + ir.InvreqPayerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88]( + (*btcec.PublicKey)(nil), + ), + ) + }, + wantErr: ErrNilPublicKey, + wantContains: "invreq_payer_id", + }, + { + name: "missing signature", + mutate: func(t *testing.T, ir *InvoiceRequest) { + ir.Signature = tlv.OptionalRecordT[ + tlv.TlvType240, [64]byte, + ]{} + }, + wantErr: ErrMissingSignature, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ir := validInvoiceRequest(t) + ir.InvreqPayerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](pub), + ) + tc.mutate(t, ir) + + err := VerifyInvoiceRequest(ir) + require.ErrorIs(t, err, tc.wantErr) + if tc.wantContains != "" { + require.Contains( + t, err.Error(), tc.wantContains, + ) + } + }) + } +} diff --git a/bolt12/subtypes.go b/bolt12/subtypes.go index e3c49ddc00b..33f99a038f4 100644 --- a/bolt12/subtypes.go +++ b/bolt12/subtypes.go @@ -60,6 +60,58 @@ const ( maxFallbackAddrLen = math.MaxUint16 ) +// strictFeaturesRecord returns a TLV record for a top-level features field, +// whose decoder rejects non-minimal encodings with ErrNonMinimalFeatures. +// RawFeatureVector re-encodes to minimal length, so accepting a padded encoding +// would change the Merkle leaf bytes and invalidate an otherwise valid +// signature. All three message types use it so the features fields decode +// through one path. The payinfo features guard in decodeBlindedPayInfos is the +// same check one subtype level down. +func strictFeaturesRecord[T tlv.TlvType]( + t *tlv.RecordT[T, lnwire.RawFeatureVector]) tlv.Record { + + return tlv.MakeDynamicRecord( + t.TlvType(), &t.Val, + func() uint64 { return uint64(t.Val.SerializeSize()) }, + strictFeaturesEncoder, strictFeaturesDecoder, + ) +} + +// strictFeaturesEncoder writes the minimal feature vector bytes, matching the +// shared lnwire encoder. +func strictFeaturesEncoder(w io.Writer, val any, _ *[8]byte) error { + fv, ok := val.(*lnwire.RawFeatureVector) + if !ok { + return tlv.NewTypeForEncodingErr(val, "lnwire.RawFeatureVector") + } + + return fv.EncodeBase256(w) +} + +// strictFeaturesDecoder decodes a feature vector and rejects a non-minimal +// encoding, so every accepted message re-encodes to the bytes the signer +// committed to. +func strictFeaturesDecoder(r io.Reader, val any, _ *[8]byte, + l uint64) error { + + fv, ok := val.(*lnwire.RawFeatureVector) + if !ok { + return tlv.NewTypeForEncodingErr(val, "lnwire.RawFeatureVector") + } + + vec := lnwire.NewRawFeatureVector() + if err := vec.DecodeBase256(r, int(l)); err != nil { + return err + } + if vec.SerializeSize() != int(l) { + return ErrNonMinimalFeatures + } + + *fv = *vec + + return nil +} + // ChainsRecord holds one or more chain hashes for the offer_chains field. type ChainsRecord struct { Chains [][chainHashLen]byte diff --git a/bolt12/test-vectors/signature-test.json b/bolt12/test-vectors/signature-test.json new file mode 100644 index 00000000000..00d327071e6 --- /dev/null +++ b/bolt12/test-vectors/signature-test.json @@ -0,0 +1,137 @@ +[ + { + "comment": "Simple n1 test, tlv1 = 1000", + "tlv": "n1", + "first-tlv": "010203e8", + "leaves": [ + { + "H(`LnLeaf`,010203e8)": "67a2a995433890d8fe0c18a1765ad19e98f1fcfeff14c13a45bbc80964a78cf7", + "H(`LnNonce`|first-tlv,tlv1-type)": "255a95f5b6b3c6997e2838dc4d9348807fb6da8eb7bbc02d30662d144718b6aa", + "H(`LnBranch`,leaf+nonce)": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + } + ], + "branches": [], + "merkle": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + }, + { + "comment": "n1 test, tlv1 = 1000, tlv2 = 1x2x3", + "tlv": "n1", + "first-tlv": "010203e8", + "leaves": [ + { + "H(`LnLeaf`,010203e8)": "67a2a995433890d8fe0c18a1765ad19e98f1fcfeff14c13a45bbc80964a78cf7", + "H(`LnNonce`|first-tlv,tlv1-type)": "255a95f5b6b3c6997e2838dc4d9348807fb6da8eb7bbc02d30662d144718b6aa", + "H(`LnBranch`,leaf+nonce)": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + }, + { + "H(`LnLeaf`,02080000010000020003)": "cc04567fcbff60d4de87afe5142de16b7401531300554838b2d1117341a4ea8d", + "H(`LnNonce`|first-tlv,tlv2-type)": "12bc15565410d8e3251a6fb1c53a2d360f39a9f65afb8403ef875016e34ff678", + "H(`LnBranch`,leaf+nonce)": "19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0" + } + ], + "branches": [ + { + "desc": "1: tlv1+nonce and tlv2+nonce", + "H(`LnBranch`,19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93)": "c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1" + } + ], + "merkle": "c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1" + }, + { + "comment": "n1 test, tlv1 = 1000, tlv2 = 1x2x3, tlv3 = 0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518, 1, 2", + "tlv": "n1", + "first-tlv": "010203e8", + "leaves": [ + { + "H(`LnLeaf`,010203e8)": "67a2a995433890d8fe0c18a1765ad19e98f1fcfeff14c13a45bbc80964a78cf7", + "H(`LnNonce`|first-tlv,1)": "255a95f5b6b3c6997e2838dc4d9348807fb6da8eb7bbc02d30662d144718b6aa", + "H(`LnBranch`,leaf+nonce)": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + }, + { + "H(`LnLeaf`,02080000010000020003)": "cc04567fcbff60d4de87afe5142de16b7401531300554838b2d1117341a4ea8d", + "H(`LnNonce`|first-tlv,2)": "12bc15565410d8e3251a6fb1c53a2d360f39a9f65afb8403ef875016e34ff678", + "H(`LnBranch`,leaf+nonce)": "19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0" + }, + { + "H(`LnLeaf`,03310266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c0351800000000000000010000000000000002)": "47da319b36d61a006e0dbcf6642fe4c822c33a6131af67dfa9293b089c5cbd27", + "H(`LnNonce`|first-tlv,3)": "068cf6e9d2db9258a6c1d3304a8f2e9d4d046ea711664c9a96960234f707a084", + "H(`LnBranch`,leaf+nonce)": "7c879819c09f1525e7bc69b84f7928180de584f92c846e01fa2daf5b17e32967" + } + ], + "branches": [ + { + "desc": "1: tlv1+nonce and tlv2+nonce", + "H(`LnBranch`,19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93)": "c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1" + }, + { + "desc": "1 and tlv3+nonce", + "H(`LnBranch`,7c879819c09f1525e7bc69b84f7928180de584f92c846e01fa2daf5b17e32967c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1)": "ab2e79b1283b0b31e0b035258de23782df6b89a38cfa7237bde69aed1a658c5d" + } + ], + "merkle": "ab2e79b1283b0b31e0b035258de23782df6b89a38cfa7237bde69aed1a658c5d" + }, + { + "comment": "invoice_request test: offer_issuer_id = Alice (privkey 0x414141...), offer_description = 'A Mathematical Treatise', offer_amount = 100, offer_currency = 'USD', invreq_payer_id = Bob (privkey 0x424242...), invreq_metadata = 0x0000000000000000", + "bolt12": "lnr1qqyqqqqqqqqqqqqqqcp4256ypqqkgzshgysy6ct5dpjk6ct5d93kzmpq23ex2ct5d9ek293pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpjkppqvjx204vgdzgsqpvcp4mldl3plscny0rt707gvpdh6ndydfacz43euzqhrurageg3n7kafgsek6gz3e9w52parv8gs2hlxzk95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu04qz3slje2rfthc89vss", + "tlv": "invoice_request", + "first-tlv": "00080000000000000000", + "leaves": [ + { + "H(`LnLeaf`,00080000000000000000)": "cd45d50b8dbb73ba995f92aa48be7c2909331998cb070572f5499bae338a03c6", + "H(`LnNonce`|first-tlv,0)": "edc13c82e89b213a5641b27f0c06c5f31ea948a0cc2fd6495120cc8590cac3f5", + "H(`LnBranch`,leaf+nonce)": "5ced451fad76ab7edc8084b84c8b5086df195b2a503c25b371e6850a280c94ab" + }, + { + "H(`LnLeaf`,0603555344)": "ae61bfe63f8fc81b7a02a962182a5b5e01501365806481d52fbdfbca915266fa", + "H(`LnNonce`|first-tlv,6)": "cc9fc57ce5e82252b6cc8908a93f012b13294a82132768e36dd767b3c3c289e8", + "H(`LnBranch`,leaf+nonce)": "a2ea87a666c1524d25132ff59883c96a118728ff76595d239f5806143e3e9c9e" + }, + { + "H(`LnLeaf`,080164)": "b4f3adb8ca4f4a4c0e7cd9e0b1cafe8634cf8a864e1a730868bdda39fbd3e336", + "H(`LnNonce`|first-tlv,8)": "376180f1ef3b7973ba4989f9391502bd78a1a8a54929fe9adcaec1dd2bfec648", + "H(`LnBranch`,leaf+nonce)": "fa0bb4f0fa2f2625c63eec9bf3a29c9aa304e64d5aa44d38e050a6bd7d6fc5c0" + }, + { + "H(`LnLeaf`,0a1741204d617468656d61746963616c205472656174697365)": "7007775409456c33c47bddd7ce946ecd5a82035f1d5a529cc90e84d146f75a6e", + "H(`LnNonce`|first-tlv,10)": "01926a0c38b4ec71d76b116eeb81ea7999706fdce24a7f5b9d67bf867fd0c4d8", + "H(`LnBranch`,leaf+nonce)": "349379beebd68fd72296e76cb2ae28554b35fa9234853956b81b24c008783230" + }, + { + "H(`LnLeaf`,162102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619)": "bdde38b7b58fa74acee1e943bbc32c04306368cb2aa513856f53f45be461051b", + "H(`LnNonce`|first-tlv,22)": "2e571571c7dd0739dbc4180bb96b7652b055f9e97f80d37337c96689990fdbaa", + "H(`LnBranch`,leaf+nonce)": "384853c9811863028876088ce34e75d784ac027fd564f103ea972cdf96236e47" + }, + { + "H(`LnLeaf`,58210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c)": "f3b92382531e261e16a0f35d65f314ae622306bbb1b206fee00d80153b76eea3", + "H(`LnNonce`|first-tlv,88)": "c31a695332d176217470b705cde5c8cd71cdb611e1f26c5a98f14c0d935c97bd", + "H(`LnBranch`,leaf+nonce)": "73e067757513706491e0da4e8077112e606da55c04239ad13ab609bc82907600" + } + ], + "branches": [ + { + "desc": "1: metadata+nonce and currency+nonce", + "H(`LnBranch`,5ced451fad76ab7edc8084b84c8b5086df195b2a503c25b371e6850a280c94aba2ea87a666c1524d25132ff59883c96a118728ff76595d239f5806143e3e9c9e)": "f0aa4611039a3a8a90dc8331fa75c9acf433be7285cac0983902aaaa8f66aaa9" + }, + { + "desc": "2: amount+nonce and descripton+nonce", + "H(`LnBranch`,349379beebd68fd72296e76cb2ae28554b35fa9234853956b81b24c008783230fa0bb4f0fa2f2625c63eec9bf3a29c9aa304e64d5aa44d38e050a6bd7d6fc5c0)": "92e6478159d6763b19c5d03a8a834e179116f89e0cec700049e5ce921f8c400e" + }, + { + "desc": "3: 1 and 2", + "H(`LnBranch`,92e6478159d6763b19c5d03a8a834e179116f89e0cec700049e5ce921f8c400ef0aa4611039a3a8a90dc8331fa75c9acf433be7285cac0983902aaaa8f66aaa9)": "432097bd1a848ab41eee3695a2c5932c4aea987b27b1a61e58ac950ecce1214a" + }, + { + "desc": "4: node_id+nonce and payer_id+nonce", + "H(`LnBranch`,384853c9811863028876088ce34e75d784ac027fd564f103ea972cdf96236e4773e067757513706491e0da4e8077112e606da55c04239ad13ab609bc82907600)": "2ac9b0261d644027939d9a7bd055cb2468b79d92c6811d56a300c6b8ff97c14d" + }, + { + "desc": "5: 3 and 4", + "H(`LnBranch`,2ac9b0261d644027939d9a7bd055cb2468b79d92c6811d56a300c6b8ff97c14d432097bd1a848ab41eee3695a2c5932c4aea987b27b1a61e58ac950ecce1214a)": "608407c18ad9a94d9ea2bcdbe170b6c20c462a7833a197621c916f78cf18e624" + } + ], + "merkle": "608407c18ad9a94d9ea2bcdbe170b6c20c462a7833a197621c916f78cf18e624", + "signature_tag": "lightninginvoice_requestsignature", + "H(signature_tag,merkle)": "aefe3aa88a69772c246dcaef75ed3e7566c08ecc4e9f995233526a5651fc34cd", + "signature": "b8f83ea3288cfd6ea510cdb481472575141e8d8744157f98562d162cc1c472526fdb24befefbdebab4dbb726bbd1b7d8aec057f8fa805187e5950d2bbe0e5642" + } +] diff --git a/bolt12/validate.go b/bolt12/validate.go index b80daaa49a1..f701937075e 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -132,11 +132,11 @@ var ( // structurally malformed or contains a non-alphabet byte. ErrInvalidBip353Name = errors.New("invalid invreq_bip_353_name") - // ErrMissingSignature is returned when a wire-form invoice or - // invoice_request is emitted without a populated signature TLV. - // Pre-sign Encode (used to compute the Merkle root) is permitted to run - // without a signature; the bech32 string-codec layer is where the - // signature becomes mandatory. + // ErrMissingSignature is returned when an invoice or invoice_request + // is encoded to its wire string or verified without a populated + // signature TLV. Pre-sign Encode (used to compute the Merkle root) + // is permitted to run without a signature; the wire-string layer is + // where the signature becomes mandatory. ErrMissingSignature = errors.New("missing signature") // ErrOfferFieldsOnSpontaneous is returned when an invoice request @@ -401,8 +401,9 @@ func ValidateInvoiceRequestWrite(ir *InvoiceRequest) error { // - MUST set signature.sig using the invreq_payer_id. // NOT CHECKED HERE: signing happens after this validator runs; - // the string encoder rejects an unsigned request and the reader - // verifies signature correctness. + // pre-sign Encode is permitted, so an unsigned request passes + // this validator and Encode. The wire-string layer rejects an + // unsigned request, and the reader verifies correctness. // - MUST set invreq_payer_id to a transient public key. // NOT CHECKED HERE: only presence is checked below; the caller @@ -615,11 +616,8 @@ func getInvreqChain(ir *InvoiceRequest) [32]byte { // Stateful or contextual checks (offer matching, path verification, unit-price // calculations) must be handled externally by the caller. // -// Signature verification is NOT performed yet: the reader MUST also reject a -// request whose Schnorr signature does not verify against invreq_payer_id, but -// that check is deferred until the merkle/signing primitives land with the -// Invoice message (see the TODO at the end of this function). Until then, a -// caller wiring this into a handler MUST verify the signature itself. +// The final check is cryptographic: the reader rejects a request whose +// BIP-340 Schnorr signature does not verify against invreq_payer_id. func ValidateInvoiceRequestRead(ir *InvoiceRequest, activeChain [32]byte, knownFeatures map[lnwire.FeatureBit]string) error { @@ -773,12 +771,7 @@ func ValidateInvoiceRequestRead(ir *InvoiceRequest, // - MUST reject the invoice request if signature is not correct as // detailed in Signature Calculation using the invreq_payer_id. - // TODO(bolt12): implement signature verification. - if !ir.Signature.IsSome() { - return ErrMissingSignature - } - - return nil + return VerifyInvoiceRequest(ir) } // getInvoiceRequestOfferChains returns the chains an invoice request's mirrored @@ -1377,8 +1370,10 @@ func ValidateInvoiceWrite(inv *Invoice) error { // - MUST specify exactly one signature TLV element: signature. // - MUST set sig to the signature using invoice_node_id as described // in Signature Calculation. - // NOT CHECKED HERE: signing happens after this validator runs. The - // string-codec layer rejects an unsigned invoice, mirroring + // NOT CHECKED HERE: signing happens after this validator runs; + // pre-sign Encode is permitted, so an unsigned invoice passes this + // validator and Encode. The wire-string layer rejects an unsigned + // invoice, and the reader verifies correctness, mirroring // ValidateInvoiceRequestWrite. // - if the expiry for accepting payment is not 7200 seconds after @@ -1671,14 +1666,14 @@ type InvoiceFeatureCatalogues struct { // ValidateInvoiceRead validates an invoice against the BOLT 12 reader // requirements, running the stateless structural checks against activeChain -// (the chain the reader supports). +// (the chain the reader supports). The final check is cryptographic: the +// reader rejects an invoice whose BIP-340 Schnorr signature does not verify +// against invoice_node_id. // -// Note: This only performs stateless structural checks. Cryptographic Schnorr -// signature verification and identity-path binding are deferred to the caller -// (see the TODO at the end of this function). Additionally, while it verifies -// that at least one usable path is present, downstream callers must re-apply -// the same features.Blinded filter at path selection time (via -// Invoice.UsablePaths) to avoid selecting paths with unknown required features. +// Note: while it verifies that at least one usable path is present, +// downstream callers must re-apply the same features.Blinded filter at path +// selection time (via Invoice.UsablePaths) to avoid selecting paths with +// unknown required features. func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, features InvoiceFeatureCatalogues) error { // - MUST reject the invoice if invoice_amount is not present. @@ -1815,14 +1810,6 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, return err } - // - MUST reject the invoice if signature is not a valid signature using - // invoice_node_id as described in Signature Calculation. - // TODO(bolt12): implement signature verification. For now only - // presence is enforced, mirroring ValidateInvoiceRequestRead. - if !inv.Signature.IsSome() { - return ErrMissingSignature - } - // - SHOULD prefer to use earlier invoice_paths over later ones if it // has no other reason for preference. // - if invoice_features contains the MPP/compulsory bit: MUST pay @@ -1841,5 +1828,7 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // ValidateInvoiceAgainstRequest; the fallback ignore rules by // UsableFallbackAddresses. - return nil + // - MUST reject the invoice if signature is not a valid signature using + // invoice_node_id as described in Signature Calculation. + return VerifyInvoice(inv) } diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index 4fd32e0a5db..ee576185f1d 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -705,34 +705,217 @@ func addAmountAndDescription(o *Offer) { } // validInvoiceRequest is the spec-minimal happy-path invoice request that -// each table row mutates to isolate the rule under test. +// each table row mutates to isolate the rule under test. The request is +// encoded, decoded, and signed with Bob's key, so reader validation sees +// the same wire form a peer would send. func validInvoiceRequest(t *testing.T) *InvoiceRequest { t.Helper() - ir := &InvoiceRequest{} + priv, _ := bobKey() - privKey, err := btcec.NewPrivateKey() + return signedInvoiceRequest(t, priv) +} + +// signedInvoiceRequest builds the spec-minimal invoice request, round-trips it +// through the wire codec, and signs the decoded copy with signer. +// invreq_payer_id always names Bob, so a signer other than Bob yields a +// well-formed signature over the correct Merkle root under the wrong key. +func signedInvoiceRequest(t testing.TB, + signer *btcec.PrivateKey) *InvoiceRequest { + + t.Helper() + + _, pub := bobKey() + + ir := &InvoiceRequest{ + OfferDescription: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType10]( + tlv.Blob("description"), + ), + ), + InvreqPayerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](pub), + ), + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + tlv.Blob("metadata"), + ), + ), + InvreqAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType82, TUint64]( + TUint64(1000), + ), + ), + } + + encoded, err := ir.Encode() + require.NoError(t, err) + + decoded, err := DecodeInvoiceRequest(encoded) require.NoError(t, err) - ir.InvreqPayerID = tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType88](privKey.PubKey()), + sig, err := SignInvoiceRequest(decoded, signer) + require.NoError(t, err) + decoded.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240](sig), ) - ir.InvreqMetadata = tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType0]( - []byte("metadata"), - ), + return decoded +} + +// signedInvoice signs the spec-minimal invoice with signer. invoice_node_id +// always names Bob, so a signer other than Bob yields a well-formed signature +// over the correct Merkle root under the wrong key. +func signedInvoice(t *testing.T, signer *btcec.PrivateKey) *Invoice { + t.Helper() + + inv := validInvoice(t) + + sig, err := SignInvoice(inv, signer) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), ) - ir.InvreqAmount = tlv.SomeRecordT( - tlv.NewRecordT[tlv.TlvType82, TUint64](1000), + return inv +} + +// TestValidateInvoiceRequestRead verifies that a freshly signed, decoded +// invoice request passes reader validation. +func TestValidateInvoiceRequestRead(t *testing.T) { + t.Parallel() + + ir := validInvoiceRequest(t) + + err := ValidateInvoiceRequestRead(ir, bitcoinMainnetGenesisHash, nil) + require.NoError(t, err) + + // A request without a signature must be rejected. + irNoSig := *ir + irNoSig.Signature = tlv.OptionalRecordT[tlv.TlvType240, [64]byte]{} + err = ValidateInvoiceRequestRead( + &irNoSig, bitcoinMainnetGenesisHash, nil, ) + require.ErrorIs(t, err, ErrMissingSignature) +} - ir.Signature = tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType240]([64]byte{0x01}), +// flipValueByte returns a copy of encoded with the first byte of needle +// inverted. needle must be the value of a TLV inside the signed range, so the +// mutation moves the Merkle root instead of a field the signature does not +// commit to. +// +// The needle must occur exactly once. A second occurrence would mean the +// caller cannot tell which field the flip lands on, and a flip that strayed +// into the signature TLV would still produce ErrInvalidSignature while no +// longer testing that a signed field is bound to the root. +func flipValueByte(t *testing.T, encoded, needle []byte) []byte { + t.Helper() + + require.Equal( + t, 1, bytes.Count(encoded, needle), + "needle must identify exactly one field", ) - return ir + out := bytes.Clone(encoded) + out[bytes.Index(encoded, needle)] ^= 0xff + + return out +} + +// TestValidateReadRejectsBadSignature pins the reader-side signature gate on +// both message types. ValidateInvoiceRequestRead and ValidateInvoiceRead key +// the check on different public keys, so covering one does not cover the +// other. +// +// The mutated-bytes rows also guard the decision to derive the Merkle root +// from re-encoded records: a decode-then-encode divergence on a signed field +// would surface here as a rejection of the untouched message. +func TestValidateReadRejectsBadSignature(t *testing.T) { + t.Parallel() + + bobPriv, _ := bobKey() + alicePriv, _ := aliceKey() + + tests := []struct { + name string + validate func(*testing.T) error + }{ + { + name: "invoice_request wrong key", + validate: func(t *testing.T) error { + ir := signedInvoiceRequest(t, alicePriv) + + return ValidateInvoiceRequestRead( + ir, bitcoinMainnetGenesisHash, nil, + ) + }, + }, + { + name: "invoice_request mutated after signing", + validate: func(t *testing.T) error { + encoded, err := signedInvoiceRequest( + t, bobPriv, + ).Encode() + require.NoError(t, err) + + // invreq_metadata is a signed opaque blob, so + // flipping a byte of its value moves the root + // and still decodes. + ir, err := DecodeInvoiceRequest(flipValueByte( + t, encoded, []byte("metadata"), + )) + require.NoError(t, err) + + return ValidateInvoiceRequestRead( + ir, bitcoinMainnetGenesisHash, nil, + ) + }, + }, + { + name: "invoice wrong key", + validate: func(t *testing.T) error { + inv := signedInvoice(t, alicePriv) + + return ValidateInvoiceRead( + inv, bitcoinMainnetGenesisHash, + InvoiceFeatureCatalogues{}, + ) + }, + }, + { + name: "invoice mutated after signing", + validate: func(t *testing.T) error { + signed := signedInvoice(t, bobPriv) + + encoded, err := signed.Encode() + require.NoError(t, err) + + // invoice_payment_hash is a signed fixed-width + // opaque field, so flipping a byte of its + // value moves the root and still decodes. + hash := signed.InvoicePaymentHash.ValOpt(). + UnwrapOrFail(t) + + inv, err := DecodeInvoice(flipValueByte( + t, encoded, hash[:], + )) + require.NoError(t, err) + + return ValidateInvoiceRead( + inv, bitcoinMainnetGenesisHash, + InvoiceFeatureCatalogues{}, + ) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.ErrorIs(t, tc.validate(t), ErrInvalidSignature) + }) + } } // TestValidateInvoiceRequestWrite pins the BOLT 12 writer-side MUSTs so a @@ -1234,6 +1417,11 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) { mutate func(*InvoiceRequest) known map[lnwire.FeatureBit]string wantErr error + + // resign re-signs the mutated request before validation. + // Rows that mutate a signed field and still expect success + // need a fresh signature over the mutated records. + resign bool }{ { name: "missing payer id", @@ -1461,6 +1649,7 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) { 0: "test_feature", }, wantErr: nil, + resign: true, }, } @@ -1471,10 +1660,13 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) { ir := validInvoiceRequest(t) tc.mutate(ir) - if tc.name == "known even feature bit accepted" { + if tc.resign { + priv, _ := bobKey() + sig, err := SignInvoiceRequest(ir, priv) + require.NoError(t, err) ir.Signature = tlv.SomeRecordT( tlv.NewPrimitiveRecord[tlv.TlvType240]( - [64]byte{0x01}, + sig, ), ) } @@ -1957,7 +2149,7 @@ func TestValidateInvoiceRead(t *testing.T) { func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) { t.Parallel() - _, pub := bobKey() + priv, pub := bobKey() _, intro := aliceKey() _, blinding := bobKey() @@ -1998,17 +2190,22 @@ func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) { BlindedPayInfos{Infos: []BlindedPayInfo{{}}}, ), ), - Signature: tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte]( - [64]byte{}, - ), - ), } // An unknown odd type at 241 sits inside the signature range and must - // be ignored, not rejected as out-of-range or unknown-even. + // be ignored, not rejected as out-of-range or unknown-even. It is + // excluded from the signature's Merkle root, so signing is unaffected + // by it. inv.decodedTLVs = tlv.TypeMap{241: nil} + // Sign with the fixture's node id (Bob) so the read path's signature + // check accepts the invoice. + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), + ) + err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, @@ -2587,11 +2784,6 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { t.Parallel() inv := validInvoice(t) - inv.Signature = tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType240]( - [64]byte{}, - ), - ) // Set MPP required (bit 16, even/required) fv := *lnwire.NewRawFeatureVector(lnwire.MPPRequired) @@ -2599,8 +2791,17 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { tlv.NewRecordT[tlv.TlvType174](fv), ) + // Sign with the fixture's node id (Bob) so the read + // path's signature check accepts the invoice. + priv, _ := bobKey() + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), + ) + // An unknown required bit must be rejected. - err := ValidateInvoiceRead( + err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, ) @@ -2623,11 +2824,6 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { t.Parallel() inv := validInvoice(t) - inv.Signature = tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType240]( - [64]byte{}, - ), - ) // Set an even required feature bit on the path's features (e.g. // bit 16). @@ -2640,9 +2836,18 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { }), ) + // Sign with the fixture's node id (Bob) so the read + // path's signature check accepts the invoice. + priv, _ := bobKey() + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), + ) + // If there are no known features in the catalogue, there are // zero usable paths and we expect ErrNoUsablePaths. - err := ValidateInvoiceRead( + err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, ) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index f10390991d3..e1b52942be3 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -138,12 +138,23 @@ add checksumless bech32 encoding/decoding for BOLT 12 `lno`, `lnr`, and `lni` strings with continuation line handling. +* [BOLT 12 Merkle tree and BIP-340 + signatures](https://github.com/lightningnetwork/lnd/pull/11061): add Merkle + tree construction over TLV records and BIP-340 Schnorr message signatures for + invoice requests and invoices, and verify the signature on read so a decoded + message with an invalid signature is rejected. + ## Testing * [BOLT 12 spec test vectors](https://github.com/lightningnetwork/lnd/pull/11001): add spec test vectors for offer decoding and format string parsing in `bolt12/test-vectors/`. +* [BOLT 12 signature test + vectors](https://github.com/lightningnetwork/lnd/pull/11061): add spec test + vectors pinning Merkle tree construction and BIP-340 signature verification + in `bolt12/test-vectors/`. + ## Database ## Code Health