diff --git a/bolt12/doc.go b/bolt12/doc.go index c58a1ced1d3..809c31ad2bc 100644 --- a/bolt12/doc.go +++ b/bolt12/doc.go @@ -16,4 +16,17 @@ // the writer requirements, invalid bytes are unrepresentable on the wire. // Low-level decoders stay permissive so diagnostic and fuzz harnesses can // inspect malformed input. +// +// DecodeOfferString, DecodeInvoiceRequestString, and DecodeInvoiceString +// (with their Encode counterparts) are the consumer entry point. Each folds +// bech32, the per-message TLV codec, and the spec reader gates into one +// validated call. +// +// An invoice that arrives as the response to an invoice request needs two +// further bindings that the message alone cannot supply, the mirror match +// against that request and the blinded-path node binding. A payer holding +// that request gates the decoded invoice with ValidateInvoiceForPayment, +// which the string wrappers cannot do for it. Such an invoice arrives as raw +// TLV over an onion message rather than as a string, so the payer path +// decodes with DecodeInvoice and validates separately. package bolt12 diff --git a/bolt12/fuzz_test.go b/bolt12/fuzz_test.go new file mode 100644 index 00000000000..35f18fb6754 --- /dev/null +++ b/bolt12/fuzz_test.go @@ -0,0 +1,306 @@ +package bolt12 + +import ( + "bytes" + "encoding/hex" + "testing" + "time" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// offerStringSeeds returns the bolt12 strings from offers-test.json. Every +// spec-compliant string becomes a corpus entry the mutator can branch from, +// reaching code paths randomly drawn bytes never reach. +func offerStringSeeds(t testing.TB) []string { + t.Helper() + + var seeds []string + for _, v := range loadOffersVectors(t) { + if v.Bolt12 != "" { + seeds = append(seeds, v.Bolt12) + } + } + + return seeds +} + +// invreqStringSeeds returns the bolt12 strings from signature-test.json, which +// exercise the invoice_request format. +func invreqStringSeeds(t testing.TB) []string { + t.Helper() + + var seeds []string + for _, v := range loadSignatureVectors(t) { + if v.Bolt12 != "" { + seeds = append(seeds, v.Bolt12) + } + } + + return seeds +} + +// tlvStreams bech32-decodes each string and collects the TLV payloads, skipping +// strings that fail to decode. Byte-level decoders get the same corpus benefit +// as the string decoders without routing through bech32. +func tlvStreams(t testing.TB, strings []string) [][]byte { + t.Helper() + + var seeds [][]byte + for _, s := range strings { + _, tlvBytes, err := Decode(s) + if err != nil { + continue + } + seeds = append(seeds, tlvBytes) + } + + return seeds +} + +// offerTLVSeeds returns the TLV streams behind the offers-test.json strings. +func offerTLVSeeds(t testing.TB) [][]byte { + t.Helper() + + return tlvStreams(t, offerStringSeeds(t)) +} + +// invreqTLVSeeds returns the TLV streams behind the signature-test.json invoice +// request strings. +func invreqTLVSeeds(t testing.TB) [][]byte { + t.Helper() + + return tlvStreams(t, invreqStringSeeds(t)) +} + +// byteCodec constrains PM to *M with an Encode method, the shape every message +// decoder returns. The pointer core type makes PM nilable, so the harness can +// compare a decoded message against nil. A method-only constraint would admit +// non-pointer types and the check would not compile. +type byteCodec[M any] interface { + *M + Encode() ([]byte, error) +} + +// fuzzByteCodec registers a byte-level decode harness on f. Decode must never +// panic, and a nil message with nil error is fatal. A decoded message that +// passes writer validation must round-trip encode→decode→encode +// byte-identically. +func fuzzByteCodec[M any, PM byteCodec[M]](f *testing.F, + decode func([]byte) (PM, error), seeds ...[]byte) { + + for _, seed := range seeds { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, data []byte) { + msg, err := decode(data) + if err != nil { + return + } + if msg == nil { + t.Fatal("nil message with nil error") + } + + encoded, err := msg.Encode() + if err != nil { + // Read accepts constraints write rejects, so a decoded + // message may fail writer validation. Skip the + // round-trip then. + return + } + + again, err := decode(encoded) + if err != nil { + t.Fatalf("round-trip decode failed: %v", err) + } + encoded2, err := again.Encode() + if err != nil { + t.Fatalf("second encode failed: %v", err) + } + if !bytes.Equal(encoded, encoded2) { + t.Fatal("round-trip changed encoded bytes") + } + }) +} + +// fuzzStringCodec registers a bech32 string decode harness on f: decoding any +// mutated string must return cleanly, never panic. +func fuzzStringCodec(f *testing.F, decode func(string), seeds ...string) { + for _, seed := range seeds { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, s string) { + decode(s) + }) +} + +// FuzzDecodeOffer fuzzes decodeOffer with offers-test.json corpus seeds. Decode +// must never panic and valid decodes round-trip byte-identically. +func FuzzDecodeOffer(f *testing.F) { + fuzzByteCodec(f, decodeOffer, offerTLVSeeds(f)...) +} + +// FuzzDecodeInvoiceRequest fuzzes DecodeInvoiceRequest with signature-test.json +// corpus seeds. Decode must never panic and valid decodes round-trip +// byte-identically. +func FuzzDecodeInvoiceRequest(f *testing.F) { + fuzzByteCodec(f, DecodeInvoiceRequest, invreqTLVSeeds(f)...) +} + +// FuzzDecodeInvoice fuzzes DecodeInvoice with a minimal type-168 seed. Decode +// must never panic and valid decodes round-trip byte-identically. +func FuzzDecodeInvoice(f *testing.F) { + fuzzByteCodec(f, DecodeInvoice, []byte{ + 0xa8, 0x20, // type=168, length=32 + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + }) +} + +// FuzzDecodeInvoiceError fuzzes DecodeInvoiceError with a type-5 error seed. +// Decode must never panic and valid decodes round-trip byte-identically. +func FuzzDecodeInvoiceError(f *testing.F) { + fuzzByteCodec(f, DecodeInvoiceError, []byte{ + 0x05, 0x05, // type=5 error, length=5 + 'h', 'e', 'l', 'l', 'o', + }) +} + +// FuzzDecodeOfferString exercises the offer bech32 wrapper, reader gates +// included. +func FuzzDecodeOfferString(f *testing.F) { + fuzzStringCodec(f, func(s string) { + _, _ = DecodeOfferString( + s, farFutureNow(), bitcoinMainnetGenesisHash, + ) + }, offerStringSeeds(f)...) +} + +// FuzzDecodeInvoiceRequestString exercises the invoice request bech32 wrapper, +// reader gates included. +func FuzzDecodeInvoiceRequestString(f *testing.F) { + fuzzStringCodec(f, func(s string) { + _, _ = DecodeInvoiceRequestString( + s, bitcoinMainnetGenesisHash, + ) + }, invreqStringSeeds(f)...) +} + +// FuzzDecodeInvoiceString exercises the invoice bech32 wrapper, reader gates +// included. The fixed clock sits one second after the seed invoice's creation +// time so the seed passes the expiry gate and exercises the full decode path. +func FuzzDecodeInvoiceString(f *testing.F) { + priv, _ := bobKey() + inv := validInvoice(f) + + sig, err := SignInvoice(inv, priv) + require.NoError(f, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), + ) + + seed, err := EncodeInvoiceString(inv) + require.NoError(f, err) + + fuzzStringCodec(f, func(s string) { + _, _ = DecodeInvoiceString( + s, time.Unix(1234567890+1, 0), + bitcoinMainnetGenesisHash, + ) + }, seed) +} + +// FuzzBech32RoundTrip pins the Encode/Decode bijection on the bech32 layer +// alone, decoupled from any TLV-level concerns. The mutator can permute HRP, +// length, and bytes. Any input that round-trips successfully must yield the +// original (hrp, data) pair. +func FuzzBech32RoundTrip(f *testing.F) { + f.Add(uint8(0), []byte{0x00}) + f.Add(uint8(1), []byte{0xab, 0xcd, 0xef}) + f.Add(uint8(2), bytes.Repeat([]byte{0x42}, 256)) + + hrps := []string{HRPOffer, HRPInvoiceRequest, HRPInvoice} + + f.Fuzz(func(t *testing.T, hrpIdx uint8, data []byte) { + if len(data) == 0 { + return + } + + hrp := hrps[int(hrpIdx)%len(hrps)] + encoded, err := Encode(hrp, data) + if err != nil { + return + } + + gotHRP, gotData, err := Decode(encoded) + if err != nil { + t.Fatalf( + "decode after successful encode "+ + "failed: %v", err, + ) + } + if gotHRP != hrp { + t.Fatalf( + "hrp mismatch: encoded with %q, "+ + "decoded as %q", hrp, gotHRP, + ) + } + if !bytes.Equal(gotData, data) { + t.Fatalf( + "data mismatch: input %s, output %s", + hex.EncodeToString(data), + hex.EncodeToString(gotData), + ) + } + }) +} + +// FuzzMerkleRootDeterminism asserts merkleRoot is deterministic: the root is +// recomputed independently at sign and verify time, so any nondeterminism +// silently breaks signature reproducibility. Two calls over identical records +// must return identical roots. The parse uses skip-on-error semantics, so it +// cannot reuse streamToRecords, which fails the test on malformed input. +func FuzzMerkleRootDeterminism(f *testing.F) { + seeds := append(offerTLVSeeds(f), invreqTLVSeeds(f)...) + for _, seed := range seeds { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, data []byte) { + stream, err := tlv.NewStream() + require.NoError(t, err) + + typeMap, err := stream.DecodeWithParsedTypesP2P( + bytes.NewReader(data), + ) + + // The test pins determinism, not TLV validity, so inputs that + // yield no records are skipped. + if err != nil || len(typeMap) == 0 { + return + } + + records := lnwire.TlvMapToRecords(typeMap) + + root1, err := merkleRoot(records) + if err != nil { + return + } + + root2, err := merkleRoot(records) + if err != nil { + t.Fatalf("second merkleRoot call failed: %v", err) + } + + if root1 != root2 { + t.Fatal("merkleRoot returned different roots for " + + "identical records") + } + }) +} diff --git a/bolt12/helpers_test.go b/bolt12/helpers_test.go index 17469cd2d14..80c8c1152d2 100644 --- a/bolt12/helpers_test.go +++ b/bolt12/helpers_test.go @@ -110,7 +110,7 @@ var loadOffersVectorsOnce = sync.OnceValues( // loadOffersVectors returns the parsed offers-test.json vectors, failing the // test if the file is unreadable or malformed. -func loadOffersVectors(t *testing.T) []offersTestVector { +func loadOffersVectors(t testing.TB) []offersTestVector { t.Helper() vectors, err := loadOffersVectorsOnce() @@ -119,6 +119,22 @@ func loadOffersVectors(t *testing.T) []offersTestVector { return vectors } +// findTestVector returns the offers-test.json vector matching desc, failing +// the test if no match is found. +func findTestVector(t *testing.T, desc string) offersTestVector { + t.Helper() + + for _, v := range loadOffersVectors(t) { + if v.Description == desc { + return v + } + } + + t.Fatalf("test vector not found: %s", desc) + + return offersTestVector{} +} + // 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. @@ -222,7 +238,7 @@ var loadSignatureVectorsOnce = sync.OnceValues( // loadSignatureVectors returns the parsed sigTestVector slice, failing the // test if signature-test.json is unreadable or malformed. -func loadSignatureVectors(t *testing.T) []sigTestVector { +func loadSignatureVectors(t testing.TB) []sigTestVector { t.Helper() vectors, err := loadSignatureVectorsOnce() diff --git a/bolt12/invoice.go b/bolt12/invoice.go index 6f8d316d471..002da217e36 100644 --- a/bolt12/invoice.go +++ b/bolt12/invoice.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "maps" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/lightningnetwork/lnd/lnwire" @@ -389,6 +390,69 @@ func DecodeInvoice(data []byte) (*Invoice, error) { return &inv, nil } +// DecodeInvoiceString decodes a BOLT 12 invoice from its bech32 string +// representation (lni1...). The spec reader gates (chain, features, signature) +// are folded in via ValidateInvoiceRead, and the expiry gate is enforced via +// ValidateInvoiceExpiry. +// +// These gates check the invoice against itself. An invoice that answers an +// invoice_request needs two further bindings that the message alone cannot +// supply, so a payer holding that request runs ValidateInvoiceForPayment on +// the result instead of treating this call as sufficient. +func DecodeInvoiceString(s string, now time.Time, + activeChain [32]byte) (*Invoice, error) { + + hrp, tlvBytes, err := Decode(s) + if err != nil { + return nil, fmt.Errorf("bech32: %w", err) + } + + if hrp != HRPInvoice { + return nil, fmt.Errorf("expected HRP %q, got %q", + HRPInvoice, hrp) + } + + inv, err := DecodeInvoice(tlvBytes) + if err != nil { + return nil, err + } + + features := InvoiceFeatureCatalogues{ + Invoice: Bolt12Features, + Blinded: Bolt12Features, + } + if err := ValidateInvoiceRead(inv, activeChain, features); err != nil { + return nil, fmt.Errorf("validate: %w", err) + } + + if err := ValidateInvoiceExpiry(inv, now); err != nil { + return nil, fmt.Errorf("validate: %w", err) + } + + return inv, nil +} + +// EncodeInvoiceString encodes a signed invoice to its bech32 string +// representation (lni1...). The string form exists only for transmission, so +// a populated signature is required and verified against invoice_node_id. +// Writer-side validation is delegated to (*Invoice).Encode. +func EncodeInvoiceString(inv *Invoice) (string, error) { + if !inv.Signature.IsSome() { + return "", ErrMissingSignature + } + + tlvBytes, err := inv.Encode() + if err != nil { + return "", err + } + + if err := VerifyInvoice(inv); err != nil { + return "", err + } + + return Encode(HRPInvoice, tlvBytes) +} + // NewInvoiceFromRequest constructs a new Invoice by copying (mirroring) all // non-signature fields from the provided InvoiceRequest. When invreq_amount is // present it is mirrored into invoice_amount per the writer requirement. The diff --git a/bolt12/invoice_request.go b/bolt12/invoice_request.go index d7af1c11c16..65a3f035ddd 100644 --- a/bolt12/invoice_request.go +++ b/bolt12/invoice_request.go @@ -237,6 +237,59 @@ func DecodeInvoiceRequest(data []byte) (*InvoiceRequest, error) { return &ir, nil } +// DecodeInvoiceRequestString decodes a BOLT 12 invoice request from its bech32 +// string representation (lnr1...). The spec reader gates (chain, features, +// signature) are folded in via ValidateInvoiceRequestRead, with activeChain +// gating the invreq_chain rule. +func DecodeInvoiceRequestString(s string, + activeChain [32]byte) (*InvoiceRequest, error) { + + hrp, tlvBytes, err := Decode(s) + if err != nil { + return nil, fmt.Errorf("bech32: %w", err) + } + + if hrp != HRPInvoiceRequest { + return nil, fmt.Errorf("expected HRP %q, got %q", + HRPInvoiceRequest, hrp) + } + + ir, err := DecodeInvoiceRequest(tlvBytes) + if err != nil { + return nil, err + } + + if err := ValidateInvoiceRequestRead( + ir, activeChain, Bolt12Features, + ); err != nil { + return nil, fmt.Errorf("validate: %w", err) + } + + return ir, nil +} + +// EncodeInvoiceRequestString encodes a signed invoice request to its bech32 +// string representation (lnr1...). The string form exists only for +// transmission, so a populated signature is required and verified against +// invreq_payer_id. Writer-side validation is delegated to +// (*InvoiceRequest).Encode. +func EncodeInvoiceRequestString(ir *InvoiceRequest) (string, error) { + if !ir.Signature.IsSome() { + return "", ErrMissingSignature + } + + tlvBytes, err := ir.Encode() + if err != nil { + return "", err + } + + if err := VerifyInvoiceRequest(ir); err != nil { + return "", err + } + + return Encode(HRPInvoiceRequest, tlvBytes) +} + // NewInvoiceRequestFromOffer constructs a new InvoiceRequest by copying // (mirroring) all fields from the provided Offer. It assigns the payer ID and // payer metadata; the caller should subsequently sign the request. diff --git a/bolt12/invoice_request_test.go b/bolt12/invoice_request_test.go index 2e642e07153..af489758b25 100644 --- a/bolt12/invoice_request_test.go +++ b/bolt12/invoice_request_test.go @@ -2,7 +2,6 @@ package bolt12 import ( "bytes" - "encoding/hex" "testing" "github.com/btcsuite/btcd/btcec/v2" @@ -171,10 +170,10 @@ func TestNewInvoiceRequestFromOfferMirrorsUnknownFields(t *testing.T) { require.True(t, found, "unknown offer TLV not mirrored into request") } -// TestDecodeInvoiceRequestBech32String decodes the invoice_request string and -// verifies key fields. This exercises the low-level Decode plus -// DecodeInvoiceRequest path. -func TestDecodeInvoiceRequestBech32String(t *testing.T) { +// TestDecodeInvoiceRequestString decodes the signature-test.json +// invoice_request string through the bech32 wrapper, reader gates included, +// and verifies key fields. +func TestDecodeInvoiceRequestString(t *testing.T) { t.Parallel() // From upstream lightning/bolts signature-test.json: the @@ -187,10 +186,7 @@ func TestDecodeInvoiceRequestBech32String(t *testing.T) { "k95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu0" + "4qz3slje2rfthc89vss" - _, tlvBytes, err := Decode(lnrStr) - require.NoError(t, err) - - ir, err := DecodeInvoiceRequest(tlvBytes) + ir, err := DecodeInvoiceRequestString(lnrStr, bitcoinMainnetGenesisHash) require.NoError(t, err) // Verify invreq_metadata is set (8 zero bytes). @@ -228,45 +224,76 @@ func TestDecodeInvoiceRequestBech32String(t *testing.T) { }, ) require.Equal(t, "A Mathematical Treatise", string(desc)) +} - // Verify invreq_payer_id is Bob's compressed pubkey (0x424242... - // privkey). - var payerIDSet bool - ir.InvreqPayerID.WhenSome( - func(r tlv.RecordT[tlv.TlvType88, *btcec.PublicKey]) { - payerIDSet = true - }, - ) - require.True(t, payerIDSet) +// TestInvoiceRequestStringRoundTrip pins the encode→decode identity of the +// lnr wrapper pair: the recovered request must re-encode to the original TLV +// stream byte-for-byte. +func TestInvoiceRequestStringRoundTrip(t *testing.T) { + t.Parallel() - // Verify signature is present. - var ( - sig [64]byte - sigSet bool - ) - ir.Signature.WhenSome( - func(r tlv.RecordT[tlv.TlvType240, [64]byte]) { - sig = r.Val - sigSet = true - }, + ir := validInvoiceRequest(t) + + encoded, err := EncodeInvoiceRequestString(ir) + require.NoError(t, err) + require.NotEmpty(t, encoded) + + decoded, err := DecodeInvoiceRequestString( + encoded, bitcoinMainnetGenesisHash, ) - require.True(t, sigSet) - - expectedSig := "b8f83ea3288cfd6ea510cdb481472575141e8d87" + - "44157f98562d162cc1c472526fdb24befefbdebab4dbb" + - "726bbd1b7d8aec057f8fa805187e5950d2bbe0e5642" - require.Equal(t, expectedSig, hex.EncodeToString(sig[:])) - - // Verify decode populated the canonical record set used by the Merkle - // tree, so every wire TLV must be reachable through AllRecords for - // signature verification to find them. - require.NotEmpty(t, ir.AllRecords()) - - // Re-encode must be byte-identical to the decoded wire bytes: the - // signature is over the Merkle root of this canonical encoding, so any - // reordering, dropped TLV, or non-canonical integer would invalidate - // it. - reencoded, err := ir.Encode() require.NoError(t, err) - require.Equal(t, tlvBytes, reencoded) + + originalBytes, err := ir.Encode() + require.NoError(t, err) + decodedBytes, err := decoded.Encode() + require.NoError(t, err) + require.Equal(t, originalBytes, decodedBytes) +} + +// TestEncodeInvoiceRequestStringInvalid asserts the wrapper refuses to emit +// a request that fails writer validation. +func TestEncodeInvoiceRequestStringInvalid(t *testing.T) { + t.Parallel() + + ir := validInvoiceRequest(t) + ir.InvreqPayerID = tlv.OptionalRecordT[ + tlv.TlvType88, *btcec.PublicKey, + ]{} + + encoded, err := EncodeInvoiceRequestString(ir) + require.ErrorIs(t, err, ErrMissingPayerID) + require.Empty(t, encoded) +} + +// TestEncodeInvoiceRequestStringUnsigned asserts the wire-string layer +// refuses to emit an unsigned invoice request: the signature becomes +// mandatory at the bech32 boundary even though pre-sign Encode is permitted. +func TestEncodeInvoiceRequestStringUnsigned(t *testing.T) { + t.Parallel() + + ir := validInvoiceRequest(t) + ir.Signature = tlv.OptionalRecordT[tlv.TlvType240, [64]byte]{} + + encoded, err := EncodeInvoiceRequestString(ir) + require.ErrorIs(t, err, ErrMissingSignature) + require.Empty(t, encoded) +} + +// TestEncodeInvoiceRequestStringInvalidSignature asserts the wire-string +// layer refuses to emit a request whose signature does not verify against +// invreq_payer_id. +func TestEncodeInvoiceRequestStringInvalidSignature(t *testing.T) { + t.Parallel() + + ir := validInvoiceRequest(t) + + // The post-sign mutation leaves the signature stale: it covers a + // Merkle root this request no longer produces. + ir.InvreqAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType82, TUint64](TUint64(2000)), + ) + + encoded, err := EncodeInvoiceRequestString(ir) + require.ErrorIs(t, err, ErrInvalidSignature) + require.Empty(t, encoded) } diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go index 56f2c74bdc6..12a4930d725 100644 --- a/bolt12/invoice_test.go +++ b/bolt12/invoice_test.go @@ -3,6 +3,7 @@ package bolt12 import ( "bytes" "testing" + "time" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/tlv" @@ -11,7 +12,7 @@ import ( // validInvoice returns an Invoice populated with the minimum set of fields // required to satisfy ValidateInvoiceWrite. -func validInvoice(t *testing.T) *Invoice { +func validInvoice(t testing.TB) *Invoice { t.Helper() _, pub := bobKey() @@ -363,3 +364,165 @@ func TestInvoiceEncodeValidationGate(t *testing.T) { _, err := inv.Encode() require.ErrorIs(t, err, ErrMissingCreatedAt) } + +// TestInvoiceStringRoundTrip pins the encode→decode identity of the lni +// wrapper pair: the recovered invoice must re-encode to the original TLV +// stream byte-for-byte. The invoice is signed first because the decode +// wrapper runs the reader gates, which reject unsigned invoices. +func TestInvoiceStringRoundTrip(t *testing.T) { + t.Parallel() + + priv, _ := bobKey() + inv := validInvoice(t) + + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), + ) + + encoded, err := EncodeInvoiceString(inv) + require.NoError(t, err) + require.NotEmpty(t, encoded) + + // validInvoice sets invoice_created_at to 1234567890, so a clock one + // second later sits inside the default 7200s expiry window. + decoded, err := DecodeInvoiceString( + encoded, time.Unix(1234567890+1, 0), bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + + originalBytes, err := inv.Encode() + require.NoError(t, err) + decodedBytes, err := decoded.Encode() + require.NoError(t, err) + require.Equal(t, originalBytes, decodedBytes) +} + +// TestEncodeInvoiceStringInvalid asserts the wrapper refuses to emit an +// invoice that fails writer validation. +func TestEncodeInvoiceStringInvalid(t *testing.T) { + t.Parallel() + + // A dummy signature passes the presence gate. Writer validation runs + // before signature verification, so the writer-validation branch is + // exercised. + inv := validInvoice(t) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte]([64]byte{}), + ) + inv.InvoicePaymentHash = tlv.OptionalRecordT[ + tlv.TlvType168, [32]byte, + ]{} + + encoded, err := EncodeInvoiceString(inv) + require.ErrorIs(t, err, ErrMissingPaymentHash) + require.Empty(t, encoded) +} + +// TestEncodeInvoiceStringUnsigned asserts the wire-string layer refuses to +// emit an unsigned invoice: the signature becomes mandatory at the bech32 +// boundary even though pre-sign Encode is permitted. +func TestEncodeInvoiceStringUnsigned(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + require.False(t, inv.Signature.IsSome()) + + encoded, err := EncodeInvoiceString(inv) + require.ErrorIs(t, err, ErrMissingSignature) + require.Empty(t, encoded) +} + +// TestEncodeInvoiceStringInvalidSignature asserts the wire-string layer +// refuses to emit an invoice whose signature does not verify against +// invoice_node_id. +func TestEncodeInvoiceStringInvalidSignature(t *testing.T) { + t.Parallel() + + priv, _ := bobKey() + inv := validInvoice(t) + + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), + ) + + // The post-sign mutation leaves the signature stale: it covers a + // Merkle root this invoice no longer produces. + inv.InvoiceAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170, TUint64](TUint64(200_000)), + ) + + encoded, err := EncodeInvoiceString(inv) + require.ErrorIs(t, err, ErrInvalidSignature) + require.Empty(t, encoded) +} + +// TestDecodeInvoiceStringExpiry asserts the wrapper folds the expiry gate +// in: an invoice past invoice_created_at + relative expiry is rejected even +// though the structural reader checks pass. +func TestDecodeInvoiceStringExpiry(t *testing.T) { + t.Parallel() + + priv, _ := bobKey() + inv := validInvoice(t) + + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), + ) + + encoded, err := EncodeInvoiceString(inv) + require.NoError(t, err) + + // validInvoice's invoice_created_at is 1234567890 with the default + // 7200s expiry, so 8000s later is past the window. + expired := time.Unix(1234567890+8000, 0) + decoded, err := DecodeInvoiceString( + encoded, expired, bitcoinMainnetGenesisHash, + ) + require.ErrorIs(t, err, ErrInvoiceExpired) + require.Nil(t, decoded) +} + +// TestDecodeInvoiceStringInvalid asserts the lni wrapper rejects a string +// that fails HRP discrimination or bech32 decoding. +func TestDecodeInvoiceStringInvalid(t *testing.T) { + t.Parallel() + + offerStr := findTestVector(t, "Minimal bolt12 offer").Bolt12 + + tests := []struct { + name string + invoice string + errContains string + }{ + { + name: "wrong HRP", + invoice: offerStr, + errContains: "expected HRP", + }, + { + name: "malformed bech32", + invoice: "not a bech32 string", + errContains: "bech32", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + inv, err := DecodeInvoiceString( + tc.invoice, farFutureNow(), + bitcoinMainnetGenesisHash, + ) + require.Error(t, err) + require.Nil(t, inv) + require.Contains(t, err.Error(), tc.errContains) + }) + } +} diff --git a/bolt12/offer.go b/bolt12/offer.go index 3b8eadc2ca4..7405322494a 100644 --- a/bolt12/offer.go +++ b/bolt12/offer.go @@ -3,6 +3,7 @@ package bolt12 import ( "bytes" "fmt" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/lightningnetwork/lnd/lnwire" @@ -165,3 +166,44 @@ func decodeOffer(data []byte) (*Offer, error) { return &o, nil } + +// DecodeOfferString decodes a BOLT 12 offer from its bech32 string +// representation (lno1...). The spec reader gates (chain, expiry, features) are +// folded in via ValidateOfferRead. +func DecodeOfferString(s string, now time.Time, + activeChain [32]byte) (*Offer, error) { + + hrp, tlvBytes, err := Decode(s) + if err != nil { + return nil, fmt.Errorf("bech32: %w", err) + } + + if hrp != HRPOffer { + return nil, fmt.Errorf("expected HRP %q, got %q", + HRPOffer, hrp) + } + + offer, err := decodeOffer(tlvBytes) + if err != nil { + return nil, err + } + + if err := ValidateOfferRead( + offer, now, activeChain, Bolt12Features, + ); err != nil { + return nil, fmt.Errorf("validate: %w", err) + } + + return offer, nil +} + +// EncodeOfferString encodes an offer to its bech32 string representation +// (lno1...). Writer-side validation is delegated to (*Offer).Encode. +func EncodeOfferString(o *Offer) (string, error) { + tlvBytes, err := o.Encode() + if err != nil { + return "", err + } + + return Encode(HRPOffer, tlvBytes) +} diff --git a/bolt12/offer_test.go b/bolt12/offer_test.go index 4bf65ec02f9..ce786f02579 100644 --- a/bolt12/offer_test.go +++ b/bolt12/offer_test.go @@ -75,9 +75,11 @@ func TestDecodeOversizedRecord(t *testing.T) { ) } -// TestDecodeOfferString decodes a minimal offer string and verifies the -// issuer ID field is correctly parsed. -func TestDecodeOfferString(t *testing.T) { +// TestDecodeMinimalOfferString decodes a minimal offer string and verifies +// the issuer ID field is correctly parsed. This exercises the low-level +// Decode plus decodeOffer path. TestDecodeOfferString covers the +// DecodeOfferString wrapper. +func TestDecodeMinimalOfferString(t *testing.T) { t.Parallel() // Minimal offer: just offer_issuer_id (type 22). @@ -113,3 +115,112 @@ func TestDecodeOfferString(t *testing.T) { require.NoError(t, err) require.Equal(t, tlvBytes, reencoded) } + +// TestDecodeOfferString decodes a spec test vector through the bech32 +// wrapper, reader gates included. +func TestDecodeOfferString(t *testing.T) { + t.Parallel() + + vec := findTestVector(t, "with description (but no amount)") + + offer, err := DecodeOfferString( + vec.Bolt12, farFutureNow(), bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + + var desc []byte + offer.OfferDescription.WhenSome( + func(r tlv.RecordT[tlv.TlvType10, tlv.Blob]) { + desc = r.Val + }, + ) + require.Equal(t, "Test vectors", string(desc)) +} + +// TestDecodeOfferStringInvalid asserts the wrapper rejects a string that +// fails at each layer: HRP discrimination and the reader MUST gates. +func TestDecodeOfferStringInvalid(t *testing.T) { + t.Parallel() + + // An lnr string from signature-test.json exercises HRP + // discrimination. + lnrStr := "lnr1qqyqqqqqqqqqqqqqqcp4256ypqqkgzshgysy6ct5d" + + "pjk6ct5d93kzmpq23ex2ct5d9ek293pqthvwfzadd7jej" + + "es8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpjkppqvj" + + "x204vgdzgsqpvcp4mldl3plscny0rt707gvpdh6ndydfac" + + "z43euzqhrurageg3n7kafgsek6gz3e9w52parv8gs2hlxz" + + "k95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu0" + + "4qz3slje2rfthc89vss" + + missingIssuer := findTestVector( + t, "Missing offer_issuer_id and no offer_path", + ) + + tests := []struct { + name string + offer string + errContains string + }{ + { + name: "wrong HRP", + offer: lnrStr, + errContains: "expected HRP", + }, + { + name: "reader gate failure", + offer: missingIssuer.Bolt12, + errContains: ErrNoIssuerIdentity.Error(), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := DecodeOfferString( + tc.offer, farFutureNow(), + bitcoinMainnetGenesisHash, + ) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errContains) + }) + } +} + +// TestOfferStringRoundTrip pins the encode→decode identity of the bech32 +// wrapper pair on a spec vector. +func TestOfferStringRoundTrip(t *testing.T) { + t.Parallel() + + vec := findTestVector(t, "Minimal bolt12 offer") + + offer, err := DecodeOfferString( + vec.Bolt12, farFutureNow(), bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + + encoded, err := EncodeOfferString(offer) + require.NoError(t, err) + require.NotEmpty(t, encoded) + + offer2, err := DecodeOfferString( + encoded, farFutureNow(), bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + + var id1, id2 *btcec.PublicKey + offer.OfferIssuerID.WhenSome( + func(r tlv.RecordT[tlv.TlvType22, *btcec.PublicKey]) { + id1 = r.Val + }, + ) + offer2.OfferIssuerID.WhenSome( + func(r tlv.RecordT[tlv.TlvType22, *btcec.PublicKey]) { + id2 = r.Val + }, + ) + require.Equal( + t, hex.EncodeToString(id1.SerializeCompressed()), + hex.EncodeToString(id2.SerializeCompressed()), + ) +} diff --git a/bolt12/validate.go b/bolt12/validate.go index f701937075e..e700b90e976 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -17,6 +17,14 @@ import ( "golang.org/x/text/currency" ) +// Bolt12Features defines the set of BOLT 12 feature bits understood and +// supported by our implementation: multi-path payments (MPP), both the +// compulsory and optional variants. +var Bolt12Features = map[lnwire.FeatureBit]string{ + lnwire.MPPRequired: "mpp", + lnwire.MPPOptional: "mpp", +} + var ( // ErrOutOfRangeType is returned when a TLV type falls outside the // allowed offer ranges (1-79 and 1000000000-1999999999). @@ -193,6 +201,13 @@ var ( "invoice_node_id does not match offer_issuer_id", ) + // ErrUnexpectedInvoiceNodeID is returned by validateInvoiceNodeID when + // the invoice was signed by a node other than the one the payer + // expected to answer. + ErrUnexpectedInvoiceNodeID = errors.New( + "invoice_node_id does not match the node the payer expected", + ) + // ErrZeroInvoiceAmount is returned when invoice_amount is present but // set to zero. The spec permits a zero "minimum amount", but a // zero-amount HTLC cannot settle past the channel-layer dust limit, so @@ -1497,6 +1512,39 @@ func ValidateInvoiceExpiry(inv *Invoice, now time.Time) error { return nil } +// validateInvoiceNodeID rejects an invoice that was not signed by the node the +// payer expected to answer. Which node that is comes from the payer's own +// state: offer_issuer_id, the final blinded_node_id of the path it chose, or +// the node it addressed an offerless request to. None of that is derivable +// from the invoice, so ValidateInvoiceRead cannot make the comparison and +// callers run this separately, as they already do for ValidateInvoiceExpiry. +// +// Skipping it is not cosmetic. Every node on the blinded path can answer with +// its own correctly signed invoice, and the reader accepts it, because the +// signature only has to agree with whatever invoice_node_id the invoice itself +// carries. +func validateInvoiceNodeID(inv *Invoice, + expectedNodeID *btcec.PublicKey) error { + + if expectedNodeID == nil { + return fmt.Errorf("%w: expected invoice_node_id", + ErrNilPublicKey) + } + + // A present-but-nil invoice_node_id is rejected as ErrNilPublicKey by + // the readers, so a nil here means the field is absent. + nodeID := inv.InvoiceNodeID.ValOpt().UnwrapOr(nil) + if nodeID == nil { + return ErrMissingNodeID + } + + if !nodeID.IsEqual(expectedNodeID) { + return ErrUnexpectedInvoiceNodeID + } + + return nil +} + // mirroredRecordBytes encodes the records in the invreq mirror range to their // canonical per-record bytes, keyed by TLV type. This is the view the // byte-for-byte invreq->invoice comparison operates on. @@ -1832,3 +1880,31 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // invoice_node_id as described in Signature Calculation. return VerifyInvoice(inv) } + +// ValidateInvoiceForPayment runs the full set of payer-side invoice checks in +// one call against an invoice and its originating request. +// +// expectedNodeID is the node the payer expects to have signed the invoice, +// and is always compared against invoice_node_id. It is offer_issuer_id for +// an offer that carried one, the final blinded_node_id on the path the payer +// chose for an offer that carried offer_paths, and the node it sent to for an +// offerless request. +func ValidateInvoiceForPayment(inv *Invoice, req *InvoiceRequest, + now time.Time, activeChain [32]byte, + features InvoiceFeatureCatalogues, + expectedNodeID *btcec.PublicKey) error { + + if err := ValidateInvoiceRead(inv, activeChain, features); err != nil { + return err + } + + if err := ValidateInvoiceExpiry(inv, now); err != nil { + return err + } + + if err := ValidateInvoiceAgainstRequest(inv, req); err != nil { + return err + } + + return validateInvoiceNodeID(inv, expectedNodeID) +} diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index ee576185f1d..7de328400df 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" @@ -708,7 +709,7 @@ func addAmountAndDescription(o *Offer) { // 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 { +func validInvoiceRequest(t testing.TB) *InvoiceRequest { t.Helper() priv, _ := bobKey() @@ -918,6 +919,287 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { } } +// TestValidateInvoiceNodeID pins the binding the readers cannot check for +// themselves: invoice_node_id must name the node the payer expected to +// answer, which is state only the payer holds. +func TestValidateInvoiceNodeID(t *testing.T) { + t.Parallel() + + _, alicePub := aliceKey() + _, bobPub := bobKey() + + tests := []struct { + name string + nodeID fn.Option[*btcec.PublicKey] + expected *btcec.PublicKey + wantErr error + }{ + { + name: "matches the path's final node", + nodeID: fn.Some(bobPub), + expected: bobPub, + }, + { + name: "another node on the path impersonates", + nodeID: fn.Some(alicePub), + expected: bobPub, + wantErr: ErrUnexpectedInvoiceNodeID, + }, + { + name: "invoice_node_id absent", + nodeID: fn.None[*btcec.PublicKey](), + expected: bobPub, + wantErr: ErrMissingNodeID, + }, + { + name: "caller supplies no final node", + nodeID: fn.Some(bobPub), + expected: nil, + wantErr: ErrNilPublicKey, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + inv.InvoiceNodeID = tlv.OptionalRecordT[ + tlv.TlvType176, *btcec.PublicKey, + ]{} + tc.nodeID.WhenSome(func(pk *btcec.PublicKey) { + inv.InvoiceNodeID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176]( + pk, + ), + ) + }) + + err := validateInvoiceNodeID(inv, tc.expected) + if tc.wantErr == nil { + require.NoError(t, err) + return + } + require.ErrorIs(t, err, tc.wantErr) + }) + } +} + +// TestValidateInvoiceForPayment pins the combined payer-side validator: it +// runs the structural read, the expiry gate, the mirror-match against the +// request, and the binding to the expected signer in one call, so a caller +// cannot forget any of them. The binding is compared in every case, so the +// test asserts it in blinded and cleartext mode alike. +func TestValidateInvoiceForPayment(t *testing.T) { + t.Parallel() + + _, bobPub := bobKey() + alicePriv, alicePub := aliceKey() + + // validInvoice sets invoice_created_at to 1234567890, so a clock one + // second later sits inside the default 7200s expiry window. + validNow := time.Unix(1234567890+1, 0) + + // A blinded-path request: no offer_issuer_id, so the caller must supply + // the final blinded node. + blindedIR := &InvoiceRequest{ + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + tlv.Blob("metadata"), + ), + ), + } + blindedIREncoded, err := encodeIRBypassValidate(blindedIR) + require.NoError(t, err) + blindedIRDecoded, err := DecodeInvoiceRequest(blindedIREncoded) + require.NoError(t, err) + + // The matching invoice: mirrors the request's signed-range fields and + // carries the invoice-specific fields the reader requires, signed by + // Bob (invoice_node_id). + inv := validInvoice(t) + inv.InvreqMetadata = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + tlv.Blob("metadata"), + ), + ) + invEncoded, err := encodeInvBypassValidate(inv) + require.NoError(t, err) + invDecoded, err := DecodeInvoice(invEncoded) + require.NoError(t, err) + + bobPriv, _ := bobKey() + sig, err := SignInvoice(invDecoded, bobPriv) + require.NoError(t, err) + invDecoded.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), + ) + + // Blinded mode, happy path: the final node matches invoice_node_id. + require.NoError(t, ValidateInvoiceForPayment( + invDecoded, blindedIRDecoded, validNow, + bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + )) + + // The expiry gate is part of the composite, so a caller that only calls + // this cannot pay an expired invoice. validInvoice's default 7200s + // window is long past 8000s after creation. + err = ValidateInvoiceForPayment( + invDecoded, blindedIRDecoded, time.Unix(1234567890+8000, 0), + bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + ) + require.ErrorIs(t, err, ErrInvoiceExpired) + + // A payee that charges an amount other than the one the payer offered + // fails inside ValidateInvoiceAgainstRequest, before the signer + // binding is reached. The invoice mirrors invreq_amount faithfully, so + // the only disagreement is invoice_amount, which validInvoice sets to + // 100000 against the 1000 the payer offered. + amountIR := &InvoiceRequest{ + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + tlv.Blob("metadata"), + ), + ), + InvreqAmount: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType82, TUint64](1000), + ), + } + amountIREncoded, err := encodeIRBypassValidate(amountIR) + require.NoError(t, err) + amountIRDecoded, err := DecodeInvoiceRequest(amountIREncoded) + require.NoError(t, err) + + overcharged := validInvoice(t) + overcharged.InvreqMetadata = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0](tlv.Blob("metadata")), + ) + overcharged.InvreqAmount = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType82, TUint64](1000), + ) + overchargedEncoded, err := encodeInvBypassValidate(overcharged) + require.NoError(t, err) + overchargedDecoded, err := DecodeInvoice(overchargedEncoded) + require.NoError(t, err) + + overSig, err := SignInvoice(overchargedDecoded, bobPriv) + require.NoError(t, err) + overchargedDecoded.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240](overSig), + ) + + err = ValidateInvoiceForPayment( + overchargedDecoded, amountIRDecoded, validNow, + bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + ) + require.ErrorIs(t, err, ErrInvoiceMismatch) + require.ErrorContains( + t, err, "invoice_amount 100000 != invreq_amount 1000", + ) + + // Blinded mode, a legitimate invoice against the wrong expectation: + // Bob answered, but the payer believes it addressed Alice. + err = ValidateInvoiceForPayment( + invDecoded, blindedIRDecoded, validNow, + bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, + alicePub, + ) + require.ErrorIs(t, err, ErrUnexpectedInvoiceNodeID) + + // Blinded mode, the substitution this composite exists to catch. An + // intermediate node on the path answers with an invoice of its own, + // names itself as invoice_node_id, and signs it correctly, so the + // invoice is internally consistent and mirrors the request. Only the + // tie to the node the payer actually addressed rejects it. + forged := validInvoice(t) + forged.InvreqMetadata = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + tlv.Blob("metadata"), + ), + ) + forged.InvoiceNodeID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176](alicePub), + ) + forgedEncoded, err := encodeInvBypassValidate(forged) + require.NoError(t, err) + forgedDecoded, err := DecodeInvoice(forgedEncoded) + require.NoError(t, err) + + forgedSig, err := SignInvoice(forgedDecoded, alicePriv) + require.NoError(t, err) + forgedDecoded.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](forgedSig), + ) + + // The reader accepts it, which is the half that makes the composite + // necessary rather than redundant. Asserting both halves on the same + // invoice is what shows the second step cannot be skipped. + require.NoError(t, ValidateInvoiceRead( + forgedDecoded, bitcoinMainnetGenesisHash, + InvoiceFeatureCatalogues{}, + )) + + err = ValidateInvoiceForPayment( + forgedDecoded, blindedIRDecoded, validNow, + bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + ) + require.ErrorIs(t, err, ErrUnexpectedInvoiceNodeID) + + // Cleartext mode: offer_issuer_id is present, so the readers bind + // invoice_node_id to it. The payer still states the same key as its + // expectation, which the composite compares like any other case. + cleartextIR := &InvoiceRequest{ + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + tlv.Blob("metadata"), + ), + ), + OfferIssuerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22](bobPub), + ), + } + cleartextIREncoded, err := encodeIRBypassValidate(cleartextIR) + require.NoError(t, err) + cleartextIRDecoded, err := DecodeInvoiceRequest(cleartextIREncoded) + require.NoError(t, err) + + // The invoice mirrors offer_issuer_id (Bob) and is signed by Bob, so + // checkInvoiceNodeID inside the readers enforces the binding. + inv.InvreqMetadata = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + tlv.Blob("metadata"), + ), + ) + inv.OfferIssuerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22](bobPub), + ) + invEncoded, err = encodeInvBypassValidate(inv) + require.NoError(t, err) + invDecoded, err = DecodeInvoice(invEncoded) + require.NoError(t, err) + + sig, err = SignInvoice(invDecoded, bobPriv) + require.NoError(t, err) + invDecoded.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), + ) + + // The expectation is offer_issuer_id here, which the payer holds from + // the offer it built the request from. + require.NoError(t, ValidateInvoiceForPayment( + invDecoded, cleartextIRDecoded, validNow, + bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + )) + + // The check is unconditional, so a wrong expectation is caught even + // though offer_issuer_id is present and the readers already bound it. + err = ValidateInvoiceForPayment( + invDecoded, cleartextIRDecoded, validNow, + bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, alicePub, + ) + require.ErrorIs(t, err, ErrUnexpectedInvoiceNodeID) +} + // TestValidateInvoiceRequestWrite pins the BOLT 12 writer-side MUSTs so a // malformed or incomplete invoice request is rejected. func TestValidateInvoiceRequestWrite(t *testing.T) { diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index de0c108ec11..63847d2c1a3 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -143,6 +143,12 @@ invoice requests and invoices, and verify the signature on read so a decoded message with an invalid signature is rejected. +* [BOLT 12 string codecs and payment + validation](https://github.com/lightningnetwork/lnd/pull/11146): add + validated `Decode`/`Encode` string entry points for offers, invoice + requests, and invoices, and `ValidateInvoiceForPayment` to bundle the + payer-side invoice checks into one call. + ## Testing * [BOLT 12 spec test vectors](https://github.com/lightningnetwork/lnd/pull/11001): @@ -154,6 +160,11 @@ vectors pinning Merkle tree construction and BIP-340 signature verification in `bolt12/test-vectors/`. +* [BOLT 12 fuzz + harnesses](https://github.com/lightningnetwork/lnd/pull/11146): fuzz the + `bolt12/` decoders for panics and encode/decode bijection, and pin Merkle + root determinism. + ## Database ## Code Health diff --git a/make/fuzz_flags.mk b/make/fuzz_flags.mk index 7e877315d9d..9bf450e6ebe 100644 --- a/make/fuzz_flags.mk +++ b/make/fuzz_flags.mk @@ -1,4 +1,4 @@ -FUZZPKG = brontide lnwire watchtower/wtwire zpay32 +FUZZPKG = bolt12 brontide lnwire watchtower/wtwire zpay32 FUZZ_TEST_RUN_TIME = 30s FUZZ_NUM_PROCESSES = 4