diff --git a/bolt12/bech32.go b/bolt12/bech32.go index bd12b73a70d..b8680225469 100644 --- a/bolt12/bech32.go +++ b/bolt12/bech32.go @@ -266,8 +266,8 @@ func stripContinuation(s string) (string, error) { } if j >= len(s) || !isContinuationNeighbour(s[j]) { return "", fmt.Errorf( - "bolt12: %w: '+' must precede a "+ - "non-whitespace character", + "bolt12: %w: '+' must join two "+ + "characters", ErrInvalidContinuation, ) } diff --git a/bolt12/doc.go b/bolt12/doc.go index 809c31ad2bc..61cc552d656 100644 --- a/bolt12/doc.go +++ b/bolt12/doc.go @@ -1,6 +1,7 @@ // Package bolt12 implements encoding, decoding, and validation for BOLT 12 -// Offers, Invoice Requests, and Invoices. It provides a pure codec library -// with no LND daemon dependencies. +// Offers, Invoice Requests, and Invoices. It is a codec library: it does not +// reach into the daemon, and it takes the chain, the clock and the known +// feature bits from its caller. // // BOLT 12 messages use TLV streams encoded with a checksumless bech32 variant // and signed with BIP-340 Schnorr signatures over a Merkle tree of TLV fields. diff --git a/bolt12/invoice.go b/bolt12/invoice.go index 002da217e36..a27db95d65d 100644 --- a/bolt12/invoice.go +++ b/bolt12/invoice.go @@ -417,7 +417,7 @@ func DecodeInvoiceString(s string, now time.Time, return nil, err } - features := InvoiceFeatureCatalogues{ + features := InvoiceKnownFeatures{ Invoice: Bolt12Features, Blinded: Bolt12Features, } diff --git a/bolt12/invoice_error.go b/bolt12/invoice_error.go index 051b7d85e8e..80bcd727744 100644 --- a/bolt12/invoice_error.go +++ b/bolt12/invoice_error.go @@ -46,6 +46,11 @@ func (ie *InvoiceError) allRecordProducers() []tlv.RecordProducer { // signature to invalidate by dropping unrecognized TLVs (unlike signed // messages such as invoices, where unknown TLVs must be preserved to keep // signatures valid). +// +// One writer rule stays unchecked: a suggested_value is not verified against +// the type of the field erroneous_field names. Emitting a value the peer +// cannot decode is therefore possible, see the TODO in +// ValidateInvoiceErrorWrite. func (ie *InvoiceError) Encode() ([]byte, error) { if err := ValidateInvoiceErrorWrite(ie); err != nil { return nil, fmt.Errorf("validate invoice error: %w", err) diff --git a/bolt12/invoice_error_test.go b/bolt12/invoice_error_test.go index e050126906d..f18f05d347d 100644 --- a/bolt12/invoice_error_test.go +++ b/bolt12/invoice_error_test.go @@ -49,15 +49,19 @@ func TestInvoiceErrorRoundTrip(t *testing.T) { name: "all fields", ie: &InvoiceError{ ErroneousField: someErrField(82), + // Field 82 is invreq_amount, a tu64, so the + // suggested value has to be minimal: a + // leading zero byte would make it undecodable + // for the peer. SuggestedValue: someSuggested( - []byte{0x00, 0x01, 0x86, 0xa0}, + []byte{0x01, 0x86, 0xa0}, ), Error: someError("amount too low"), }, wantMsg: "amount too low", wantHasField: true, wantFieldNum: 82, - wantSuggest: []byte{0x00, 0x01, 0x86, 0xa0}, + wantSuggest: []byte{0x01, 0x86, 0xa0}, }, { name: "minimal error only", diff --git a/bolt12/invoice_request_test.go b/bolt12/invoice_request_test.go index af489758b25..83f2cc7a090 100644 --- a/bolt12/invoice_request_test.go +++ b/bolt12/invoice_request_test.go @@ -5,41 +5,136 @@ import ( "testing" "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) -// TestInvoiceRequestRoundTrip pins encode→decode→re-encode for an -// InvoiceRequest with a representative subset of optional fields. +// TestInvoiceRequestRoundTrip pins encode, decode and re-encode for an +// InvoiceRequest with every field set, plus an unknown odd TLV. Comparing the +// decoded struct against the fixture catches a field that is wired into the +// encode path but not into the decode path, which byte identity alone cannot +// see. func TestInvoiceRequestRoundTrip(t *testing.T) { t.Parallel() - _, bobPub := bobKey() + priv, bobPub := bobKey() + _, intro := aliceKey() + _, blinding := bobKey() + _, hopPub := aliceKey() - metadata := tlv.Blob("payer-metadata") + introNode, err := lnwire.NewPubkeyIntro(intro) + require.NoError(t, err) + + paths := lnwire.BlindedPaths{ + Paths: []lnwire.BlindedPath{ + { + IntroductionNode: introNode, + BlindingPoint: blinding, + Hops: []lnwire.BlindedHop{ + { + BlindedNodeID: hopPub, + EncryptedData: []byte{1, 2}, + }, + }, + }, + }, + } + + // name_len, name, domain_len, domain. + bip353 := append( + []byte{3, 'b', 'o', 'b', 6}, []byte("ex.com")..., + ) ir := &InvoiceRequest{ + OfferChains: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType2](ChainsRecord{ + Chains: [][32]byte{bitcoinMainnetGenesisHash}, + }), + ), + OfferMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4]( + tlv.Blob("opaque"), + ), + ), + OfferCurrency: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType6](tlv.Blob("USD")), + ), + OfferAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType8](TUint64(1500)), + ), OfferDescription: tlv.SomeRecordT( tlv.NewPrimitiveRecord[tlv.TlvType10]( - tlv.Blob("description"), + tlv.Blob("coffee"), ), ), - InvreqPayerID: tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType88](bobPub), + OfferFeatures: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType12]( + *lnwire.NewRawFeatureVector(lnwire.MPPOptional), + ), + ), + OfferAbsoluteExpiry: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType14](TUint64(1 << 32)), + ), + OfferPaths: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType16](paths), + ), + OfferIssuer: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType18]( + tlv.Blob("alice"), + ), + ), + OfferQuantityMax: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType20](TUint64(5)), + ), + OfferIssuerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22](bobPub), ), InvreqMetadata: tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType0](metadata), + tlv.NewPrimitiveRecord[tlv.TlvType0]( + tlv.Blob("payer-metadata"), + ), + ), + InvreqChain: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType80]( + bitcoinMainnetGenesisHash, + ), ), InvreqAmount: tlv.SomeRecordT( - tlv.NewRecordT[tlv.TlvType82, TUint64](1000), + tlv.NewRecordT[tlv.TlvType82](TUint64(3000)), ), - Signature: tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType240]( - [64]byte{0x01}, + InvreqFeatures: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType84]( + *lnwire.NewRawFeatureVector(lnwire.MPPOptional), ), ), + InvreqQuantity: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType86](TUint64(2)), + ), + InvreqPayerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](bobPub), + ), + InvreqPayerNote: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType89](tlv.Blob("tip")), + ), + InvreqPaths: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType90](paths), + ), + InvreqBip353Name: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType91](bip353), + ), + + // An unknown odd type in the signed range must survive the + // round trip byte for byte. + decodedTLVs: tlv.TypeMap{93: []byte{0xde, 0xad}}, } + sig, err := SignInvoiceRequest(ir, priv) + require.NoError(t, err) + ir.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240](sig), + ) + encoded, err := ir.Encode() require.NoError(t, err) require.NotEmpty(t, encoded) @@ -47,14 +142,12 @@ func TestInvoiceRequestRoundTrip(t *testing.T) { decoded, err := DecodeInvoiceRequest(encoded) require.NoError(t, err) - require.Equal( - t, bobPub.SerializeCompressed(), - decoded.InvreqPayerID.UnwrapOrFailV(t).SerializeCompressed(), - ) - require.Equal(t, metadata, decoded.InvreqMetadata.UnwrapOrFailV(t)) - require.Equal( - t, TUint64(1000), decoded.InvreqAmount.UnwrapOrFailV(t), - ) + // The sidecar names every type seen on the wire, whereas the fixture + // carries only the unknown one. Adopt the decoded view so the + // comparison below covers the typed fields. + require.Equal(t, []byte{0xde, 0xad}, decoded.decodedTLVs[93]) + ir.decodedTLVs = decoded.decodedTLVs + require.Equal(t, ir, decoded) reencoded, err := decoded.Encode() require.NoError(t, err) diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go index 12a4930d725..e3369b41484 100644 --- a/bolt12/invoice_test.go +++ b/bolt12/invoice_test.go @@ -157,7 +157,7 @@ func TestUsablePaths(t *testing.T) { ), } - // Empty catalogue: the MPPRequired bit is unknown, so path 0 is + // No known bits: the MPPRequired bit is unknown, so path 0 is // filtered out and only path 1 (fee_base 2) survives. got := inv.UsablePaths(nil) require.Len(t, got, 1) @@ -177,19 +177,150 @@ func TestUsablePaths(t *testing.T) { require.Empty(t, inv.UsablePaths(known)) } -// TestInvoiceRoundTripPreservesAllTypes encodes a fully populated invoice then -// decodes it back, asserting every field is preserved byte-for-byte. The codec -// promises bijection on the message level, and any drift (dropped record, -// re-ordered output) breaks downstream signature verification because the -// Merkle root depends on the exact raw TLV stream. +// TestInvoiceRoundTripPreservesAllTypes pins encode, decode and re-encode for +// an Invoice with every field set, plus an unknown odd TLV. Byte identity +// keeps the Merkle root stable, and comparing the decoded struct against the +// fixture catches a field wired into the encode path but not into the decode +// path, which byte identity alone cannot see. func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { t.Parallel() + priv, pub := bobKey() + _, intro := aliceKey() + _, blinding := bobKey() + _, hopPub := aliceKey() + + introNode, err := lnwire.NewPubkeyIntro(intro) + require.NoError(t, err) + + paths := lnwire.BlindedPaths{ + Paths: []lnwire.BlindedPath{ + { + IntroductionNode: introNode, + BlindingPoint: blinding, + Hops: []lnwire.BlindedHop{ + { + BlindedNodeID: hopPub, + EncryptedData: []byte{1, 2}, + }, + }, + }, + }, + } + + // name_len, name, domain_len, domain. + bip353 := append( + []byte{3, 'b', 'o', 'b', 6}, []byte("ex.com")..., + ) + + features := *lnwire.NewRawFeatureVector(lnwire.MPPOptional) + + // The required fields come from the shared fixture, the rest are set + // here so the round trip covers every field. inv := validInvoice(t) - // Sign with the fixture's node id (Bob) so the read path's signature - // check accepts the invoice. - priv, _ := bobKey() + // The shared fixture leaves the hop payload and the payinfo feature + // vector at their zero values, which decode as empty rather than nil, + // so set both explicitly here. + inv.InvoicePaths = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType160](paths), + ) + payInfo := BlindedPayInfo{ + FeeBaseMsat: 1000, + FeeProportionalMillionths: 10, + CltvExpiryDelta: 80, + HtlcMinimumMsat: 1, + HtlcMaximumMsat: 100_000, + Features: *lnwire.NewRawFeatureVector(), + } + inv.InvoiceBlindedPay = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType162](BlindedPayInfos{ + Infos: []BlindedPayInfo{payInfo}, + }), + ) + inv.InvreqMetadata = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0](tlv.Blob("payer-meta")), + ) + inv.OfferChains = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType2](ChainsRecord{ + Chains: [][32]byte{bitcoinMainnetGenesisHash}, + }), + ) + inv.OfferMetadata = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4](tlv.Blob("opaque")), + ) + inv.OfferCurrency = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType6](tlv.Blob("USD")), + ) + inv.OfferAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType8](TUint64(1500)), + ) + inv.OfferDescription = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType10](tlv.Blob("coffee")), + ) + inv.OfferFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType12](features), + ) + inv.OfferAbsoluteExpiry = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType14](TUint64(1 << 32)), + ) + inv.OfferPaths = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType16](paths), + ) + inv.OfferIssuer = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType18](tlv.Blob("alice")), + ) + inv.OfferQuantityMax = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType20](TUint64(5)), + ) + inv.OfferIssuerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22](pub), + ) + inv.InvreqChain = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType80]( + bitcoinMainnetGenesisHash, + ), + ) + inv.InvreqAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType82](TUint64(100_000)), + ) + inv.InvreqFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType84](features), + ) + inv.InvreqQuantity = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType86](TUint64(2)), + ) + inv.InvreqPayerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](pub), + ) + inv.InvreqPayerNote = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType89](tlv.Blob("tip")), + ) + inv.InvreqPaths = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType90](paths), + ) + inv.InvreqBip353Name = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType91](bip353), + ) + inv.InvoiceRelativeExp = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType166](TUint32(3600)), + ) + inv.InvoiceFallbacks = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType172](FallbackAddresses{ + Addrs: []FallbackAddress{{ + Version: 1, + Address: []byte{3, 4, 5}, + }}, + }), + ) + inv.InvoiceFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType174](features), + ) + + // An unknown odd type in the signed range must survive the round trip + // byte for byte, because the Merkle root covers it. + inv.decodedTLVs = tlv.TypeMap{93: []byte{0xde, 0xad}} + sig, err := SignInvoice(inv, priv) require.NoError(t, err) inv.Signature = tlv.SomeRecordT( @@ -203,15 +334,26 @@ func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { decoded, err := DecodeInvoice(encoded) require.NoError(t, err) - err = ValidateInvoiceRead(decoded, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}) + err = ValidateInvoiceRead( + decoded, bitcoinMainnetGenesisHash, + InvoiceKnownFeatures{ + Invoice: Bolt12Features, + Blinded: Bolt12Features, + }, + ) require.NoError(t, err) + // The sidecar names every type seen on the wire, whereas the fixture + // carries only the unknown one. Adopt the decoded view so the + // comparison below covers the typed fields. + require.Equal(t, []byte{0xde, 0xad}, decoded.decodedTLVs[93]) + inv.decodedTLVs = decoded.decodedTLVs + require.Equal(t, inv, decoded) + // Re-encode the decoded copy and confirm canonicality. - // decode(encode(decode(encode(x)))) must equal decode(encode(x)). - encoded2, err := decoded.Encode() + reencoded, err := decoded.Encode() require.NoError(t, err) - require.Equal(t, encoded, encoded2) + require.Equal(t, encoded, reencoded) } // TestDecodeInvoiceRejectsTruncated locks in that DecodeInvoice surfaces an diff --git a/bolt12/offer_test.go b/bolt12/offer_test.go index ce786f02579..509d5e34d0e 100644 --- a/bolt12/offer_test.go +++ b/bolt12/offer_test.go @@ -6,33 +6,84 @@ import ( "testing" "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) -// TestOfferRoundTrip pins encode→decode→re-encode for an Offer with a -// representative subset of optional fields. A byte-identical re-encode is the -// invariant that keeps offer_id stable across the codec boundary. +// TestOfferRoundTrip pins encode, decode and re-encode for an Offer with every +// field set, plus an unknown odd TLV in the offer range. Comparing the decoded +// struct against the original catches a field that is wired into the encode +// path but not into the decode path, which byte identity alone cannot see: +// such a field survives as an unknown TLV and re-encodes cleanly while its +// typed value silently disappears. func TestOfferRoundTrip(t *testing.T) { t.Parallel() desc := tlv.Blob("coffee") issuer := tlv.Blob("alice") + currency := tlv.Blob("USD") + metadata := tlv.Blob("opaque") _, bobPub := bobKey() + _, intro := aliceKey() + _, blinding := bobKey() + _, hopPub := aliceKey() + + introNode, err := lnwire.NewPubkeyIntro(intro) + require.NoError(t, err) o := &Offer{ + OfferChains: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType2](ChainsRecord{ + Chains: [][32]byte{bitcoinMainnetGenesisHash}, + }), + ), + OfferMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4](metadata), + ), + OfferCurrency: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType6](currency), + ), OfferAmount: tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType8](TUint64(1500)), ), OfferDescription: tlv.SomeRecordT( tlv.NewPrimitiveRecord[tlv.TlvType10](desc), ), + OfferFeatures: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType12]( + *lnwire.NewRawFeatureVector(lnwire.MPPOptional), + ), + ), + OfferAbsoluteExpiry: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType14](TUint64(1 << 32)), + ), + OfferPaths: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType16](lnwire.BlindedPaths{ + Paths: []lnwire.BlindedPath{{ + IntroductionNode: introNode, + BlindingPoint: blinding, + Hops: []lnwire.BlindedHop{{ + BlindedNodeID: hopPub, + EncryptedData: []byte{1, 2}, + }}, + }}, + }), + ), OfferIssuer: tlv.SomeRecordT( tlv.NewPrimitiveRecord[tlv.TlvType18](issuer), ), + OfferQuantityMax: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType20](TUint64(5)), + ), OfferIssuerID: tlv.SomeRecordT( tlv.NewPrimitiveRecord[tlv.TlvType22](bobPub), ), + + // An unknown odd type in the offer range must survive the round + // trip byte for byte, so that offer_id stays stable across + // encoders that understand a wider set of extensions. + decodedTLVs: tlv.TypeMap{13: []byte{0xde, 0xad}}, } encoded, err := o.Encode() @@ -42,9 +93,12 @@ func TestOfferRoundTrip(t *testing.T) { decoded, err := decodeOffer(encoded) require.NoError(t, err) - require.Equal(t, TUint64(1500), decoded.OfferAmount.UnwrapOrFailV(t)) - require.Equal(t, desc, decoded.OfferDescription.UnwrapOrFailV(t)) - require.Equal(t, issuer, decoded.OfferIssuer.UnwrapOrFailV(t)) + // The sidecar is a decode artifact: it names every type seen on the + // wire, whereas the fixture above only carries the unknown one. Adopt + // the decoded view so the comparison below covers the typed fields. + require.Equal(t, []byte{0xde, 0xad}, decoded.decodedTLVs[13]) + o.decodedTLVs = decoded.decodedTLVs + require.Equal(t, o, decoded) reencoded, err := decoded.Encode() require.NoError(t, err) diff --git a/bolt12/subtypes_test.go b/bolt12/subtypes_test.go index a7c6287a33c..293e8a5ad8b 100644 --- a/bolt12/subtypes_test.go +++ b/bolt12/subtypes_test.go @@ -259,6 +259,7 @@ func TestDecodeBlindedPayInfosRejectsTruncated(t *testing.T) { data []byte declLen uint64 errSubstr string + wantErr error }{ { name: "missing fee_base", @@ -277,10 +278,10 @@ func TestDecodeBlindedPayInfosRejectsTruncated(t *testing.T) { errSubstr: "exceeds remaining", }, { - name: "exceeds cap", - data: make([]byte, (maxBlindedPayInfos+1)*28), - declLen: (maxBlindedPayInfos + 1) * 28, - errSubstr: "exceeds maxBlindedPayInfos", + name: "exceeds cap", + data: make([]byte, (maxBlindedPayInfos+1)*28), + declLen: (maxBlindedPayInfos + 1) * 28, + wantErr: ErrTooManyBlindedPayInfos, }, { name: "non-minimal features", @@ -290,8 +291,8 @@ func TestDecodeBlindedPayInfosRejectsTruncated(t *testing.T) { data: append( make([]byte, 26), []byte{0x00, 0x01, 0x00}..., ), - declLen: 29, - errSubstr: "non-minimal", + declLen: 29, + wantErr: ErrNonMinimalFeatures, }, { name: "inverted htlc range", @@ -305,8 +306,8 @@ func TestDecodeBlindedPayInfosRejectsTruncated(t *testing.T) { return b }(), - declLen: 26, - errSubstr: "htlc_minimum_msat exceeds", + declLen: 26, + wantErr: ErrInvalidHtlcRange, }, } @@ -320,6 +321,13 @@ func TestDecodeBlindedPayInfosRejectsTruncated(t *testing.T) { tc.declLen, ) require.Error(t, err) + + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + + return + } + require.Contains(t, err.Error(), tc.errSubstr) }) } @@ -361,6 +369,7 @@ func TestDecodeFallbackAddrsRejectsTruncated(t *testing.T) { data []byte declLen uint64 errSubstr string + wantErr error }{ { name: "missing version byte", @@ -392,10 +401,10 @@ func TestDecodeFallbackAddrsRejectsTruncated(t *testing.T) { errSubstr: "exceeds remaining", }, { - name: "exceeds cap", - data: make([]byte, (maxFallbackAddrs+1)*3), - declLen: (maxFallbackAddrs + 1) * 3, - errSubstr: "exceeds maxFallbackAddrs", + name: "exceeds cap", + data: make([]byte, (maxFallbackAddrs+1)*3), + declLen: (maxFallbackAddrs + 1) * 3, + wantErr: ErrTooManyFallbackAddrs, }, } @@ -409,6 +418,13 @@ func TestDecodeFallbackAddrsRejectsTruncated(t *testing.T) { tc.declLen, ) require.Error(t, err) + + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + + return + } + require.Contains(t, err.Error(), tc.errSubstr) }) } diff --git a/bolt12/test-vectors/README.md b/bolt12/test-vectors/README.md index 0659a3597bf..eed09f64eae 100644 --- a/bolt12/test-vectors/README.md +++ b/bolt12/test-vectors/README.md @@ -1,6 +1,8 @@ # BOLT 12 Spec Test Vectors -These test vectors are vendored from the upstream [lightning/bolts](https://github.com/lightning/bolts) specification repository. +These test vectors are vendored from the upstream +[lightning/bolts](https://github.com/lightning/bolts) specification +repository. - **Source**: `bolt12/` directory in `lightning/bolts` - **Upstream Commit**: `311119388a46dfa859da3d2eda0ca836cfc5f078` diff --git a/bolt12/validate.go b/bolt12/validate.go index e700b90e976..e305f181e66 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -313,10 +313,10 @@ func ValidateInvoiceErrorWrite(ie *InvoiceError) error { // - if it sets suggested_value: // - MUST set suggested_value to a valid field for that // tlv_fieldnum. - // NOT CHECKED HERE: verifying the replacement is a valid encoding for - // the erroneous field needs the schema of the rejected invoice or - // invoice_request, which is caller context this validator does not - // have. + // TODO(bitromortac): enforce this once we send invoice_error. It needs + // a field-number to type table for the invoice_request and invoice + // fields, so a tu64 field rejects a non-minimal value, type 80 + // requires 32 bytes and type 88 a compressed point. return nil } @@ -535,10 +535,8 @@ func ValidateInvoiceRequestWrite(ir *InvoiceRequest) error { // - if it supports bolt12 invoice request features: // - MUST set invreq_features.features to the bitmap of features. - // We rely on the writer to set feature bits correctly as those are - // mostly static and the reader will also verify the features. This is - // done to not having to pass in the known feature vector for writer - // validation, similar to other write validation in this file. + // NOT CHECKED HERE: the bits are the caller's own, and the reader + // rejects unknown even bits using the known bits passed to it. // check UTF-8 constraints and BIP 353 err := checkUTF8(ir.InvreqPayerNote, "invreq_payer_note") @@ -1200,12 +1198,12 @@ func checkISO4217[T tlv.TlvType](opt tlv.OptionalRecordT[T, tlv.Blob]) error { // checkFeatures rejects any unknown even (must-understand) feature bit. func checkFeatures[T tlv.TlvType]( opt tlv.OptionalRecordT[T, lnwire.RawFeatureVector], - known map[lnwire.FeatureBit]string) error { + knownFeatures map[lnwire.FeatureBit]string) error { return fn.MapOptionZ( opt.ValOpt(), func(fv lnwire.RawFeatureVector) error { - wrapped := lnwire.NewFeatureVector(&fv, known) + wrapped := lnwire.NewFeatureVector(&fv, knownFeatures) unknown := wrapped.UnknownRequiredFeatures() if len(unknown) == 0 { return nil @@ -1286,10 +1284,10 @@ func checkPubKeyNotNil[T tlv.TlvType]( // checkInvoiceNodeID enforces the spec rule that, when offer_issuer_id is // present, invoice_node_id MUST equal it. Both fields live on the invoice, so // this is verifiable without the originating offer. The offer_paths branch -// (invoice_node_id equals the final blinded_node_id on the arrival path) needs -// caller context and is not checked here. A present-but-nil offer_issuer_id or -// invoice_node_id is rejected separately as ErrNilPublicKey, so a nil here is -// treated as absent. +// (invoice_node_id equals the final blinded_node_id the payer sent the invoice +// request to) needs caller context and is not checked here. A present-but-nil +// offer_issuer_id or invoice_node_id is rejected separately as +// ErrNilPublicKey, so a nil here is treated as absent. func checkInvoiceNodeID(inv *Invoice) error { // A present-but-nil offer_issuer_id is rejected separately as // ErrNilPublicKey, so a nil here means absent and there is nothing to @@ -1696,13 +1694,13 @@ func isKnownInvoiceTLVType(typ tlv.Type) bool { } } -// InvoiceFeatureCatalogues names the two feature-bit catalogues the invoice +// InvoiceKnownFeatures names the two sets of known feature bits the invoice // reader validates against. They are grouped in a struct rather than passed as // two positional map[lnwire.FeatureBit]string arguments because the identical // types would otherwise let a caller transpose them silently: validating -// invoice_features against the blinded-path catalogue and vice versa compiles +// invoice_features against the blinded-path bits and vice versa compiles // cleanly but misvalidates. Named fields make the swap impossible. -type InvoiceFeatureCatalogues struct { +type InvoiceKnownFeatures struct { // Invoice names the feature bits the reader understands for the // top-level invoice_features field. Invoice map[lnwire.FeatureBit]string @@ -1723,7 +1721,7 @@ type InvoiceFeatureCatalogues struct { // selection time (via Invoice.UsablePaths) to avoid selecting paths with // unknown required features. func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, - features InvoiceFeatureCatalogues) error { + features InvoiceKnownFeatures) error { // - MUST reject the invoice if invoice_amount is not present. if !inv.InvoiceAmount.IsSome() { return ErrMissingAmount @@ -1852,8 +1850,8 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // fields live on the invoice). NOT CHECKED HERE: the byte-for-byte // field mirror and the invreq_amount == invoice_amount rule are // enforced by ValidateInvoiceAgainstRequest once the invoice is paired - // with its request; the offer_paths blinded_node_id case needs the - // arrival path and stays with the caller. + // with its request; the offer_paths blinded_node_id case needs the path + // the payer sent the request to and stays with the caller. if err := checkInvoiceNodeID(inv); err != nil { return err } @@ -1891,7 +1889,7 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // offerless request. func ValidateInvoiceForPayment(inv *Invoice, req *InvoiceRequest, now time.Time, activeChain [32]byte, - features InvoiceFeatureCatalogues, + features InvoiceKnownFeatures, expectedNodeID *btcec.PublicKey) error { if err := ValidateInvoiceRead(inv, activeChain, features); err != nil { diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index 7de328400df..1f05216f2c1 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/hex" "math" + "strings" "testing" "time" @@ -879,7 +880,7 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { return ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, ) }, }, @@ -904,7 +905,7 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { return ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, ) }, }, @@ -1038,7 +1039,7 @@ func TestValidateInvoiceForPayment(t *testing.T) { // Blinded mode, happy path: the final node matches invoice_node_id. require.NoError(t, ValidateInvoiceForPayment( invDecoded, blindedIRDecoded, validNow, - bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, bobPub, )) // The expiry gate is part of the composite, so a caller that only calls @@ -1046,7 +1047,7 @@ func TestValidateInvoiceForPayment(t *testing.T) { // window is long past 8000s after creation. err = ValidateInvoiceForPayment( invDecoded, blindedIRDecoded, time.Unix(1234567890+8000, 0), - bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, bobPub, ) require.ErrorIs(t, err, ErrInvoiceExpired) @@ -1090,7 +1091,7 @@ func TestValidateInvoiceForPayment(t *testing.T) { err = ValidateInvoiceForPayment( overchargedDecoded, amountIRDecoded, validNow, - bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, bobPub, ) require.ErrorIs(t, err, ErrInvoiceMismatch) require.ErrorContains( @@ -1101,7 +1102,7 @@ func TestValidateInvoiceForPayment(t *testing.T) { // Bob answered, but the payer believes it addressed Alice. err = ValidateInvoiceForPayment( invDecoded, blindedIRDecoded, validNow, - bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, alicePub, ) require.ErrorIs(t, err, ErrUnexpectedInvoiceNodeID) @@ -1136,12 +1137,12 @@ func TestValidateInvoiceForPayment(t *testing.T) { // invoice is what shows the second step cannot be skipped. require.NoError(t, ValidateInvoiceRead( forgedDecoded, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, )) err = ValidateInvoiceForPayment( forgedDecoded, blindedIRDecoded, validNow, - bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, bobPub, ) require.ErrorIs(t, err, ErrUnexpectedInvoiceNodeID) @@ -1188,14 +1189,14 @@ func TestValidateInvoiceForPayment(t *testing.T) { // the offer it built the request from. require.NoError(t, ValidateInvoiceForPayment( invDecoded, cleartextIRDecoded, validNow, - bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, 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, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, alicePub, ) require.ErrorIs(t, err, ErrUnexpectedInvoiceNodeID) } @@ -2052,6 +2053,53 @@ func TestValidateInvoiceRequestAmountOverflow(t *testing.T) { require.ErrorIs(t, writeErr, ErrAmountBelowExpected) } +// TestValidateInvoiceAmountOverflow is the invoice-side twin of +// TestValidateInvoiceRequestAmountOverflow: with invreq_amount absent the +// authorized amount is offer_amount times invreq_quantity, and that product +// must not wrap. An unguarded multiply would truncate to zero and accept any +// invoice_amount as "at least zero". +func TestValidateInvoiceAmountOverflow(t *testing.T) { + t.Parallel() + + _, pub := bobKey() + + req := &InvoiceRequest{} + req.OfferIssuerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22](pub), + ) + req.OfferAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType8](TUint64(2)), + ) + + // quantity_max zero means unlimited, so the bound check does not cap + // the quantity below. + req.OfferQuantityMax = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType20](TUint64(0)), + ) + + // We request to pay 2^63 units, which would overflow the uint64 product + // with offer_amount(2). + req.InvreqQuantity = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType86](TUint64(1 << 63)), + ) + req.InvreqPayerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](pub), + ) + req.InvreqMetadata = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0](tlv.Blob("m")), + ) + + // An invoice setting the overflow value of 0 would be accepted by an + // unguarded validator. + inv := NewInvoiceFromRequest(req) + inv.InvoiceAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170](TUint64(0)), + ) + + err := ValidateInvoiceAgainstRequest(inv, req) + require.ErrorIs(t, err, ErrAmountBelowExpected) +} + // TestValidateInvoiceRequestReadChain pins the spec invreq_chain rule: // an absent invreq_chain defaults to Bitcoin mainnet and must be // rejected on a non-mainnet node, while a present invreq_chain that @@ -2418,7 +2466,7 @@ func TestValidateInvoiceRead(t *testing.T) { err := ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, ) require.ErrorIs(t, err, tc.wantErr) }) @@ -2490,7 +2538,7 @@ func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) { err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, ) require.NoError(t, err) } @@ -3055,10 +3103,10 @@ func TestValidateInvoiceWrite(t *testing.T) { } } -// TestValidateFeaturesWithCatalogue verifies that both Role 1 endpoint features -// and Role 2 routing path features are correctly validated using injected -// catalogues. -func TestValidateFeaturesWithCatalogue(t *testing.T) { +// TestValidateFeaturesKnownBits verifies that both Role 1 endpoint features +// and Role 2 routing path features are correctly validated against the known +// bits the caller injects. +func TestValidateFeaturesKnownBits(t *testing.T) { t.Parallel() // Role 1 validation verifies endpoint features on ValidateInvoiceRead. @@ -3085,7 +3133,7 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { // An unknown required bit must be rejected. err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, ) require.ErrorIs(t, err, ErrUnknownEvenFeature) @@ -3095,7 +3143,7 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { } err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{Invoice: known}, + InvoiceKnownFeatures{Invoice: known}, ) require.NoError(t, err) }) @@ -3127,22 +3175,22 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { 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. + // With no known feature bits there are zero usable paths, so + // we expect ErrNoUsablePaths. err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, ) require.ErrorIs(t, err, ErrNoUsablePaths) - // A known features catalogue for blinded pay results in at - // least one usable path, which must pass. + // Known blinded-pay bits leave at least one usable path, which + // must pass. knownBlinded := map[lnwire.FeatureBit]string{ lnwire.MPPRequired: "mpp", } err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{Blinded: knownBlinded}, + InvoiceKnownFeatures{Blinded: knownBlinded}, ) require.NoError(t, err) }) @@ -3375,6 +3423,23 @@ func TestValidateOfferReadVectors(t *testing.T) { ) } + // Feed the vector back out through the + // writer. Nothing else runs Encode or the + // bech32 writer against spec data, so a + // record-ordering or writer bug would + // otherwise only show against fixtures we + // wrote ourselves. + restrung, encErr := EncodeOfferString(offer) + require.NoError(t, encErr) + require.True( + t, + strings.EqualFold( + tc.Bolt12, restrung, + ), + "re-encode mismatch: want %s, got %s", + tc.Bolt12, restrung, + ) + return } @@ -3387,68 +3452,6 @@ func TestValidateOfferReadVectors(t *testing.T) { } } -// TestOfferVectorsLayerCensus verifies that every invalid vector in -// offers-test.json is rejected at the expected layer, pinning the distribution -// of failure modes across bech32 decode, TLV decode, and semantic validation. -func TestOfferVectorsLayerCensus(t *testing.T) { - t.Parallel() - - vectors := loadOffersVectors(t) - now := farFutureNow() - - var ( - bech32Rejections int - tlvRejections int - valRejections int - falseAccepts int - ) - - for _, tc := range vectors { - if tc.Valid { - continue - } - - _, tlvBytes, bech32Err := Decode(tc.Bolt12) - if bech32Err != nil { - bech32Rejections++ - continue - } - - offer, decodeErr := decodeOffer(tlvBytes) - if decodeErr != nil { - tlvRejections++ - continue - } - - valErr := ValidateOfferRead( - offer, now, bitcoinMainnetGenesisHash, nil, - ) - if valErr != nil { - valRejections++ - continue - } - - t.Errorf( - "invalid vector falsely accepted: %s", - tc.Description, - ) - falseAccepts++ - } - - require.Equal( - t, 2, bech32Rejections, "bech32 rejections mismatch", - ) - require.Equal( - t, 16, tlvRejections, "TLV decode rejections mismatch", - ) - require.Equal( - t, 15, valRejections, "validation rejections mismatch", - ) - require.Equal( - t, 0, falseAccepts, "false accepts count mismatch", - ) -} - // findRecord searches a slice of TLV records for a record with the given type. func findRecord(records []tlv.Record, typ uint64) (*tlv.Record, bool) { for i := range records {