Merge pull request #11061 from bitromortac/2604-bolt12-1f
What changed, and why it matters
This commit adds the missing cryptographic signature layer for BOLT 12 offers in LND. It builds a Merkle tree from the message fields, signs that tree with a BIP-340 Schnorr signature, and now rejects invoice requests and invoices whose signatures do not verify. It also tightens decoding so non-minimal encodings of feature bits and amounts are rejected, preventing a message from being accepted in one form but re-encoded into a different byte form that would break the signature.
Review the Merkle leaf encoding path for any field that does not round-trip byte-exactly (especially custom/experimental TLVs and amount types), confirm that all production call sites now invoke VerifyInvoiceRequest/VerifyInvoice or ValidateInvoiceRequestRead/ValidateInvoiceRead, and run the new spec-vector tests before release.
Security signals we found
Adds BIP-340 Schnorr signature verification for BOLT 12 invoice_request and invoice messages
Merkle tree commits to canonical re-encoded TLV records; non-minimal feature/amount encodings now rejected to preserve byte-exactness
Signature TLV type 240 and reserved range 240-1000 excluded from the signed Merkle root
Reader validation now rejects missing or invalid signatures instead of only checking presence
Extensive test coverage including spec vectors, tampering tests, nil-key guards, and order-sensitivity property tests
Evidence from the diff
The change introduces bolt12/merkle.go (Merkle root over TLV records using LnLeaf/LnNonce/LnBranch tagged hashes), bolt12/signature.go (BIP-340 Schnorr sign/verify for invoice_request and invoice messages), strict feature-vector decoding in bolt12/subtypes.go, and wires signature verification into ValidateInvoiceRequestRead and ValidateInvoiceRead. It replaces placeholder signature handling with real verification against invreq_payer_id / invoice_node_id and adds spec test vectors.
Changed components
bolt12/merkle.gobolt12/signature.gobolt12/subtypes.gobolt12/validate.gobolt12/invoice.gobolt12/invoice_request.gobolt12/offer.gobolt12/decode_test.gobolt12/validate_test.gobolt12/signature_test.gobolt12/merkle_test.gobolt12/helpers_test.gobolt12/invoice_test.gobolt12/test-vectors/signature-test.jsondocs/release-notes/release-notes-0.22.0.mdInspect captured patch +2076 / −78
### 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())
+}
### 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
+}
### 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)
### 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(),
)
### 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()
### 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
+}
### 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`,<hex>)
+ 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', <hex>)` 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', <hashA||hashB>) 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', <hashA||hashB>) 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,
+ )
+ }
+}
### 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(),
### 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,
+ )
+}
### 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,
+ )
+ }
+ })
+ }
+}
### 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
### 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"
+ }
+]
### 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)
}
### 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,20 +2784,24 @@ 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)
inv.InvoiceFeatures = tlv.SomeRecordT(
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{},
)
### docs/release-notes/release-notes-0.22.0.md
@@ -137,12 +137,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 HealthWhy this scored 34/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.