From 1fd308edcc93792d4182b1af92df67fec96b7cb6 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Wed, 2 Sep 2026 09:11:19 +0000 Subject: [PATCH 1/5] bolt12: add strict features subtype Add strictFeaturesRecord, a TLV record for the top-level features fields whose decoder rejects non-minimally encoded feature vectors 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. The payinfo features guard in decodeBlindedPayInfos is the same check one subtype level down. All three message types use it, so the features fields decode through a single path. An offer holds no signature of its own, so the record buys it nothing directly, but no producer emits a padded vector and a second lenient path would earn its keep only if one did. Dropping this guard would not make lnd accept a padded vector on a signed message: the vector decodes, re-encodes one byte shorter, and the message is then refused at signature verification, which is the same rejection behind a less obvious error. Accepting one requires deriving the root from the bytes the peer sent rather than from re-encoded records. --- bolt12/invoice.go | 8 +++--- bolt12/invoice_request.go | 5 ++-- bolt12/offer.go | 2 +- bolt12/subtypes.go | 52 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) 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/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/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 From e1f2f190bd39053bb59ceeaa6469228cd9149aa3 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Wed, 2 Sep 2026 09:11:29 +0000 Subject: [PATCH 2/5] bolt12: add Merkle tree construction BOLT 12 signs the Merkle root of a tree built from the message's TLV records. MerkleRoot constructs the tree from a []tlv.Record by hashing each record into an LnLeaf and a uniqueness LnNonce, combining them into LnBranch nodes, and recursing to a single root. signableTLVs selects the records that enter the root, excluding the 240-1000 range the spec reserves for signature fields. The vendored signature-test.json vectors pin the construction byte-for-byte against the spec, down to every intermediate LnLeaf, LnNonce, and LnBranch digest. --- bolt12/decode_test.go | 121 ++++++ bolt12/helpers_test.go | 125 ++++++ bolt12/merkle.go | 163 ++++++++ bolt12/merkle_test.go | 506 ++++++++++++++++++++++++ bolt12/test-vectors/signature-test.json | 137 +++++++ 5 files changed, 1052 insertions(+) create mode 100644 bolt12/decode_test.go create mode 100644 bolt12/merkle.go create mode 100644 bolt12/merkle_test.go create mode 100644 bolt12/test-vectors/signature-test.json 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..a740bb495b2 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,125 @@ 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) +} + +// 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/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/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" + } +] From dfdcd5072e52716f42fbdef69f85783131940b12 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 6 Aug 2026 19:18:34 +0000 Subject: [PATCH 3/5] bolt12: add BIP-340 message signatures SignMessage and VerifySignature wrap btcec's Schnorr API around the Merkle root with the spec's tagged-hash domain separator. Typed helpers bind the construction to invoice requests and invoices, so callers sign and verify messages rather than raw roots. The signed vectors in signature-test.json verify the construction against the spec, and a tamper matrix pins rejection of modified roots, signatures, keys, and tags. --- bolt12/helpers_test.go | 21 ++ bolt12/signature.go | 171 ++++++++++++++ bolt12/signature_test.go | 488 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 680 insertions(+) create mode 100644 bolt12/signature.go create mode 100644 bolt12/signature_test.go diff --git a/bolt12/helpers_test.go b/bolt12/helpers_test.go index a740bb495b2..17469cd2d14 100644 --- a/bolt12/helpers_test.go +++ b/bolt12/helpers_test.go @@ -134,6 +134,27 @@ func streamToRecords(t *testing.T, data []byte) []tlv.Record { 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 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, + ) + } + }) + } +} From aa8bd19f9534e8f7e65517bdefa2181c4ac2c75b Mon Sep 17 00:00:00 2001 From: bitromortac Date: Wed, 2 Sep 2026 11:53:36 +0000 Subject: [PATCH 4/5] bolt12: verify invoice_request and invoice signatures on read ValidateInvoiceRequestRead and ValidateInvoiceRead now run VerifyInvoiceRequest and VerifyInvoice as their final step, so a decoded message whose BIP-340 signature does not verify against invreq_payer_id or invoice_node_id is rejected instead of only checked for presence. This satisfies the reader-side MUSTs of the BOLT 12 invoice_request and invoice requirements, and the docstrings now state the contract instead of deferring it to callers. The invoice_request wiring is pinned by the spec's signed vector; no signed invoice vector exists, so the invoice side is pinned by round-trip tests. Reader-validation fixtures now sign with Bob's key, and the happy-path invoice_request test reuses the shared validInvoiceRequest fixture. --- bolt12/invoice_test.go | 8 +- bolt12/validate.go | 61 ++++----- bolt12/validate_test.go | 275 +++++++++++++++++++++++++++++++++++----- 3 files changed, 272 insertions(+), 72 deletions(-) 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/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{}, ) From 9cfd5a669a88d5e3d782e62b3b30642facb46d44 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 11 Aug 2026 15:22:07 +0000 Subject: [PATCH 5/5] docs: update release notes Add changes for the Merkle tree and BIP-340 signature work in bolt12. --- docs/release-notes/release-notes-0.22.0.md | 11 +++++++++++ 1 file changed, 11 insertions(+) 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