diff --git a/bolt12/bech32.go b/bolt12/bech32.go index b868022546..ec0cdfe3e4 100644 --- a/bolt12/bech32.go +++ b/bolt12/bech32.go @@ -10,10 +10,8 @@ import ( ) var ( - // ErrStringTooLong is returned when a raw string is longer than - // maxBolt12RawStringLen or a cleaned string is longer than - // maxBolt12StringLen. It is also returned when a payload is larger - // than maxBolt12DataLen. + // ErrStringTooLong is returned when a payload is larger than + // maxBolt12DataLen. ErrStringTooLong = errors.New("input length exceeds limit") // ErrEmptyString is returned when a string has no characters. It is @@ -72,30 +70,19 @@ const ( maxPrintableASCII = 126 // bolt12HRPLen is the length of a BOLT 12 human-readable prefix. All - // three prefixes have it, so the limit below counts it as a fixed cost. + // three prefixes have it. bolt12HRPLen = 3 - // maxBolt12DataLen is the largest TLV stream that one BOLT 12 string - // can hold. The spec limits neither a field nor the stream, so the - // limit comes from this package: the P2P decoder rejects a record above - // tlv.MaxRecordSize. Eleven offer fields at that size give 704 - // kibibytes, and one mebibyte leaves room for unknown odd fields. Only - // an offer needs the room, because an invoice travels in a smaller - // onion message. + // maxBolt12DataLen is the largest TLV stream encodeBech32 emits. It + // is a writer policy, not a protocol rule: the spec limits neither a + // field nor the stream, and no other implementation caps either. The + // P2P decoder rejects a record above tlv.MaxRecordSize, eleven offer + // fields at that size give 704 kibibytes, and one mebibyte leaves + // room for unknown odd fields. decodeBech32 enforces no length limit, + // because a decode allocates on the order of its input and the input + // already exists in the caller's memory, so a limit there would + // reject a spec-valid message without protecting anything. maxBolt12DataLen = 1 << 20 - - // maxBolt12StringLen is the largest cleaned BOLT 12 bech32 string the - // codec accepts, once continuation markers and their whitespace are - // stripped. Each character of the data part holds 5 of the 8 bits of - // a payload byte. The limit therefore comes from maxBolt12DataLen. It - // counts the prefix, the separator, and one character for each group - // of 5 bits. Encode and Decode use the same limit, so every string - // that Encode makes is a string that Decode accepts. - maxBolt12StringLen = bolt12HRPLen + 1 + (maxBolt12DataLen*8+4)/5 - - // maxBolt12RawStringLen is the largest raw BOLT 12 string the codec - // accepts, continuation markers and whitespace included. - maxBolt12RawStringLen = 2 * maxBolt12StringLen ) // validHRPs holds the prefixes the BOLT 12 codec accepts, in the order the @@ -116,32 +103,18 @@ func unsupportedHRPError(hrp string) error { ) } -// Decode reads a BOLT 12 bech32 string. It returns the human-readable prefix -// and the data bytes. A BOLT 12 string has no checksum. A '+' character can -// join two parts of the string, and whitespace can follow it. Decode rejects -// a raw string above maxBolt12RawStringLen and a cleaned string above -// maxBolt12StringLen, but the caller must set a smaller limit for its own -// medium. See the caller obligations in the package documentation. -func Decode(s string) (string, []byte, error) { - if len(s) > maxBolt12RawStringLen { - return "", nil, fmt.Errorf( - "bolt12: %w: input length %d exceeds limit %d", - ErrStringTooLong, len(s), maxBolt12RawStringLen, - ) - } - +// decodeBech32 reads a BOLT 12 bech32 string. It returns the human-readable +// prefix and the data bytes. A BOLT 12 string has no checksum. A '+' character +// can join two parts of the string, and whitespace can follow it. It enforces +// the BOLT 12 encoding rules and no length limit, so the caller bounds its own +// medium: the onion-message envelope bounds an invoice_request and an invoice, +// and the RPC or CLI bounds a pasted or scanned offer string. +func decodeBech32(s string) (string, []byte, error) { cleaned, err := stripContinuation(s) if err != nil { return "", nil, err } - if len(cleaned) > maxBolt12StringLen { - return "", nil, fmt.Errorf( - "bolt12: %w: cleaned length %d exceeds limit %d", - ErrStringTooLong, len(cleaned), maxBolt12StringLen, - ) - } - if len(cleaned) == 0 { return "", nil, fmt.Errorf("bolt12: %w", ErrEmptyString) } @@ -183,11 +156,11 @@ func Decode(s string) (string, []byte, error) { return hrp, data8bit, nil } -// Encode makes a BOLT 12 bech32 string from the data bytes and the given +// encodeBech32 makes a BOLT 12 bech32 string from the data bytes and the given // human-readable prefix. It adds no checksum. It changes the prefix to -// lowercase and takes only lno, lnr, and lni. The payload size must be a size -// that Decode also takes, so a caller can make only strings that Decode reads. -func Encode(hrp string, data []byte) (string, error) { +// lowercase and takes only lno, lnr, and lni. It refuses a payload above +// maxBolt12DataLen, a writer policy the reader does not mirror. +func encodeBech32(hrp string, data []byte) (string, error) { hrp = strings.ToLower(hrp) if !isValidHRP(hrp) { return "", unsupportedHRPError(hrp) @@ -195,7 +168,7 @@ func Encode(hrp string, data []byte) (string, error) { // A BOLT 12 string holds a TLV stream, and the stream must hold at // least one record. An empty payload gives a string with only the - // prefix and the separator, which Decode rejects. + // prefix and the separator, which decodeBech32 rejects. if len(data) == 0 { return "", fmt.Errorf( "bolt12: %w: nothing to encode", ErrEmptyString, diff --git a/bolt12/bech32_test.go b/bolt12/bech32_test.go index c1b8ed97f6..b9b5b1b492 100644 --- a/bolt12/bech32_test.go +++ b/bolt12/bech32_test.go @@ -22,7 +22,7 @@ func TestBech32FormatStringVectors(t *testing.T) { t.Run(tc.Comment, func(t *testing.T) { t.Parallel() - hrp, decoded, err := Decode(tc.String) + hrp, decoded, err := decodeBech32(tc.String) if !tc.Valid { require.Error(t, err, "expected error for: %s", @@ -37,10 +37,10 @@ func TestBech32FormatStringVectors(t *testing.T) { require.NotEmpty(t, decoded) // Round-trip: re-encode and decode again. - encoded, err := Encode(hrp, decoded) + encoded, err := encodeBech32(hrp, decoded) require.NoError(t, err) - hrp2, decoded2, err := Decode(encoded) + hrp2, decoded2, err := decodeBech32(encoded) require.NoError(t, err) require.Equal(t, hrp, hrp2) require.Equal(t, decoded, decoded2) @@ -59,11 +59,11 @@ func TestBech32RoundTrip(t *testing.T) { t.Run(hrp, func(t *testing.T) { t.Parallel() - encoded, err := Encode(hrp, testData) + encoded, err := encodeBech32(hrp, testData) require.NoError(t, err) require.True(t, len(encoded) > len(hrp)+1) - gotHRP, gotData, err := Decode(encoded) + gotHRP, gotData, err := decodeBech32(encoded) require.NoError(t, err) require.Equal(t, hrp, gotHRP) require.Equal(t, testData, gotData) @@ -105,7 +105,7 @@ func TestBech32DecodeErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - _, _, err := Decode(tc.input) + _, _, err := decodeBech32(tc.input) require.Error(t, err) }) } @@ -239,13 +239,13 @@ func TestDecodeContinuationAnywhere(t *testing.T) { t.Parallel() payload := []byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef} - encoded, err := Encode(HRPOffer, payload) + encoded, err := encodeBech32(HRPOffer, payload) require.NoError(t, err) for i := 1; i < len(encoded); i++ { split := encoded[:i] + "+" + encoded[i:] - hrp, data, err := Decode(split) + hrp, data, err := decodeBech32(split) require.NoError(t, err, "marker at position %d", i) require.Equal(t, HRPOffer, hrp) require.Equal(t, payload, data) @@ -259,7 +259,7 @@ func TestDecodeContinuationAnywhere(t *testing.T) { func TestEncodeUnknownHRP(t *testing.T) { t.Parallel() - _, err := Encode("bogus", []byte{0x00}) + _, err := encodeBech32("bogus", []byte{0x00}) require.ErrorIs(t, err, ErrUnsupportedHRP) for _, hrp := range validHRPs { @@ -272,7 +272,7 @@ func TestEncodeUnknownHRP(t *testing.T) { func TestDecodeUnknownHRP(t *testing.T) { t.Parallel() - _, _, err := Decode("bogus1pqps7sjq") + _, _, err := decodeBech32("bogus1pqps7sjq") require.ErrorIs(t, err, ErrUnsupportedHRP) } @@ -293,51 +293,53 @@ func TestDecodeUnprintableCharacter(t *testing.T) { } for _, input := range unprintable { - _, _, err := Decode(input) + _, _, err := decodeBech32(input) require.ErrorIs(t, err, ErrInvalidCharacter) } } -// TestDecodeOversizeInput asserts the input length cap fires before any -// allocation. -func TestDecodeOversizeInput(t *testing.T) { +// maxEncodedLen is the length of the string Encode makes from the largest +// payload it accepts: the prefix, the separator, and one character per group +// of five payload bits. +const maxEncodedLen = bolt12HRPLen + 1 + (maxBolt12DataLen*8+4)/5 + +// TestDecodeAcceptsAboveWriterLimit asserts Decode enforces no length limit of +// its own, so a string longer than anything Encode emits still decodes. The +// bound belongs to the caller's medium, not to the codec. +func TestDecodeAcceptsAboveWriterLimit(t *testing.T) { t.Parallel() - // A raw string above the transport limit is rejected. - huge := strings.Repeat("a", maxBolt12RawStringLen+1) - _, _, err := Decode(huge) - require.ErrorIs(t, err, ErrStringTooLong) - - // A string under the raw limit but over the cleaned limit is - // rejected after stripping. - oversize := strings.Repeat("a", maxBolt12StringLen+1) - _, _, err = Decode(oversize) - require.ErrorIs(t, err, ErrStringTooLong) - - // A string at the cleaned limit is accepted, but here leads to a - // parsing error. - oversize = strings.Repeat("a", maxBolt12StringLen) - _, _, err = Decode(oversize) - require.ErrorIs(t, err, ErrInvalidSeparator) + payload := make([]byte, maxBolt12DataLen) + encoded, err := encodeBech32(HRPOffer, payload) + require.NoError(t, err) + + // Eight more data characters carry five more payload bytes, so the + // string and its payload both exceed what Encode would emit. + oversize := encoded + strings.Repeat("q", 8) + require.Greater(t, len(oversize), maxEncodedLen) + + hrp, data, err := decodeBech32(oversize) + require.NoError(t, err) + require.Equal(t, HRPOffer, hrp) + require.Greater(t, len(data), maxBolt12DataLen) } // TestDecodeWrappedMaxPayload asserts that a legal continuation wrapping of the -// longest string Encode can make still decodes. The cleaned limit governs the -// payload, and the raw limit leaves room for the wrapping. +// longest string Encode can make still decodes. func TestDecodeWrappedMaxPayload(t *testing.T) { t.Parallel() payload := make([]byte, maxBolt12DataLen) - encoded, err := Encode(HRPOffer, payload) + encoded, err := encodeBech32(HRPOffer, payload) require.NoError(t, err) - require.Len(t, encoded, maxBolt12StringLen) + require.Len(t, encoded, maxEncodedLen) - // Insert a marker and a whitespace run into the data part. The raw - // string grows past the cleaned limit but stays under the raw one. + // Insert a marker and a whitespace run into the data part, so the raw + // string grows past the string Encode made. wrapped := encoded[:100] + "+ \n\t" + encoded[100:] - require.Greater(t, len(wrapped), maxBolt12StringLen) + require.Greater(t, len(wrapped), len(encoded)) - hrp, data, err := Decode(wrapped) + hrp, data, err := decodeBech32(wrapped) require.NoError(t, err) require.Equal(t, HRPOffer, hrp) require.Equal(t, payload, data) @@ -397,7 +399,7 @@ func TestEncodePayloadSize(t *testing.T) { { name: "longest payload", payload: make([]byte, maxBolt12DataLen), - wantLen: maxBolt12StringLen, + wantLen: maxEncodedLen, }, { name: "one byte above the longest payload", @@ -410,7 +412,7 @@ func TestEncodePayloadSize(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - encoded, err := Encode(HRPOffer, tc.payload) + encoded, err := encodeBech32(HRPOffer, tc.payload) if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) @@ -423,7 +425,7 @@ func TestEncodePayloadSize(t *testing.T) { } // Decode takes each string that Encode makes. - hrp, data, err := Decode(encoded) + hrp, data, err := decodeBech32(encoded) require.NoError(t, err) require.Equal(t, HRPOffer, hrp) require.Equal(t, tc.payload, data) @@ -438,13 +440,13 @@ func TestDecodeUppercase(t *testing.T) { t.Parallel() payload := []byte{0x01, 0x23, 0x45, 0x67} - encoded, err := Encode(HRPOffer, payload) + encoded, err := encodeBech32(HRPOffer, payload) require.NoError(t, err) uppered := strings.ToUpper(encoded) require.NotEqual(t, encoded, uppered) - hrp, data, err := Decode(uppered) + hrp, data, err := decodeBech32(uppered) require.NoError(t, err) require.Equal(t, HRPOffer, hrp) require.Equal(t, payload, data) @@ -472,10 +474,10 @@ func TestPropertyBech32RoundTrip(t *testing.T) { rapid.Byte(), size, size, ).Draw(t, "data") - encoded, err := Encode(hrp, data) + encoded, err := encodeBech32(hrp, data) require.NoError(t, err) - decodedHRP, decodedData, err := Decode(encoded) + decodedHRP, decodedData, err := decodeBech32(encoded) require.NoError(t, err) require.Equal(t, hrp, decodedHRP) require.Equal(t, data, decodedData) diff --git a/bolt12/decode_test.go b/bolt12/decode_test.go index d1ff88d067..f51376277a 100644 --- a/bolt12/decode_test.go +++ b/bolt12/decode_test.go @@ -81,6 +81,122 @@ func TestDecodeRejectsNonMinimalFeatures(t *testing.T) { } } +// TestEncodeWritesMinimalFeatures tests that every features field is written in +// its minimal form on encode. The Merkle leaves commit to the encoded bytes, so +// a padded vector would sign bytes the strict decoder rejects. +func TestEncodeWritesMinimalFeatures(t *testing.T) { + t.Parallel() + + // Clearing a high bit must not leave padding behind. Only bit 1 is + // left, whose minimal encoding is the single byte 0x02. + features := lnwire.NewRawFeatureVector() + features.Set(201) + features.Unset(201) + features.Set(1) + minimal := []byte{0x02} + + offer := validBobOffer(t) + offer.OfferFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType12](*features), + ) + + invreq := validInvoiceRequest(t) + invreq.InvreqFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType84](*features), + ) + + // A request that answers an offer also mirrors offer_features. + _, payerPub := aliceKey() + offerReq, err := NewInvoiceRequestFromOffer( + offer, payerPub, []byte("metadata"), bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + offerReq.InvreqFeatures = invreq.InvreqFeatures + offerReq.InvreqAmount = invreq.InvreqAmount + + inv := validInvoice(t) + inv.OfferFeatures = offer.OfferFeatures + inv.InvreqFeatures = invreq.InvreqFeatures + inv.InvoiceFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType174](*features), + ) + + tests := []struct { + name string + encode func() ([]byte, error) + decode func([]byte) error + types []tlv.Type + }{ + { + name: "offer", + encode: offer.encode, + decode: func(b []byte) error { + _, err := decodeOffer(b) + return err + }, + types: []tlv.Type{offerFeaturesType}, + }, + { + name: "invoice_request for an offer", + encode: offerReq.encode, + decode: func(b []byte) error { + _, err := DecodeInvoiceRequest(b) + return err + }, + types: []tlv.Type{ + offerFeaturesType, invreqFeaturesType, + }, + }, + { + name: "invoice_request", + encode: invreq.encode, + decode: func(b []byte) error { + _, err := DecodeInvoiceRequest(b) + return err + }, + types: []tlv.Type{invreqFeaturesType}, + }, + { + name: "invoice", + encode: inv.encode, + decode: func(b []byte) error { + _, err := DecodeInvoice(b) + return err + }, + types: []tlv.Type{ + offerFeaturesType, invreqFeaturesType, + invoiceFeaturesType, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + encoded, err := tc.encode() + require.NoError(t, err) + + stream, err := tlv.NewStream() + require.NoError(t, err) + typeMap, err := stream.DecodeWithParsedTypesP2P( + bytes.NewReader(encoded), + ) + require.NoError(t, err) + + for _, typ := range tc.types { + require.Equal( + t, minimal, typeMap[typ], + "type %d", typ, + ) + } + + // The strict decoder accepts what encode wrote. + require.NoError(t, tc.decode(encoded)) + }) + } +} + // 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. diff --git a/bolt12/doc.go b/bolt12/doc.go index 61cc552d65..58bd84f088 100644 --- a/bolt12/doc.go +++ b/bolt12/doc.go @@ -1,33 +1,59 @@ // Package bolt12 implements encoding, decoding, and validation for BOLT 12 // 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. -// -// Human-readable prefixes: -// - lno: Offer -// - lnr: Invoice Request -// - lni: Invoice -// -// # Codec Contract -// -// Encode validates before serialising and refuses to emit bytes that would fail -// 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. +// reach into the daemon, and it takes the chain, the clock, the known feature +// bits and the node it expected to answer from its caller. Anything a message +// cannot prove about itself is the caller's to supply. +// +// BOLT 12 messages are TLV streams signed with BIP-340 Schnorr signatures over +// a Merkle tree of their own fields. +// +// # The flow +// +// A merchant publishes an offer out of band. A payer reads it with +// DecodeOfferString, mirrors its fields into a request with +// NewInvoiceRequestFromOffer, signs that with SignInvoiceRequest, and sends the +// EncodeSigned bytes inside an onion message. The receiver decodes them with +// DecodeInvoiceRequest, gates the result with ValidateInvoiceRequestRead, and +// answers with an invoice built by NewInvoiceFromRequest and signed with +// SignInvoice. The payer decodes that reply with DecodeInvoice, gates it with +// ValidateInvoiceForPayment, and pays the paths UsablePaths returns. +// TestOfferPaymentFlow in offer_test.go runs these steps in order, one per +// party. +// +// A payment can also start without an offer. The payer builds a request with no +// offer_issuer_id or offer_paths, signs it with SignInvoiceRequest, and +// publishes it with EncodeInvoiceRequestString as an lnr1 string, such as a QR +// code. The payee reads it with DecodeInvoiceRequestString and answers with an +// invoice built and signed as above, sent to invreq_paths or invreq_payer_id. +// The payer gates that invoice with ValidateInvoiceForPayment, passing a nil +// node id unless it confirmed the payee's key out of band. +// TestOfferlessPaymentFlow in offer_test.go runs these steps. +// +// # Wire form or string form +// +// An offer only ever travels out of band, as an lno1 string. An invoice_request +// and an invoice reach a peer as raw TLV inside an onion message, which is what +// EncodeSigned emits. An invoice also has an lni1 string, for display and for +// out-of-band delivery. An invoice_request that answers no offer is published +// as an lnr1 string instead, which EncodeInvoiceRequestString emits signed. +// +// # Pitfalls +// +// Gating a reply invoice against itself is not enough. The read gates check an +// invoice in isolation, but the mirror match against the request, the node +// binding and the expiry need state only the payer holds, and only +// ValidateInvoiceForPayment applies them. A payer that merely decodes and reads +// will accept a correctly signed invoice from the wrong node. +// +// Signing and encoding are independent. Signing reads the struct, so encoding +// never has to run first and does not require a signature. EncodeSigned and the +// string encoders are where a signature becomes mandatory. +// +// Unknown fields must survive a round trip. A signature covers the Merkle root +// over the message's own TLVs, so re-encoding has to reproduce the wire bytes. +// That is why decoding rejects a non-minimal encoding rather than normalising +// it, and why unknown TLVs are preserved verbatim. +// +// DecodeInvoiceStringUnvalidated skips every gate. Use it only to display an +// invoice that was validated when it was stored. package bolt12 diff --git a/bolt12/fuzz_test.go b/bolt12/fuzz_test.go index 35f18fb675..557bd36595 100644 --- a/bolt12/fuzz_test.go +++ b/bolt12/fuzz_test.go @@ -50,7 +50,7 @@ func tlvStreams(t testing.TB, strings []string) [][]byte { var seeds [][]byte for _, s := range strings { - _, tlvBytes, err := Decode(s) + _, tlvBytes, err := decodeBech32(s) if err != nil { continue } @@ -75,21 +75,12 @@ func invreqTLVSeeds(t testing.TB) [][]byte { 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) { +func fuzzByteCodec[M any](f *testing.F, decode func([]byte) (*M, error), + encode func(*M) ([]byte, error), seeds ...[]byte) { for _, seed := range seeds { f.Add(seed) @@ -104,7 +95,7 @@ func fuzzByteCodec[M any, PM byteCodec[M]](f *testing.F, t.Fatal("nil message with nil error") } - encoded, err := msg.Encode() + encoded, err := encode(msg) if err != nil { // Read accepts constraints write rejects, so a decoded // message may fail writer validation. Skip the @@ -116,7 +107,7 @@ func fuzzByteCodec[M any, PM byteCodec[M]](f *testing.F, if err != nil { t.Fatalf("round-trip decode failed: %v", err) } - encoded2, err := again.Encode() + encoded2, err := encode(again) if err != nil { t.Fatalf("second encode failed: %v", err) } @@ -141,20 +132,23 @@ func fuzzStringCodec(f *testing.F, decode func(string), seeds ...string) { // 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)...) + fuzzByteCodec(f, decodeOffer, (*Offer).encode, 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)...) + fuzzByteCodec( + f, DecodeInvoiceRequest, (*InvoiceRequest).encode, + 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{ + fuzzByteCodec(f, DecodeInvoice, (*Invoice).encode, []byte{ 0xa8, 0x20, // type=168, length=32 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, @@ -166,7 +160,7 @@ func FuzzDecodeInvoice(f *testing.F) { // 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{ + fuzzByteCodec(f, DecodeInvoiceError, (*InvoiceError).Encode, []byte{ 0x05, 0x05, // type=5 error, length=5 'h', 'e', 'l', 'l', 'o', }) @@ -233,12 +227,12 @@ func FuzzBech32RoundTrip(f *testing.F) { } hrp := hrps[int(hrpIdx)%len(hrps)] - encoded, err := Encode(hrp, data) + encoded, err := encodeBech32(hrp, data) if err != nil { return } - gotHRP, gotData, err := Decode(encoded) + gotHRP, gotData, err := decodeBech32(encoded) if err != nil { t.Fatalf( "decode after successful encode "+ diff --git a/bolt12/invoice.go b/bolt12/invoice.go index a27db95d65..d306eebac9 100644 --- a/bolt12/invoice.go +++ b/bolt12/invoice.go @@ -21,7 +21,7 @@ import ( type Invoice struct { // Fields in the 0-91 range are mirrored verbatim from the // invoice_request (which carries the offer's fields); the byte-for-byte - // match is enforced by ValidateInvoiceAgainstRequest. + // match is enforced by validateInvoiceAgainstRequest. // InvreqMetadata is the payer metadata. InvreqMetadata tlv.OptionalRecordT[tlv.TlvType0, tlv.Blob] @@ -159,9 +159,9 @@ const ( maxWitnessProgramLen = 40 ) -// UsableFallbackAddresses returns the invoice_fallbacks entries a payer may use +// usableFallbackAddresses returns the invoice_fallbacks entries a payer may use // after applying the BOLT 12 reader's MUST-ignore rules for the bitcoin chain. -func (inv *Invoice) UsableFallbackAddresses() []FallbackAddress { +func (inv *Invoice) usableFallbackAddresses() []FallbackAddress { // Unwrap the optional up front so the filtering loop stays flat; a nil // Addrs slice ranges as empty. fallbacks := inv.InvoiceFallbacks.ValOpt().UnwrapOr(FallbackAddresses{}) @@ -209,7 +209,7 @@ type UsablePath struct { // set. knownBlindedFeatures names the feature bits the reader understands. // // The result is empty when invoice_paths or invoice_blindedpay is absent, or -// when the two lists differ in length; ValidateInvoiceRead rejects those cases +// when the two lists differ in length; validateInvoiceRead rejects those cases // separately, so a caller that validates first can treat an empty result as // "no usable paths". func (inv *Invoice) UsablePaths( @@ -219,7 +219,7 @@ func (inv *Invoice) UsablePaths( bp := inv.InvoiceBlindedPay.ValOpt().UnwrapOr(BlindedPayInfos{}) // Entries pair by index; a length mismatch is rejected upstream by - // ValidateInvoiceRead, so guard here to stay in bounds. + // validateInvoiceRead, so guard here to stay in bounds. if len(paths.Paths) != len(bp.Infos) { return nil } @@ -284,10 +284,10 @@ func (inv *Invoice) allRecordProducers() []tlv.RecordProducer { return p } -// Encode validates the invoice per writer requirements and serialises it via +// encode validates the invoice per writer requirements and serialises it via // the PureTLVMessage shape. -func (inv *Invoice) Encode() ([]byte, error) { - if err := ValidateInvoiceWrite(inv); err != nil { +func (inv *Invoice) encode() ([]byte, error) { + if err := validateInvoiceWrite(inv); err != nil { return nil, fmt.Errorf("validate invoice: %w", err) } @@ -299,8 +299,35 @@ func (inv *Invoice) Encode() ([]byte, error) { return buf.Bytes(), nil } +// EncodeSigned serialises an invoice that is ready to leave the node. It +// requires the signature the writer requirements make mandatory and verifies +// it against invoice_node_id. +// +// encode stays permissive about the signature because signing does not need +// it: SignInvoice derives the Merkle root from the records, so a caller never +// has to encode first. EncodeSigned is the entry point for bytes that reach a +// peer, so it is where the writer-side MUST is enforced. An invoice travels as +// raw TLV inside an onion message, so that boundary is not the bech32 string +// form. +func (inv *Invoice) EncodeSigned() ([]byte, error) { + if !inv.Signature.IsSome() { + return nil, ErrMissingSignature + } + + tlvBytes, err := inv.encode() + if err != nil { + return nil, err + } + + if err := verifyInvoice(inv); err != nil { + return nil, err + } + + return tlvBytes, nil +} + // DecodeInvoice deserializes an invoice from a TLV byte stream. Decoding is -// permissive: callers that need spec compliance must run ValidateInvoiceRead. +// permissive: callers that need spec compliance must run validateInvoiceRead. func DecodeInvoice(data []byte) (*Invoice, error) { var inv Invoice @@ -390,10 +417,29 @@ func DecodeInvoice(data []byte) (*Invoice, error) { return &inv, nil } +// DecodeInvoiceStringUnvalidated decodes a BOLT 12 invoice from its bech32 +// string representation (lni1...) without running the reader gates. It exists +// for displaying an invoice that was already validated when it was stored, +// such as one read back from a database column. Every other caller wants +// DecodeInvoiceString. +func DecodeInvoiceStringUnvalidated(s string) (*Invoice, error) { + hrp, tlvBytes, err := decodeBech32(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) + } + + return DecodeInvoice(tlvBytes) +} + // 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. +// 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 @@ -402,17 +448,7 @@ func DecodeInvoice(data []byte) (*Invoice, error) { 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) + inv, err := DecodeInvoiceStringUnvalidated(s) if err != nil { return nil, err } @@ -421,11 +457,11 @@ func DecodeInvoiceString(s string, now time.Time, Invoice: Bolt12Features, Blinded: Bolt12Features, } - if err := ValidateInvoiceRead(inv, activeChain, features); err != nil { + if err := validateInvoiceRead(inv, activeChain, features); err != nil { return nil, fmt.Errorf("validate: %w", err) } - if err := ValidateInvoiceExpiry(inv, now); err != nil { + if err := validateInvoiceExpiry(inv, now); err != nil { return nil, fmt.Errorf("validate: %w", err) } @@ -434,23 +470,15 @@ func DecodeInvoiceString(s string, now time.Time, // 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. +// it carries the same signature requirement as the raw TLV form and adds the +// human-readable prefix. func EncodeInvoiceString(inv *Invoice) (string, error) { - if !inv.Signature.IsSome() { - return "", ErrMissingSignature - } - - tlvBytes, err := inv.Encode() + tlvBytes, err := inv.EncodeSigned() if err != nil { return "", err } - if err := VerifyInvoice(inv); err != nil { - return "", err - } - - return Encode(HRPInvoice, tlvBytes) + return encodeBech32(HRPInvoice, tlvBytes) } // NewInvoiceFromRequest constructs a new Invoice by copying (mirroring) all diff --git a/bolt12/invoice_error.go b/bolt12/invoice_error.go index 80bcd72774..c882658105 100644 --- a/bolt12/invoice_error.go +++ b/bolt12/invoice_error.go @@ -47,12 +47,12 @@ func (ie *InvoiceError) allRecordProducers() []tlv.RecordProducer { // 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. +// NOTE: 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 { + if err := validateInvoiceErrorWrite(ie); err != nil { return nil, fmt.Errorf("validate invoice error: %w", err) } diff --git a/bolt12/invoice_request.go b/bolt12/invoice_request.go index 65a3f035dd..d5e96b781a 100644 --- a/bolt12/invoice_request.go +++ b/bolt12/invoice_request.go @@ -150,10 +150,10 @@ func (ir *InvoiceRequest) allRecordProducers() []tlv.RecordProducer { return p } -// Encode validates the invoice request per writer requirements and serialises +// encode validates the invoice request per writer requirements and serialises // it via the PureTLVMessage shape. -func (ir *InvoiceRequest) Encode() ([]byte, error) { - if err := ValidateInvoiceRequestWrite(ir); err != nil { +func (ir *InvoiceRequest) encode() ([]byte, error) { + if err := validateInvoiceRequestWrite(ir); err != nil { return nil, fmt.Errorf("validate invoice request: %w", err) } @@ -165,6 +165,33 @@ func (ir *InvoiceRequest) Encode() ([]byte, error) { return buf.Bytes(), nil } +// EncodeSigned serialises an invoice request that is ready to leave the node. +// It requires the signature the writer requirements make mandatory and +// verifies it against invreq_payer_id. +// +// encode stays permissive about the signature because signing does not need +// it: SignInvoiceRequest derives the Merkle root from the records, so a caller +// never has to encode first. EncodeSigned is the entry point for bytes that +// reach a peer, so it is where the writer-side MUST is enforced. An invoice +// request travels as raw TLV inside an onion message, so that boundary is not +// the bech32 string form. +func (ir *InvoiceRequest) EncodeSigned() ([]byte, error) { + if !ir.Signature.IsSome() { + return nil, ErrMissingSignature + } + + tlvBytes, err := ir.encode() + if err != nil { + return nil, err + } + + if err := verifyInvoiceRequest(ir); err != nil { + return nil, err + } + + return tlvBytes, nil +} + // DecodeInvoiceRequest deserializes an invoice request from a TLV byte stream. // Decoding is permissive: callers that need spec compliance must run // ValidateInvoiceRequestRead. @@ -244,7 +271,7 @@ func DecodeInvoiceRequest(data []byte) (*InvoiceRequest, error) { func DecodeInvoiceRequestString(s string, activeChain [32]byte) (*InvoiceRequest, error) { - hrp, tlvBytes, err := Decode(s) + hrp, tlvBytes, err := decodeBech32(s) if err != nil { return nil, fmt.Errorf("bech32: %w", err) } @@ -269,25 +296,14 @@ func DecodeInvoiceRequestString(s string, } // 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. +// string representation (lnr1...). func EncodeInvoiceRequestString(ir *InvoiceRequest) (string, error) { - if !ir.Signature.IsSome() { - return "", ErrMissingSignature - } - - tlvBytes, err := ir.Encode() + tlvBytes, err := ir.EncodeSigned() if err != nil { return "", err } - if err := VerifyInvoiceRequest(ir); err != nil { - return "", err - } - - return Encode(HRPInvoiceRequest, tlvBytes) + return encodeBech32(HRPInvoiceRequest, tlvBytes) } // NewInvoiceRequestFromOffer constructs a new InvoiceRequest by copying @@ -297,7 +313,7 @@ func EncodeInvoiceRequestString(ir *InvoiceRequest) (string, error) { // Per "MUST copy all fields from the offer (including unknown fields)", the // offer's unknown TLVs are carried via the decodedTLVs sidecar so they are // signed and mirrored into the invoice. Note that because unknown even TLV -// types in the offer would have already been rejected by ValidateOfferRead, any +// types in the offer would have already been rejected by validateOfferRead, any // unknown TLVs mirrored here are guaranteed to be unknown odd TLVs ("it's ok to // be odd") which are safe to ignore and carry forward. // diff --git a/bolt12/invoice_request_test.go b/bolt12/invoice_request_test.go index 83f2cc7a09..28ff819ee7 100644 --- a/bolt12/invoice_request_test.go +++ b/bolt12/invoice_request_test.go @@ -2,6 +2,7 @@ package bolt12 import ( "bytes" + "strings" "testing" "github.com/btcsuite/btcd/btcec/v2" @@ -135,7 +136,7 @@ func TestInvoiceRequestRoundTrip(t *testing.T) { tlv.NewPrimitiveRecord[tlv.TlvType240](sig), ) - encoded, err := ir.Encode() + encoded, err := ir.encode() require.NoError(t, err) require.NotEmpty(t, encoded) @@ -149,7 +150,7 @@ func TestInvoiceRequestRoundTrip(t *testing.T) { ir.decodedTLVs = decoded.decodedTLVs require.Equal(t, ir, decoded) - reencoded, err := decoded.Encode() + reencoded, err := decoded.encode() require.NoError(t, err) require.Equal(t, encoded, reencoded) } @@ -227,7 +228,7 @@ func TestNewInvoiceRequestFromOfferMirrorsUnknownFields(t *testing.T) { tlv.NewPrimitiveRecord[tlv.TlvType22](pub), ), } - encoded, err := offer.Encode() + encoded, err := offer.encode() require.NoError(t, err) const unknownType = 33 @@ -319,33 +320,9 @@ func TestDecodeInvoiceRequestString(t *testing.T) { require.Equal(t, "A Mathematical Treatise", string(desc)) } -// 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() - - ir := validInvoiceRequest(t) - - encoded, err := EncodeInvoiceRequestString(ir) - require.NoError(t, err) - require.NotEmpty(t, encoded) - - decoded, err := DecodeInvoiceRequestString( - encoded, bitcoinMainnetGenesisHash, - ) - require.NoError(t, err) - - 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) { +// TestEncodeSignedInvalid asserts EncodeSigned refuses to emit a request +// that fails writer validation. +func TestEncodeSignedInvalid(t *testing.T) { t.Parallel() ir := validInvoiceRequest(t) @@ -353,29 +330,28 @@ func TestEncodeInvoiceRequestStringInvalid(t *testing.T) { tlv.TlvType88, *btcec.PublicKey, ]{} - encoded, err := EncodeInvoiceRequestString(ir) + encoded, err := ir.EncodeSigned() 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) { +// TestEncodeSignedUnsigned asserts EncodeSigned refuses to emit an unsigned +// invoice request. An invoice request reaches a peer as raw TLV, so this is +// the boundary that makes the writer-side signature MUST unavoidable. +func TestEncodeSignedUnsigned(t *testing.T) { t.Parallel() ir := validInvoiceRequest(t) ir.Signature = tlv.OptionalRecordT[tlv.TlvType240, [64]byte]{} - encoded, err := EncodeInvoiceRequestString(ir) + encoded, err := ir.EncodeSigned() 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) { +// TestEncodeSignedInvalidSignature asserts EncodeSigned refuses to emit a +// request whose signature does not verify against invreq_payer_id. +func TestEncodeSignedInvalidSignature(t *testing.T) { t.Parallel() ir := validInvoiceRequest(t) @@ -386,7 +362,36 @@ func TestEncodeInvoiceRequestStringInvalidSignature(t *testing.T) { tlv.NewRecordT[tlv.TlvType82, TUint64](TUint64(2000)), ) - encoded, err := EncodeInvoiceRequestString(ir) + encoded, err := ir.EncodeSigned() require.ErrorIs(t, err, ErrInvalidSignature) require.Empty(t, encoded) } + +// TestEncodeInvoiceRequestString asserts that a signed request round-trips +// through its lnr1 string, and that an unsigned one is refused, as it is in the +// raw TLV form. +func TestEncodeInvoiceRequestString(t *testing.T) { + t.Parallel() + + ir := validInvoiceRequest(t) + + lnr, err := EncodeInvoiceRequestString(ir) + require.NoError(t, err) + require.True(t, strings.HasPrefix(lnr, HRPInvoiceRequest+"1")) + + decoded, err := DecodeInvoiceRequestString( + lnr, bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + + want, err := ir.EncodeSigned() + require.NoError(t, err) + got, err := decoded.EncodeSigned() + require.NoError(t, err) + require.Equal(t, want, got) + + ir.Signature = tlv.OptionalRecordT[tlv.TlvType240, [64]byte]{} + lnr, err = EncodeInvoiceRequestString(ir) + require.ErrorIs(t, err, ErrMissingSignature) + require.Empty(t, lnr) +} diff --git a/bolt12/invoice_string_test.go b/bolt12/invoice_string_test.go new file mode 100644 index 0000000000..649c05b8f9 --- /dev/null +++ b/bolt12/invoice_string_test.go @@ -0,0 +1,51 @@ +package bolt12 + +import ( + "testing" + + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestDecodeInvoiceStringUnvalidated asserts the display entry point skips the +// reader gates but still pins the prefix, so an offer string cannot be read +// back as an invoice. +func TestDecodeInvoiceStringUnvalidated(t *testing.T) { + t.Parallel() + + // An invoice that the reader would reject, here for a chain the node + // does not accept, still decodes for display. + inv := validInvoice(t) + + var altChain [32]byte + for i := range altChain { + altChain[i] = 0xaa + } + inv.InvreqChain = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType80](altChain), + ) + + priv, _ := bobKey() + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240](sig), + ) + + encoded, err := EncodeInvoiceString(inv) + require.NoError(t, err) + + _, err = DecodeInvoiceString( + encoded, farFutureNow(), bitcoinMainnetGenesisHash, + ) + require.ErrorIs(t, err, ErrUnsupportedChain) + + decoded, err := DecodeInvoiceStringUnvalidated(encoded) + require.NoError(t, err) + require.Equal(t, altChain, decoded.InvreqChain.UnwrapOrFailV(t)) + + // The prefix still has to be an invoice one. + offerStr := findTestVector(t, "Minimal bolt12 offer").Bolt12 + _, err = DecodeInvoiceStringUnvalidated(offerStr) + require.ErrorContains(t, err, "expected HRP") +} diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go index e3369b4148..ef3992b8b0 100644 --- a/bolt12/invoice_test.go +++ b/bolt12/invoice_test.go @@ -11,7 +11,7 @@ import ( ) // validInvoice returns an Invoice populated with the minimum set of fields -// required to satisfy ValidateInvoiceWrite. +// required to satisfy validateInvoiceWrite. func validInvoice(t testing.TB) *Invoice { t.Helper() @@ -94,7 +94,7 @@ func TestUsableFallbackAddresses(t *testing.T) { ), } - got := inv.UsableFallbackAddresses() + got := inv.usableFallbackAddresses() require.Len(t, got, 2) require.Equal(t, byte(0), got[0].Version) require.Equal(t, byte(16), got[1].Version) @@ -172,7 +172,7 @@ func TestUsablePaths(t *testing.T) { require.Equal(t, uint32(2), got[1].PayInfo.FeeBaseMsat) // A length mismatch between paths and payinfos yields no usable paths - // (rejected upstream by ValidateInvoiceRead). + // (rejected upstream by validateInvoiceRead). inv.InvoiceBlindedPay = payRecord(BlindedPayInfo{}) require.Empty(t, inv.UsablePaths(known)) } @@ -327,14 +327,14 @@ func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { tlv.NewPrimitiveRecord[tlv.TlvType240](sig), ) - encoded, err := inv.Encode() + encoded, err := inv.encode() require.NoError(t, err) require.NotEmpty(t, encoded) decoded, err := DecodeInvoice(encoded) require.NoError(t, err) - err = ValidateInvoiceRead( + err = validateInvoiceRead( decoded, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{ Invoice: Bolt12Features, @@ -351,7 +351,7 @@ func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { require.Equal(t, inv, decoded) // Re-encode the decoded copy and confirm canonicality. - reencoded, err := decoded.Encode() + reencoded, err := decoded.encode() require.NoError(t, err) require.Equal(t, encoded, reencoded) } @@ -364,7 +364,7 @@ func TestDecodeInvoiceRejectsTruncated(t *testing.T) { t.Parallel() inv := validInvoice(t) - encoded, err := inv.Encode() + encoded, err := inv.encode() require.NoError(t, err) // Chop off the last byte. The truncation lands in the middle of the @@ -450,7 +450,7 @@ func TestNewInvoiceFromRequestMirrorsUnknownFields(t *testing.T) { tlv.NewRecordT[tlv.TlvType82, TUint64](1000), ), } - encoded, err := req.Encode() + encoded, err := req.encode() require.NoError(t, err) // Fill in an unknown odd TLV (type 93, within the invreq signed range @@ -494,7 +494,7 @@ func TestNewInvoiceFromRequestMirrorsUnknownFields(t *testing.T) { } // TestInvoiceEncodeValidationGate verifies that Encode runs -// ValidateInvoiceWrite and rejects invalid invoices. +// validateInvoiceWrite and rejects invalid invoices. func TestInvoiceEncodeValidationGate(t *testing.T) { t.Parallel() @@ -503,7 +503,7 @@ func TestInvoiceEncodeValidationGate(t *testing.T) { tlv.TlvType164, TUint64, ]{} - _, err := inv.Encode() + _, err := inv.encode() require.ErrorIs(t, err, ErrMissingCreatedAt) } @@ -534,9 +534,9 @@ func TestInvoiceStringRoundTrip(t *testing.T) { ) require.NoError(t, err) - originalBytes, err := inv.Encode() + originalBytes, err := inv.encode() require.NoError(t, err) - decodedBytes, err := decoded.Encode() + decodedBytes, err := decoded.encode() require.NoError(t, err) require.Equal(t, originalBytes, decodedBytes) } @@ -563,8 +563,8 @@ func TestEncodeInvoiceStringInvalid(t *testing.T) { } // 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. +// emit an unsigned invoice: the signature is mandatory at every exported +// way out, while encode itself does not require one. func TestEncodeInvoiceStringUnsigned(t *testing.T) { t.Parallel() diff --git a/bolt12/merkle_test.go b/bolt12/merkle_test.go index c66e778e8b..934f3476de 100644 --- a/bolt12/merkle_test.go +++ b/bolt12/merkle_test.go @@ -30,7 +30,7 @@ func TestMerkleRootVectors(t *testing.T) { // Decode the bech32 string to get TLV bytes, // then convert into the record view merkleRoot // consumes. - _, tlvBytes, err := Decode(tc.Bolt12) + _, tlvBytes, err := decodeBech32(tc.Bolt12) require.NoError(t, err) records = streamToRecords(t, tlvBytes) diff --git a/bolt12/offer.go b/bolt12/offer.go index 7405322494..823a141d07 100644 --- a/bolt12/offer.go +++ b/bolt12/offer.go @@ -61,7 +61,8 @@ type Offer struct { // Handled types map to nil; unhandled types map to their value bytes. // Encoding and validation both derive their view from this single field // so they cannot drift apart, and so signed-range extras the decoder - // did not understand are re-emitted on encode and preserve offer_id. + // did not understand are re-emitted on encode and preserve the offer + // hash. decodedTLVs tlv.TypeMap } @@ -96,9 +97,9 @@ func (o *Offer) allRecordProducers() []tlv.RecordProducer { return p } -// Encode serialises the offer into a canonical TLV byte stream. -func (o *Offer) Encode() ([]byte, error) { - if err := ValidateOfferWrite(o); err != nil { +// encode serialises the offer into a canonical TLV byte stream. +func (o *Offer) encode() ([]byte, error) { + if err := validateOfferWrite(o); err != nil { return nil, fmt.Errorf("validate offer: %w", err) } @@ -112,9 +113,9 @@ func (o *Offer) Encode() ([]byte, error) { // decodeOffer parses a TLV byte stream into an Offer. Decoding is permissive — // the spec writer requirements are not enforced here, so callers that need a -// valid offer must run ValidateOfferRead. Unknown TLVs are preserved on the -// returned offer so a later Encode can re-emit signed-range extras and keep -// offer_id stable. +// valid offer must run validateOfferRead. Unknown TLVs are preserved on the +// returned offer so a later encode can re-emit signed-range extras and keep +// offer hash stable. func decodeOffer(data []byte) (*Offer, error) { var o Offer @@ -169,11 +170,11 @@ func decodeOffer(data []byte) (*Offer, error) { // DecodeOfferString decodes a BOLT 12 offer from its bech32 string // representation (lno1...). The spec reader gates (chain, expiry, features) are -// folded in via ValidateOfferRead. +// folded in via validateOfferRead. func DecodeOfferString(s string, now time.Time, activeChain [32]byte) (*Offer, error) { - hrp, tlvBytes, err := Decode(s) + hrp, tlvBytes, err := decodeBech32(s) if err != nil { return nil, fmt.Errorf("bech32: %w", err) } @@ -188,7 +189,7 @@ func DecodeOfferString(s string, now time.Time, return nil, err } - if err := ValidateOfferRead( + if err := validateOfferRead( offer, now, activeChain, Bolt12Features, ); err != nil { return nil, fmt.Errorf("validate: %w", err) @@ -198,12 +199,12 @@ func DecodeOfferString(s string, now time.Time, } // EncodeOfferString encodes an offer to its bech32 string representation -// (lno1...). Writer-side validation is delegated to (*Offer).Encode. +// (lno1...). Writer-side validation is delegated to (*Offer).encode. func EncodeOfferString(o *Offer) (string, error) { - tlvBytes, err := o.Encode() + tlvBytes, err := o.encode() if err != nil { return "", err } - return Encode(HRPOffer, tlvBytes) + return encodeBech32(HRPOffer, tlvBytes) } diff --git a/bolt12/offer_hash.go b/bolt12/offer_hash.go new file mode 100644 index 0000000000..821f540227 --- /dev/null +++ b/bolt12/offer_hash.go @@ -0,0 +1,39 @@ +package bolt12 + +import ( + "crypto/sha256" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" +) + +// OfferHash returns the offer hash a BOLT 12 message carries: the SHA-256 of +// its records in the offer TLV ranges. An offer hashes to its own offer hash, +// and an invoice_request or an invoice hashes to the offer hash of the offer it +// mirrors, so a receiver can find the offer a request answers. +// +// The hash covers a range rather than a set of known fields, so an unknown TLV +// in the offer range changes the hash. That is what makes a store lookup by +// offer hash the exact-match check the reader requirements ask for. +// +// The offer hash is a local store key, not an interop value: BOLT 12 defines +// no offer identifier, so each implementation picks its own. This construction +// is the one Core Lightning picked for its offer_id, and the two values agree +// byte for byte, which is why the Merkle root already in this package is not +// used here. LDK and eclair derive their offer id from that root instead, so +// those values differ for the same offer. +func OfferHash(m lnwire.PureTLVMessage) ([32]byte, error) { + var records []tlv.Record + for _, r := range m.AllRecords() { + if offerAllowedRange(r.Type()) { + records = append(records, r) + } + } + + encoded, err := lnwire.EncodeRecords(records) + if err != nil { + return [32]byte{}, err + } + + return sha256.Sum256(encoded), nil +} diff --git a/bolt12/offer_hash_test.go b/bolt12/offer_hash_test.go new file mode 100644 index 0000000000..be2136ffa6 --- /dev/null +++ b/bolt12/offer_hash_test.go @@ -0,0 +1,132 @@ +package bolt12 + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestOfferHashMatchesOfferEncoding asserts that for an offer the offer hash is +// the hash of its whole encoding. Every offer TLV already sits in the hashed +// ranges, so the range filter is a no-op there. +func TestOfferHashMatchesOfferEncoding(t *testing.T) { + t.Parallel() + + vec := findTestVector(t, "Minimal bolt12 offer") + _, tlvBytes, err := decodeBech32(vec.Bolt12) + require.NoError(t, err) + + offer, err := decodeOffer(tlvBytes) + require.NoError(t, err) + + encoded, err := offer.encode() + require.NoError(t, err) + + id, err := OfferHash(offer) + require.NoError(t, err) + require.Equal(t, sha256.Sum256(encoded), id) +} + +// TestOfferHashCoversUnknownOfferRangeTLVs asserts that an unknown TLV in the +// offer range changes the id. A caller that rebuilt the offer from its known +// fields instead would compute the same id for both requests and accept a +// request whose offer fields differ from the offer. +func TestOfferHashCoversUnknownOfferRangeTLVs(t *testing.T) { + t.Parallel() + + _, pub := bobKey() + pubBytes := pub.SerializeCompressed() + + // Build two invoice requests that differ only by an unknown odd TLV in + // the offer range. + request := func(withUnknown bool) *InvoiceRequest { + var buf bytes.Buffer + appendRawRecord(t, &buf, 0, []byte("meta")) + appendRawRecord(t, &buf, 10, []byte("coffee")) + if withUnknown { + appendRawRecord(t, &buf, 13, []byte{0xde, 0xad}) + } + appendRawRecord(t, &buf, 22, pubBytes) + appendRawRecord(t, &buf, 88, pubBytes) + + ir, err := DecodeInvoiceRequest(buf.Bytes()) + require.NoError(t, err) + + return ir + } + + plain, err := OfferHash(request(false)) + require.NoError(t, err) + + withUnknown, err := OfferHash(request(true)) + require.NoError(t, err) + + require.NotEqual(t, plain, withUnknown) +} + +// TestOfferHashSkipsFieldsOutsideTheOfferRange asserts an invoice request +// mirroring an offer hashes to that offer's id, which is the lookup a receiver +// performs. The filter drops the payer's own fields, so a signature or an +// invreq field cannot move the id. +func TestOfferHashSkipsFieldsOutsideTheOfferRange(t *testing.T) { + t.Parallel() + + vec := findTestVector(t, "Minimal bolt12 offer") + _, tlvBytes, err := decodeBech32(vec.Bolt12) + require.NoError(t, err) + + offer, err := decodeOffer(tlvBytes) + require.NoError(t, err) + + want, err := OfferHash(offer) + require.NoError(t, err) + + priv, pub := bobKey() + ir, err := NewInvoiceRequestFromOffer( + offer, pub, []byte("meta"), bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + + ir.InvreqAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType82, TUint64](TUint64(1000)), + ) + _, err = SignInvoiceRequest(ir, priv) + require.NoError(t, err) + + got, err := OfferHash(ir) + require.NoError(t, err) + require.Equal(t, want, got) +} + +// TestOfferHashMatchesCoreLightning pins the offer hash against the offer Core +// Lightning publishes in its own RPC schema, together with the offer_id it +// reports for it. +func TestOfferHashMatchesCoreLightning(t *testing.T) { + t.Parallel() + + // Offer and expected offer_id taken from Core Lightning's + // doc/schemas/offer.json. + const ( + clnOffer = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlr" + + "m9deu7xyfzrcgqgn3qzs2ge5hx6pqwdskcefpzcssxwz9sqk" + + "jtd8qwnx06lxckvu6g8w8t0ue0zsrfqqygj636s4sw7v6" + + clnOfferHash = "c5cde0292d56941940f8b10a4c9bdd1f8846d6041a61b" + + "3f7e0f87e105aa88121" + ) + + _, tlvBytes, err := decodeBech32(clnOffer) + require.NoError(t, err) + + offer, err := decodeOffer(tlvBytes) + require.NoError(t, err) + + id, err := OfferHash(offer) + require.NoError(t, err) + + require.Equal(t, clnOfferHash, hex.EncodeToString(id[:])) +} diff --git a/bolt12/offer_test.go b/bolt12/offer_test.go index 509d5e34d0..9746e7265a 100644 --- a/bolt12/offer_test.go +++ b/bolt12/offer_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/hex" "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/lightningnetwork/lnd/lnwire" @@ -86,7 +87,7 @@ func TestOfferRoundTrip(t *testing.T) { decodedTLVs: tlv.TypeMap{13: []byte{0xde, 0xad}}, } - encoded, err := o.Encode() + encoded, err := o.encode() require.NoError(t, err) require.NotEmpty(t, encoded) @@ -100,7 +101,7 @@ func TestOfferRoundTrip(t *testing.T) { o.decodedTLVs = decoded.decodedTLVs require.Equal(t, o, decoded) - reencoded, err := decoded.Encode() + reencoded, err := decoded.encode() require.NoError(t, err) require.Equal(t, encoded, reencoded) } @@ -140,7 +141,7 @@ func TestDecodeMinimalOfferString(t *testing.T) { offerStr := "lno1zcss9mk8y3wkklfvevcrszlmu23kfrxh49p" + "x20665dqwmn4p72pksese" - _, tlvBytes, err := Decode(offerStr) + _, tlvBytes, err := decodeBech32(offerStr) require.NoError(t, err) offer, err := decodeOffer(tlvBytes) @@ -165,7 +166,7 @@ func TestDecodeMinimalOfferString(t *testing.T) { hex.EncodeToString(issuerKey.SerializeCompressed())) // Re-encode and verify bytes match. - reencoded, err := offer.Encode() + reencoded, err := offer.encode() require.NoError(t, err) require.Equal(t, tlvBytes, reencoded) } @@ -278,3 +279,206 @@ func TestOfferStringRoundTrip(t *testing.T) { hex.EncodeToString(id2.SerializeCompressed()), ) } + +// TestOfferPaymentFlow simulates a payment for an offer, one step per party. +// The payee publishes an offer as an lno1 string, the payer answers it with a +// signed invoice request, and the payee replies with an invoice that the payer +// checks before it pays. +func TestOfferPaymentFlow(t *testing.T) { + t.Parallel() + + payeeKey, payeePub := aliceKey() + payerKey, payerPub := bobKey() + now := time.Unix(1234567890, 0).Add(time.Minute) + + // Payee: create the offer and publish it as an lno1 string, for + // example in a QR code. offer_issuer_id names the node that will sign + // the invoice. + offer := &Offer{ + OfferAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType8, TUint64](TUint64(1000)), + ), + OfferDescription: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType10]( + tlv.Blob("coffee"), + ), + ), + OfferIssuerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22](payeePub), + ), + } + lno, err := EncodeOfferString(offer) + require.NoError(t, err) + offerHash, err := OfferHash(offer) + require.NoError(t, err) + + // Payer: read the offer and mirror its fields into a request under a + // transient payer key. + scanned, err := DecodeOfferString(lno, now, bitcoinMainnetGenesisHash) + require.NoError(t, err) + req, err := NewInvoiceRequestFromOffer( + scanned, payerPub, []byte("unpredictable"), + bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + + // Payer: sign the request and send the raw TLV in an onion message to + // the offer's node, with a reply path for the invoice. + reqSig, err := SignInvoiceRequest(req, payerKey) + require.NoError(t, err) + req.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240](reqSig), + ) + reqWire, err := req.EncodeSigned() + require.NoError(t, err) + + // Payee: read the request, gate it, and find the offer it answers. The + // request mirrors the offer's fields, so it carries the same offer + // hash. + received, err := DecodeInvoiceRequest(reqWire) + require.NoError(t, err) + require.NoError(t, ValidateInvoiceRequestRead( + received, bitcoinMainnetGenesisHash, Bolt12Features, + )) + receivedHash, err := OfferHash(received) + require.NoError(t, err) + require.Equal(t, offerHash, receivedHash) + + // Payee: answer over the reply path with an invoice for the offer's + // amount, signed with the key offer_issuer_id names. + tmpl := validInvoice(t) + inv := NewInvoiceFromRequest(received) + inv.InvoiceCreatedAt = tmpl.InvoiceCreatedAt + inv.InvoicePaymentHash = tmpl.InvoicePaymentHash + inv.InvoicePaths = tmpl.InvoicePaths + inv.InvoiceBlindedPay = tmpl.InvoiceBlindedPay + inv.InvoiceAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170]( + offer.OfferAmount.ValOpt().UnwrapOr(0), + ), + ) + inv.InvoiceNodeID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176](payeePub), + ) + invSig, err := SignInvoice(inv, payeeKey) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](invSig), + ) + invWire, err := inv.EncodeSigned() + require.NoError(t, err) + + // Payer: read the invoice and check it against its own request. The + // node id to expect comes from the offer's offer_issuer_id. + reply, err := DecodeInvoice(invWire) + require.NoError(t, err) + features := InvoiceKnownFeatures{ + Invoice: Bolt12Features, + Blinded: Bolt12Features, + } + issuerID := scanned.OfferIssuerID.ValOpt().UnwrapOr(nil) + require.NoError(t, ValidateInvoiceForPayment( + reply, req, now, bitcoinMainnetGenesisHash, features, issuerID, + )) + + // Payer: pay over the invoice's blinded paths. + require.NotEmpty(t, reply.UsablePaths(Bolt12Features)) +} + +// TestOfferlessPaymentFlow simulates a payment for an invoice request that +// answers no offer, one step per party. The payer publishes a signed request as +// an lnr1 string, the payee reads it and answers with an invoice, and the payer +// checks that invoice before it pays. +func TestOfferlessPaymentFlow(t *testing.T) { + t.Parallel() + + payerKey, payerPub := bobKey() + payeeKey, payeePub := aliceKey() + + // Payer: build the request. It carries no offer_issuer_id and no + // offer_paths. The payer's own key and the amount it will pay take + // the place of an offer. + req := &InvoiceRequest{ + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + tlv.Blob("unpredictable"), + ), + ), + OfferDescription: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType10]( + tlv.Blob("refund"), + ), + ), + InvreqAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType82, TUint64](TUint64(1000)), + ), + InvreqPayerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](payerPub), + ), + } + + // Payer: sign with the invreq_payer_id key and publish the request as + // an lnr1 string, for example in a QR code. + reqSig, err := SignInvoiceRequest(req, payerKey) + require.NoError(t, err) + req.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240](reqSig), + ) + lnr, err := EncodeInvoiceRequestString(req) + require.NoError(t, err) + + // Payee: read the scanned string. The reader gates run here, so a + // request without a valid signature stops at this step. + scanned, err := DecodeInvoiceRequestString( + lnr, bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + + // Payee: answer with an invoice for the requested amount, signed with + // its node key. It sends the encoded invoice in an onion message to + // invreq_paths, or to invreq_payer_id when there are none. + tmpl := validInvoice(t) + inv := NewInvoiceFromRequest(scanned) + inv.InvoiceCreatedAt = tmpl.InvoiceCreatedAt + inv.InvoicePaymentHash = tmpl.InvoicePaymentHash + inv.InvoicePaths = tmpl.InvoicePaths + inv.InvoiceBlindedPay = tmpl.InvoiceBlindedPay + inv.InvoiceAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170]( + scanned.InvreqAmount.ValOpt().UnwrapOr(0), + ), + ) + inv.InvoiceNodeID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176](payeePub), + ) + invSig, err := SignInvoice(inv, payeeKey) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](invSig), + ) + wire, err := inv.EncodeSigned() + require.NoError(t, err) + + // Payer: read the invoice and check it against the request it + // published. It confirmed no payee key out of band, so it passes nil. + received, err := DecodeInvoice(wire) + require.NoError(t, err) + now := time.Unix(1234567890, 0).Add(time.Minute) + features := InvoiceKnownFeatures{ + Invoice: Bolt12Features, + Blinded: Bolt12Features, + } + require.NoError(t, ValidateInvoiceForPayment( + received, req, now, bitcoinMainnetGenesisHash, features, nil, + )) + + // Payer: pay over the invoice's blinded paths. + require.NotEmpty(t, received.UsablePaths(Bolt12Features)) + + // A payer that did confirm a key out of band still rejects an invoice + // that another node signed. + require.ErrorIs(t, ValidateInvoiceForPayment( + received, req, now, bitcoinMainnetGenesisHash, features, + payerPub, + ), ErrUnexpectedInvoiceNodeID) +} diff --git a/bolt12/signature.go b/bolt12/signature.go index c4dda72418..56f7e6e202 100644 --- a/bolt12/signature.go +++ b/bolt12/signature.go @@ -25,7 +25,7 @@ const ( tagFieldSignature = "signature" ) -// ErrInvalidSignature is returned by VerifyInvoice and VerifyInvoiceRequest +// 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") @@ -92,6 +92,15 @@ func SignInvoiceRequest(ir *InvoiceRequest, privKey *btcec.PrivateKey) ( return [64]byte{}, ErrNilPrivateKey } + // A signature over a message that breaks the writer requirements is + // worthless: the peer rejects it on read. Refusing here keeps a key + // from signing bytes no correct reader accepts. + if err := validateInvoiceRequestWrite(ir); err != nil { + return [64]byte{}, fmt.Errorf( + "validate invoice request: %w", err, + ) + } + root, err := merkleRoot(signableTLVs(ir.AllRecords())) if err != nil { return [64]byte{}, err @@ -102,9 +111,9 @@ func SignInvoiceRequest(ir *InvoiceRequest, privKey *btcec.PrivateKey) ( ) } -// VerifyInvoiceRequest verifies the signature on an invoice request using its +// verifyInvoiceRequest verifies the signature on an invoice request using its // invreq_payer_id public key. -func VerifyInvoiceRequest(ir *InvoiceRequest) error { +func verifyInvoiceRequest(ir *InvoiceRequest) error { pubKey, err := ir.InvreqPayerID.UnwrapOrErrV(ErrMissingPayerID) if err != nil { return err @@ -136,6 +145,13 @@ func SignInvoice(inv *Invoice, privKey *btcec.PrivateKey) ([64]byte, error) { return [64]byte{}, ErrNilPrivateKey } + // A signature over a message that breaks the writer requirements is + // worthless: the peer rejects it on read. Refusing here keeps a key + // from signing bytes no correct reader accepts. + if err := validateInvoiceWrite(inv); err != nil { + return [64]byte{}, fmt.Errorf("validate invoice: %w", err) + } + root, err := merkleRoot(signableTLVs(inv.AllRecords())) if err != nil { return [64]byte{}, err @@ -144,9 +160,9 @@ func SignInvoice(inv *Invoice, privKey *btcec.PrivateKey) ([64]byte, error) { return signMessage(tagMsgInvoice, tagFieldSignature, root, privKey) } -// VerifyInvoice verifies the signature on an invoice using its invoice_node_id +// verifyInvoice verifies the signature on an invoice using its invoice_node_id // public key. -func VerifyInvoice(inv *Invoice) error { +func verifyInvoice(inv *Invoice) error { pubKey, err := inv.InvoiceNodeID.UnwrapOrErrV(ErrMissingNodeID) if err != nil { return err diff --git a/bolt12/signature_test.go b/bolt12/signature_test.go index f7f438e6b9..e0b1174da0 100644 --- a/bolt12/signature_test.go +++ b/bolt12/signature_test.go @@ -54,7 +54,7 @@ func verifyInvoiceRequestSigVector(t *testing.T, tc sigTestVector, // Decode the bech32 string and convert the TLV bytes into the record // view merkleRoot consumes. - _, tlvBytes, err := Decode(tc.Bolt12) + _, tlvBytes, err := decodeBech32(tc.Bolt12) require.NoError(t, err) records := streamToRecords(t, tlvBytes) @@ -128,7 +128,7 @@ func TestVerifyInvoiceRequestVector(t *testing.T) { } require.NotEmpty(t, tc.Bolt12) - hrp, tlvBytes, err := Decode(tc.Bolt12) + hrp, tlvBytes, err := decodeBech32(tc.Bolt12) require.NoError(t, err) require.Equal(t, "lnr", hrp) @@ -145,7 +145,7 @@ func TestVerifyInvoiceRequestVector(t *testing.T) { tlv.NewPrimitiveRecord[tlv.TlvType240](sig), ) - require.NoError(t, VerifyInvoiceRequest(ir)) + require.NoError(t, verifyInvoiceRequest(ir)) } // TestSignatureVerifyRejectsTampering asserts that every way a malicious @@ -311,7 +311,7 @@ func TestNilKeyGuards(t *testing.T) { } } -// TestVerifyInvoiceDirect drives VerifyInvoice end to end using a minimal valid +// TestVerifyInvoiceDirect drives verifyInvoice end to end using a minimal valid // Invoice constructed via validInvoice. func TestVerifyInvoiceDirect(t *testing.T) { t.Parallel() @@ -333,7 +333,7 @@ func TestVerifyInvoiceDirect(t *testing.T) { { name: "valid round-trip verifies", mutate: func(t *testing.T, inv *Invoice) { - _, err := inv.Encode() + _, err := inv.encode() require.NoError(t, err) sig, err := SignInvoice(inv, priv) @@ -389,7 +389,7 @@ func TestVerifyInvoiceDirect(t *testing.T) { ) tc.mutate(t, inv) - err := VerifyInvoice(inv) + err := verifyInvoice(inv) require.ErrorIs(t, err, tc.wantErr) if tc.wantContains != "" { require.Contains( @@ -400,7 +400,7 @@ func TestVerifyInvoiceDirect(t *testing.T) { } } -// TestVerifyInvoiceRequestDirect drives VerifyInvoiceRequest end to end using a +// TestVerifyInvoiceRequestDirect drives verifyInvoiceRequest end to end using a // minimal valid InvoiceRequest constructed via validInvoiceRequest. func TestVerifyInvoiceRequestDirect(t *testing.T) { t.Parallel() @@ -476,7 +476,7 @@ func TestVerifyInvoiceRequestDirect(t *testing.T) { ) tc.mutate(t, ir) - err := VerifyInvoiceRequest(ir) + err := verifyInvoiceRequest(ir) require.ErrorIs(t, err, tc.wantErr) if tc.wantContains != "" { require.Contains( diff --git a/bolt12/subtypes.go b/bolt12/subtypes.go index 33f99a038f..4e5c30a5d8 100644 --- a/bolt12/subtypes.go +++ b/bolt12/subtypes.go @@ -66,28 +66,23 @@ const ( // 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. +// same check one subtype level down. The encoder passes through to lnwire's +// features record, which already writes the minimal form, so no second copy of +// the encoding exists to drift from it. func strictFeaturesRecord[T tlv.TlvType]( t *tlv.RecordT[T, lnwire.RawFeatureVector]) tlv.Record { + lnwireRec := t.Val.Record() + return tlv.MakeDynamicRecord( - t.TlvType(), &t.Val, - func() uint64 { return uint64(t.Val.SerializeSize()) }, - strictFeaturesEncoder, strictFeaturesDecoder, + t.TlvType(), &t.Val, lnwireRec.Size, + func(w io.Writer, _ any, _ *[8]byte) error { + return lnwireRec.Encode(w) + }, + 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. diff --git a/bolt12/validate.go b/bolt12/validate.go index e305f181e6..8f06973eb7 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -141,10 +141,10 @@ var ( ErrInvalidBip353Name = errors.New("invalid invreq_bip_353_name") // 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. + // is emitted or verified without a populated signature TLV. Signing + // reads the struct directly, so encode never needs to run first and + // is free to serialise an unsigned message. EncodeSigned and the + // string encoders are where the signature becomes mandatory. ErrMissingSignature = errors.New("missing signature") // ErrOfferFieldsOnSpontaneous is returned when an invoice request @@ -176,14 +176,14 @@ var ( // ErrMissingPaths is returned when invoice_paths is absent. ErrMissingPaths = errors.New("missing invoice_paths") - // ErrNoUsablePaths is returned by ValidateInvoiceRead when every + // ErrNoUsablePaths is returned by validateInvoiceRead when every // blinded path in invoice_paths carries unknown required features in // payinfo. ErrNoUsablePaths = errors.New( "no blinded paths with known required features", ) - // ErrInvoiceExpired is returned by ValidateInvoiceExpiry when the + // ErrInvoiceExpired is returned by validateInvoiceExpiry when the // caller's clock is past invoice_created_at + invoice_relative_expiry // (default 7200 seconds when relative expiry is absent). ErrInvoiceExpired = errors.New("invoice has expired") @@ -276,11 +276,11 @@ const ( invoiceErrorErrorType tlv.Type = 5 ) -// ValidateInvoiceErrorWrite validates an invoice_error per the BOLT 12 writer +// validateInvoiceErrorWrite validates an invoice_error per the BOLT 12 writer // requirements. The checks follow the spec's writer section in order. The // caller must check that the suggested value, if present, contains a valid // type. -func ValidateInvoiceErrorWrite(ie *InvoiceError) error { +func validateInvoiceErrorWrite(ie *InvoiceError) error { // - MUST set error to an explanatory string. if !ie.Error.IsSome() { return ErrMissingError @@ -371,14 +371,14 @@ func isKnownInvreqTLVType(typ tlv.Type) bool { } } -// ValidateInvoiceRequestWrite ensures an invoice request adheres to the BOLT 12 +// validateInvoiceRequestWrite ensures an invoice request adheres to the BOLT 12 // writer requirements. // // Note: This writer validation assumes that for requests responding to an // offer, the caller/constructor has already mirrored the offer's fields exactly // by using the NewInvoiceRequestFromOffer constructor, as an invoice request // can also be created without an offer. -func ValidateInvoiceRequestWrite(ir *InvoiceRequest) error { +func validateInvoiceRequestWrite(ir *InvoiceRequest) error { // A present-but-nil pubkey passes IsSome but would panic the codec on // encode, so reject both pubkey fields. if err := checkPubKeyNotNil( @@ -415,10 +415,11 @@ func ValidateInvoiceRequestWrite(ir *InvoiceRequest) error { } // - MUST set signature.sig using the invreq_payer_id. - // NOT CHECKED HERE: signing happens after this validator runs; - // 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. + // NOT CHECKED HERE: signing reads the struct, not the encoded + // bytes, so encode does not require a signature and an + // unsigned request passes it and this validator. EncodeSigned + // is the exported gate: it requires the signature and + // verifies it. The reader verifies correctness too. // - MUST set invreq_payer_id to a transient public key. // NOT CHECKED HERE: only presence is checked below; the caller @@ -784,7 +785,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. - return VerifyInvoiceRequest(ir) + return verifyInvoiceRequest(ir) } // getInvoiceRequestOfferChains returns the chains an invoice request's mirrored @@ -959,24 +960,21 @@ func isKnownOfferTLVType(typ tlv.Type) bool { } } -// ValidateOfferRead validates an offer per the BOLT 12 offer reader +// validateOfferRead validates an offer per the BOLT 12 offer reader // requirements. The now parameter is used for expiry checks and can be // overridden in tests. activeChain is required: per spec, absent offer_chains // defaults to Bitcoin mainnet, and the reader must reject offers that do not // list a chain it operates on. Pass the genesis hash of the chain the receiver // is willing to settle on. -func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte, +func validateOfferRead(o *Offer, now time.Time, activeChain [32]byte, knownFeatures map[lnwire.FeatureBit]string) error { - // A present-but-nil offer_issuer_id passes IsSome but would panic the - // codec on encode, so reject it here. - if err := checkPubKeyNotNil( - o.OfferIssuerID, "offer_issuer_id", - ); err != nil { - return err - } - // Check TLV types are in allowed range and that unknown even types are - // rejected (even = must-understand). + // - if the offer contains any TLV fields outside the inclusive ranges: + // 1 to 79 and 1000000000 to 1999999999: + // - MUST NOT respond to the offer. + // + // BOLT 1 adds that an unknown even type is must-understand. An offer + // carries no signature, so no range is exempt from either rule. for _, t := range sortedTypes(o.decodedTLVs) { if !offerAllowedRange(t) { return fmt.Errorf("%w: type %d", ErrOutOfRangeType, t) @@ -987,12 +985,28 @@ func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte, } } - // Check for unknown even feature bits. + // - if offer_features contains unknown odd bits that are non-zero: + // - MUST ignore the bit. + // - if offer_features contains unknown even bits that are non-zero: + // - MUST NOT respond to the offer. + // - SHOULD indicate the unknown bit to the user. + // NOT CHECKED HERE: surfacing the bit to a user is the caller's, and + // the error names the offending bit for it. if err := checkFeatures(o.OfferFeatures, knownFeatures); err != nil { return err } - // offer_chains present but empty. + // - if offer_chains is not set: + // - if the node does not accept bitcoin invoices: + // - MUST NOT respond to the offer + // - otherwise (offer_chains is set): + // - if the node does not accept invoices for at least one of the + // chains: + // - MUST NOT respond to the offer + // + // A present-but-empty offer_chains lists no chain the node could + // accept, so it is rejected before the comparison. getOfferChains + // normalises an absent field to Bitcoin mainnet. var chainsEmpty bool o.OfferChains.WhenSome( func(r tlv.RecordT[tlv.TlvType2, ChainsRecord]) { @@ -1005,44 +1019,63 @@ func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte, return ErrEmptyChains } - // Validate the offer's chain against the active chain. An absent - // offer_chains TLV means "Bitcoin mainnet" per spec, normalised by - // getOfferChains. - offerChains := getOfferChains(o) - found := slices.Contains(offerChains, activeChain) - if !found { + if !slices.Contains(getOfferChains(o), activeChain) { return ErrUnsupportedChain } - // offer_amount set requires offer_description. + // - if offer_amount is set and offer_description is not set: + // - MUST NOT respond to the offer. + // - if offer_amount is set and is not greater than zero: + // - MUST NOT respond to the offer. + // - if offer_currency is set and offer_amount is not set: + // - MUST NOT respond to the offer. hasAmount := o.OfferAmount.IsSome() if hasAmount && !o.OfferDescription.IsSome() { return ErrMissingDescription } - // offer_amount, if set, must be strictly greater than zero. if err := checkAmountPositive(o.OfferAmount); err != nil { return err } - // offer_currency requires offer_amount. if o.OfferCurrency.IsSome() && !hasAmount { return ErrCurrencyWithoutAmount } - // Must have either offer_issuer_id or offer_paths. + // - if neither offer_issuer_id nor offer_paths are set: + // - MUST NOT respond to the offer. + // + // A present-but-nil key passes IsSome but would panic the codec when + // used, so reject it before the presence rule. + if err := checkPubKeyNotNil( + o.OfferIssuerID, "offer_issuer_id", + ); err != nil { + return err + } + if !o.OfferIssuerID.IsSome() && !o.OfferPaths.IsSome() { return ErrNoIssuerIdentity } - // Check blinded paths have at least one hop. + // - if num_hops is 0 in any blinded_path in offer_paths: + // - MUST NOT respond to the offer. if err := checkBlindedPaths(o.OfferPaths); err != nil { return err } - // Expiry check. A present-but-zero offer_absolute_expiry is as a valid - // timestamp in the past, it doesn't have the special meaning of "no - // expiry". + // - if it uses offer_amount to provide the user with a cost estimate: + // - MUST take into account the currency units for offer_amount. + // - MUST warn the user if the received invoice_amount differs + // significantly from that estimate. + // NOT CHECKED HERE: the estimate and the warning belong to the caller, + // and a non-bitcoin currency needs an exchange rate the codec has no + // source for. + + // - if the current time is after offer_absolute_expiry: + // - MUST NOT respond to the offer. + // + // A present-but-zero offer_absolute_expiry is a valid timestamp in the + // past. It has no special "never expires" meaning. var ( expiry uint64 hasExpiry bool @@ -1057,7 +1090,14 @@ func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte, return ErrOfferExpired } - // Validate UTF-8 fields. + // - if it chooses to send an invoice request, it sends an onion + // message via offer_paths when set, otherwise to offer_issuer_id. + // NOT CHECKED HERE: sending is the caller's, and the path it used is + // the binding the payer later checks against invoice_node_id. + + // The spec states no encoding rule for the text fields, but a field + // that is not valid UTF-8 cannot be shown to a user or compared, and + // offer_currency has to parse as an ISO 4217 code to mean anything. if err := checkUTF8(o.OfferCurrency, "offer_currency"); err != nil { return err } @@ -1098,51 +1138,35 @@ func getOfferChains(o *Offer) [][32]byte { return chains } -// ValidateOfferWrite validates an offer per the BOLT 12 offer writer -// requirements. -func ValidateOfferWrite(o *Offer) error { - // A present-but-nil offer_issuer_id passes IsSome but would panic the - // codec on encode, so reject it here. - if err := checkPubKeyNotNil( - o.OfferIssuerID, "offer_issuer_id", - ); err != nil { - return err - } - - // Writer MUST NOT set TLV fields outside allowed ranges. This check - // catches a decoded-then-mutated offer: a freshly-built struct has no - // decodedTLVs (Decode is the only writer of that field). The typed - // field set already excludes out-of-range types by construction, so a - // freshly-built offer cannot violate the range rule in the first place. +// validateOfferWrite validates an offer per the BOLT 12 offer writer +// requirements, in the order the spec states them. +func validateOfferWrite(o *Offer) error { + // - MUST NOT set any TLV fields outside the inclusive ranges: 1 to 79 + // and 1000000000 to 1999999999. + // + // BOLT 1 adds that an unknown even type is must-understand. Both + // checks catch a decoded-then-mutated offer: a freshly-built struct + // has no decodedTLVs (Decode is the only writer of that field), and + // the typed field set cannot express an out-of-range or unknown type. + // The reader applies the same two rules in this order. for _, t := range sortedTypes(o.decodedTLVs) { if !offerAllowedRange(t) { return fmt.Errorf("%w: type %d", ErrOutOfRangeType, t) } - } - - // offer_amount requires offer_description. - if o.OfferAmount.IsSome() && !o.OfferDescription.IsSome() { - return ErrMissingDescription - } - - // offer_amount, if set, must be strictly greater than zero. - if err := checkAmountPositive(o.OfferAmount); err != nil { - return err - } - - // offer_currency requires offer_amount. - if o.OfferCurrency.IsSome() && !o.OfferAmount.IsSome() { - return ErrCurrencyWithoutAmount - } - // Without offer_paths, MUST set offer_issuer_id. - if !o.OfferPaths.IsSome() && !o.OfferIssuerID.IsSome() { - return ErrNoIssuerIdentity + if !isKnownOfferTLVType(t) && t%2 == 0 { + return fmt.Errorf("%w: type %d", ErrUnknownEvenType, t) + } } - // Defense in depth: writer-side mirrors of reader rejections for - // present-but-empty offer_chains and offer_paths. + // - if the chain for the invoice is not solely bitcoin: + // - MUST specify offer_chains the offer is valid for. + // - otherwise: + // - SHOULD omit offer_chains, implying that bitcoin is only chain. + // NOT CHECKED HERE: which chain the writer settles on is caller + // context. A present-but-empty offer_chains says nothing, so mirror + // the reader and reject it. var chainsEmpty bool o.OfferChains.WhenSome( func(r tlv.RecordT[tlv.TlvType2, ChainsRecord]) { @@ -1155,12 +1179,96 @@ func ValidateOfferWrite(o *Offer) error { return ErrEmptyChains } + // - if a specific minimum offer_amount is required for successful + // payment: + // - MUST set offer_amount to the amount expected (per item). + // - MUST set offer_amount greater than zero. + // - if the currency for offer_amount is that of all entries in + // chains: + // - MUST specify offer_amount in multiples of the minimum + // lightning-payable unit. + // - otherwise: + // - MUST specify offer_currency iso4217 as an ISO 4217 + // three-letter code. + // - MUST specify offer_amount in the currency unit adjusted by the + // ISO 4217 exponent. + // - MUST set offer_description to a complete description of the + // purpose of the payment. + // - otherwise: + // - MUST NOT set offer_amount + // - MUST NOT set offer_currency + // - MAY set offer_description + // NOT CHECKED HERE: the unit of a bitcoin amount is trivially + // satisfied in msat, and the ISO 4217 exponent needs the currency's + // own scale. + if err := checkAmountPositive(o.OfferAmount); err != nil { + return err + } + + if o.OfferAmount.IsSome() && !o.OfferDescription.IsSome() { + return ErrMissingDescription + } + + if o.OfferCurrency.IsSome() && !o.OfferAmount.IsSome() { + return ErrCurrencyWithoutAmount + } + + if err := checkISO4217(o.OfferCurrency); err != nil { + return err + } + + // - MAY set offer_metadata for its own use. + // - if it supports bolt12 offer features: + // - MUST set offer_features.features to the bitmap of bolt12 + // features. + // - if the offer expires: + // - MUST set offer_absolute_expiry seconds_from_epoch. + // NOT CHECKED HERE: all three are the writer's own decisions, with no + // state the codec could contradict. + + // - if it is connected only by private channels: + // - MUST include offer_paths containing one or more paths to the + // node from publicly reachable nodes. + // - otherwise: + // - MAY include offer_paths. + // NOT CHECKED HERE: connectivity is caller context. A path with no + // hops cannot carry a message, so mirror the reader and reject it. if err := checkBlindedPaths(o.OfferPaths); err != nil { return err } - // Defense in depth: writer-side mirrors of the reader UTF-8 checks - // for offer_currency, offer_description, and offer_issuer. + // - if it includes offer_paths: + // - MAY set offer_issuer_id. + // - otherwise: + // - MUST set offer_issuer_id to the node's public key to request the + // invoice from. + // + // A present-but-nil key passes IsSome but would panic the codec on + // encode, so reject it before the presence rule. + if err := checkPubKeyNotNil( + o.OfferIssuerID, "offer_issuer_id", + ); err != nil { + return err + } + + if !o.OfferPaths.IsSome() && !o.OfferIssuerID.IsSome() { + return ErrNoIssuerIdentity + } + + // - if it sets offer_issuer: + // - SHOULD set it to identify the issuer of the invoice clearly. + // - if it can supply more than one item for a single invoice: + // - MUST set offer_quantity_max, and MUST NOT set it to 0 when the + // maximum is known. + // - otherwise: + // - MUST NOT set offer_quantity_max. + // NOT CHECKED HERE: both describe the writer's own inventory and + // naming, which the codec cannot see. offer_quantity_max carries a + // three-state meaning (absent, zero for unlimited, a bound), so no + // value of it is invalid on its own. + + // Defense in depth: the reader rejects a non-UTF-8 text field, so the + // writer does not emit one. if err := checkUTF8(o.OfferCurrency, "offer_currency"); err != nil { return err } @@ -1175,10 +1283,6 @@ func ValidateOfferWrite(o *Offer) error { return err } - if err := checkISO4217(o.OfferCurrency); err != nil { - return err - } - return nil } @@ -1308,12 +1412,12 @@ func checkInvoiceNodeID(inv *Invoice) error { return nil } -// ValidateInvoiceWrite validates an invoice per the BOLT 12 invoice writer +// validateInvoiceWrite validates an invoice per the BOLT 12 invoice writer // requirements. The checks follow the spec's writer section in order. // Requirements that depend on context this codec layer does not have // (signing, the payment preimage, the offer or path the request arrived on) // are noted inline as deferred to the caller or to a paired validator. -func ValidateInvoiceWrite(inv *Invoice) error { +func validateInvoiceWrite(inv *Invoice) error { // - MUST set invoice_created_at to the number of seconds since Midnight // 1 January 1970, UTC when the invoice was created. if !inv.InvoiceCreatedAt.IsSome() { @@ -1331,7 +1435,7 @@ func ValidateInvoiceWrite(inv *Invoice) error { // ("minimum amount it will accept"), but a zero-amount HTLC cannot // settle past the channel-layer dust limit. The typed // ErrZeroInvoiceAmount lets a spec-strict caller distinguish this from - // a missing-field violation. Symmetric with ValidateInvoiceRead. + // a missing-field violation. Symmetric with validateInvoiceRead. if inv.InvoiceAmount.ValOpt().UnwrapOr(0) == 0 { return ErrZeroInvoiceAmount } @@ -1346,7 +1450,7 @@ func ValidateInvoiceWrite(inv *Invoice) error { // this validator runs on the assembled struct. The invoice_amount == // invreq_amount equality and the byte-for-byte field mirror are // enforced when the invoice is paired with its request in - // ValidateInvoiceAgainstRequest. The offer_currency "expected amount" + // validateInvoiceAgainstRequest. The offer_currency "expected amount" // needs a live exchange rate the codec cannot compute. // - MUST set invoice_payment_hash to the SHA256 hash of the @@ -1383,11 +1487,11 @@ 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; - // 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. + // NOT CHECKED HERE: signing reads the struct, not the encoded bytes, + // so encode does not require a signature and an unsigned invoice + // passes it and this validator. EncodeSigned is the exported gate: + // it requires the signature and verifies it, mirroring + // validateInvoiceRequestWrite. The reader verifies correctness too. // - if the expiry for accepting payment is not 7200 seconds after // invoice_created_at: MUST set invoice_relative_expiry. @@ -1395,7 +1499,7 @@ func ValidateInvoiceWrite(inv *Invoice) error { // invoice_created_at that payment should not be attempted. // NOT CHECKED HERE: the writer chooses the expiry, so there is no rule // to enforce on the encoded value. The time comparison needs a clock - // (see ValidateInvoiceExpiry). + // (see validateInvoiceExpiry). // - if it accepts onchain payments: // - MAY specify invoice_fallbacks. @@ -1406,7 +1510,7 @@ func ValidateInvoiceWrite(inv *Invoice) error { // program. // NOT CHECKED HERE: the codec stays permissive so callers can inspect // raw fallbacks. The spec's ignore semantics are applied on the read - // side by UsableFallbackAddresses. + // side by usableFallbackAddresses. // - MUST include invoice_paths containing one or more paths to the // node. @@ -1446,7 +1550,7 @@ func ValidateInvoiceWrite(inv *Invoice) error { // A present-but-nil pubkey passes IsSome but would panic the codec on // encode, so reject the mirrored pubkey fields. Symmetric with - // ValidateInvoiceRequestWrite. + // validateInvoiceRequestWrite. if err := fn.MapOptionZ(inv.InvreqPayerID.ValOpt(), func(pk *btcec.PublicKey) error { if pk == nil { @@ -1477,16 +1581,17 @@ func ValidateInvoiceWrite(inv *Invoice) error { // omits invoice_relative_expiry: two hours from creation. const defaultInvoiceRelativeExpiry uint32 = 7200 -// ValidateInvoiceExpiry rejects an invoice whose effective expiry is strictly +// validateInvoiceExpiry rejects an invoice whose effective expiry is strictly // before now. The effective expiry is invoice_created_at + // invoice_relative_expiry, falling back to a 7200-second default per spec when // relative expiry is absent. Per the BOLT 12 reader the invoice is rejected // only when the current time is greater than the expiry, so the boundary second -// itself is still valid; this matches the strict comparison ValidateOfferRead -// uses for offer_absolute_expiry. Callers must invoke this separately after -// decoding. ValidateInvoiceRead covers the structural reader requirements, but -// the time check needs a clock the codec library doesn't supply. -func ValidateInvoiceExpiry(inv *Invoice, now time.Time) error { +// itself is still valid; this matches the strict comparison validateOfferRead +// uses for offer_absolute_expiry. validateInvoiceRead cannot make the check, +// because the time comparison needs a clock the codec does not supply, so the +// exported entry points fold this in: DecodeInvoiceString for the string form +// and ValidateInvoiceForPayment for an invoice answering a request. +func validateInvoiceExpiry(inv *Invoice, now time.Time) error { createdAt, err := inv.InvoiceCreatedAt.ValOpt().UnwrapOrErr( ErrMissingCreatedAt, ) @@ -1512,18 +1617,30 @@ func ValidateInvoiceExpiry(inv *Invoice, now time.Time) error { // 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. +// state: offer_issuer_id, or the final blinded_node_id of the path it chose. +// None of that is derivable from the invoice, so validateInvoiceRead cannot +// make the comparison. ValidateInvoiceForPayment folds it in, as it does 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, +// Skipping it is not cosmetic for a response to an offer. 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, req *InvoiceRequest, expectedNodeID *btcec.PublicKey) error { + // - otherwise (invoice_request without an offer): + // - MAY reject the invoice if it cannot confirm that invoice_node_id + // is correct, out-of-band. + // + // The payer published the invoice request and never addressed the + // payee, so it has a key to compare against only if it learned one out + // of band. Without one it passes nil. + isOfferResponse := req.OfferIssuerID.IsSome() || req.OfferPaths.IsSome() + if expectedNodeID == nil && !isOfferResponse { + return nil + } + if expectedNodeID == nil { return fmt.Errorf("%w: expected invoice_node_id", ErrNilPublicKey) @@ -1565,7 +1682,7 @@ func mirroredRecordBytes(records []tlv.Record) (map[tlv.Type][]byte, error) { return out, nil } -// ValidateInvoiceAgainstRequest performs a byte-for-byte comparison of the +// validateInvoiceAgainstRequest performs a byte-for-byte comparison of the // fields in ranges 0-159 and 1000000000-2999999999 between an invoice and its // original request, as required by the BOLT 12 invoice reader specification. // Callers must invoke this after pairing the invoice with its originating @@ -1582,7 +1699,7 @@ func mirroredRecordBytes(records []tlv.Record) (map[tlv.Type][]byte, error) { // offer_amount * invreq_quantity for the native (bitcoin) case. The // offer_currency case needs a caller-supplied exchange rate and is delegated to // the caller. -func ValidateInvoiceAgainstRequest(inv *Invoice, req *InvoiceRequest) error { +func validateInvoiceAgainstRequest(inv *Invoice, req *InvoiceRequest) error { reqFields, err := mirroredRecordBytes(req.AllRecords()) if err != nil { return fmt.Errorf("encode request fields: %w", err) @@ -1710,7 +1827,7 @@ type InvoiceKnownFeatures struct { Blinded map[lnwire.FeatureBit]string } -// ValidateInvoiceRead validates an invoice against the BOLT 12 reader +// validateInvoiceRead validates an invoice against the BOLT 12 reader // requirements, running the stateless structural checks against activeChain // (the chain the reader supports). The final check is cryptographic: the // reader rejects an invoice whose BIP-340 Schnorr signature does not verify @@ -1720,14 +1837,14 @@ type InvoiceKnownFeatures struct { // 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, +func validateInvoiceRead(inv *Invoice, activeChain [32]byte, features InvoiceKnownFeatures) error { // - MUST reject the invoice if invoice_amount is not present. if !inv.InvoiceAmount.IsSome() { return ErrMissingAmount } - // Policy extension. See ValidateInvoiceWrite. + // Policy extension. See validateInvoiceWrite. if inv.InvoiceAmount.ValOpt().UnwrapOr(0) == 0 { return ErrZeroInvoiceAmount } @@ -1772,16 +1889,11 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // - MUST reject the invoice. // checkFeatures enforces those invoice_features bit rules below. // - // Separately, BOLT 1 makes unknown even TLV types must-understand, so - // reject those here over the decoded type set. Unlike the - // invoice_request reader, the invoice reader defines no out-of-range - // type rejection, so unknown odd types are simply ignored ("it's ok to - // be odd"). The signature range (240-1000) is exempt for the same - // reason, matching the invoice_request reader and the Merkle path. + // Separately, BOLT 1 makes unknown even TLV types must-understand. + // The invoice reader defines no out-of-range rule, so an unknown odd + // type is ignored at any value and an unknown even type is rejected at + // any value, the signature range (240-1000) included. for _, t := range sortedTypes(inv.decodedTLVs) { - if bolt12InUnsignedRange(t) { - continue - } if !isKnownInvoiceTLVType(t) && t%2 == 0 { return fmt.Errorf("%w: type %d", ErrUnknownEvenType, t) } @@ -1798,7 +1910,8 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // - MUST reject the invoice if the current time since 1970-01-01 UTC // is greater than invoice_created_at plus 7200. // NOT CHECKED HERE: the comparison needs a clock the codec doesn't - // supply. Callers run ValidateInvoiceExpiry separately. + // supply. DecodeInvoiceString and ValidateInvoiceForPayment fold in + // validateInvoiceExpiry, which makes the check. // - MUST reject the invoice if invoice_paths is not present or is // empty. @@ -1849,7 +1962,7 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // The offer_issuer_id case is checked here by checkInvoiceNodeID (both // 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 + // enforced by validateInvoiceAgainstRequest once the invoice is paired // 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 { @@ -1871,38 +1984,40 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // - the invreq_paths / blinded-path / reply_path arrival rules. // NOT CHECKED HERE: these are payment-time or transport concerns // handled outside this codec. invreq_amount equality is enforced by - // ValidateInvoiceAgainstRequest; the fallback ignore rules by - // UsableFallbackAddresses. + // validateInvoiceAgainstRequest; the fallback ignore rules by + // usableFallbackAddresses. // - MUST reject the invoice if signature is not a valid signature using // invoice_node_id as described in Signature Calculation. - return VerifyInvoice(inv) + 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. +// expectedNodeID is the node the payer expects to have signed the invoice, and +// is compared against invoice_node_id. It is offer_issuer_id for an offer that +// carried one, and the final blinded_node_id on the path the payer chose for an +// offer that carried offer_paths. For a request that answers no offer it is a +// key the payer confirmed out of band, or nil when it has none. Only that case +// accepts nil. For a response to an offer a nil expectedNodeID returns +// ErrNilPublicKey. func ValidateInvoiceForPayment(inv *Invoice, req *InvoiceRequest, now time.Time, activeChain [32]byte, features InvoiceKnownFeatures, expectedNodeID *btcec.PublicKey) error { - if err := ValidateInvoiceRead(inv, activeChain, features); err != nil { + if err := validateInvoiceRead(inv, activeChain, features); err != nil { return err } - if err := ValidateInvoiceExpiry(inv, now); err != nil { + if err := validateInvoiceExpiry(inv, now); err != nil { return err } - if err := ValidateInvoiceAgainstRequest(inv, req); err != nil { + if err := validateInvoiceAgainstRequest(inv, req); err != nil { return err } - return validateInvoiceNodeID(inv, expectedNodeID) + return validateInvoiceNodeID(inv, req, expectedNodeID) } diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index 1f05216f2c..f7ed86b935 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -139,7 +139,7 @@ func TestValidateOfferWrite(t *testing.T) { wantErr: ErrInvalidCurrency, }, { - // Pins the docstring claim that ValidateOfferWrite's + // Pins the docstring claim that validateOfferWrite's // offerAllowedRange loop exists to catch a // decoded-then-mutated offer with an out-of-range TLV // resurfacing via decodedTLVs. @@ -151,6 +151,18 @@ func TestValidateOfferWrite(t *testing.T) { }, wantErr: ErrOutOfRangeType, }, + { + // Same path as above, but for the must-understand + // rule: type 24 is in range and unknown to the offer + // schema, so the writer refuses to re-emit it. + name: "unknown even TLV in decoded extras", + mutate: func(o *Offer) { + o.decodedTLVs = tlv.TypeMap{ + 24: nil, + } + }, + wantErr: ErrUnknownEvenType, + }, { name: "empty blinded paths list", mutate: func(o *Offer) { @@ -221,7 +233,7 @@ func TestValidateOfferWrite(t *testing.T) { o := validBobOffer(t) tc.mutate(o) - err := ValidateOfferWrite(o) + err := validateOfferWrite(o) if tc.wantErr == nil { require.NoError(t, err) @@ -677,7 +689,7 @@ func TestValidateOfferRead(t *testing.T) { o := validBobOffer(t) tc.mutate(o) - err := ValidateOfferRead( + err := validateOfferRead( o, now, tc.activeChain, tc.known, ) if tc.wantErr == nil { @@ -750,7 +762,7 @@ func signedInvoiceRequest(t testing.TB, ), } - encoded, err := ir.Encode() + encoded, err := ir.encode() require.NoError(t, err) decoded, err := DecodeInvoiceRequest(encoded) @@ -825,7 +837,7 @@ func flipValueByte(t *testing.T, encoded, needle []byte) []byte { } // TestValidateReadRejectsBadSignature pins the reader-side signature gate on -// both message types. ValidateInvoiceRequestRead and ValidateInvoiceRead key +// both message types. ValidateInvoiceRequestRead and validateInvoiceRead key // the check on different public keys, so covering one does not cover the // other. // @@ -857,7 +869,7 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { validate: func(t *testing.T) error { encoded, err := signedInvoiceRequest( t, bobPriv, - ).Encode() + ).encode() require.NoError(t, err) // invreq_metadata is a signed opaque blob, so @@ -878,7 +890,7 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { validate: func(t *testing.T) error { inv := signedInvoice(t, alicePriv) - return ValidateInvoiceRead( + return validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -889,7 +901,7 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { validate: func(t *testing.T) error { signed := signedInvoice(t, bobPriv) - encoded, err := signed.Encode() + encoded, err := signed.encode() require.NoError(t, err) // invoice_payment_hash is a signed fixed-width @@ -903,7 +915,7 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { )) require.NoError(t, err) - return ValidateInvoiceRead( + return validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -922,42 +934,70 @@ 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. +// answer, which is state only the payer holds. A request that answers no +// offer is compared only against a key the payer confirmed out of band. func TestValidateInvoiceNodeID(t *testing.T) { t.Parallel() _, alicePub := aliceKey() _, bobPub := bobKey() + // An offer response carries offer_issuer_id or offer_paths, and a + // request that answers no offer carries neither. + offerResponse := &InvoiceRequest{ + OfferIssuerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22](bobPub), + ), + } + offerless := &InvoiceRequest{} + tests := []struct { name string + req *InvoiceRequest nodeID fn.Option[*btcec.PublicKey] expected *btcec.PublicKey wantErr error }{ { name: "matches the path's final node", + req: offerResponse, nodeID: fn.Some(bobPub), expected: bobPub, }, { name: "another node on the path impersonates", + req: offerResponse, nodeID: fn.Some(alicePub), expected: bobPub, wantErr: ErrUnexpectedInvoiceNodeID, }, { name: "invoice_node_id absent", + req: offerResponse, nodeID: fn.None[*btcec.PublicKey](), expected: bobPub, wantErr: ErrMissingNodeID, }, { name: "caller supplies no final node", + req: offerResponse, nodeID: fn.Some(bobPub), expected: nil, wantErr: ErrNilPublicKey, }, + { + name: "offerless request, no key confirmed", + req: offerless, + nodeID: fn.Some(alicePub), + expected: nil, + }, + { + name: "offerless request, confirmed key differs", + req: offerless, + nodeID: fn.Some(alicePub), + expected: bobPub, + wantErr: ErrUnexpectedInvoiceNodeID, + }, } for _, tc := range tests { @@ -976,7 +1016,7 @@ func TestValidateInvoiceNodeID(t *testing.T) { ) }) - err := validateInvoiceNodeID(inv, tc.expected) + err := validateInvoiceNodeID(inv, tc.req, tc.expected) if tc.wantErr == nil { require.NoError(t, err) return @@ -1135,7 +1175,7 @@ func TestValidateInvoiceForPayment(t *testing.T) { // 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( + require.NoError(t, validateInvoiceRead( forgedDecoded, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, )) @@ -1360,7 +1400,7 @@ func TestValidateInvoiceRequestWrite(t *testing.T) { tc.mutate(ir) - err := ValidateInvoiceRequestWrite(ir) + err := validateInvoiceRequestWrite(ir) if tc.wantErr == nil { require.NoError(t, err) return @@ -1404,14 +1444,14 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { ir := baseRequest() // Absent invreq_amount -> invalid. - err := ValidateInvoiceRequestWrite(ir) + err := validateInvoiceRequestWrite(ir) require.ErrorIs(t, err, ErrMissingAmount) // Present invreq_amount -> valid. ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](1000), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) // 2. Responding to an offer. @@ -1433,14 +1473,14 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { ir := baseResponseRequest() // InvreqAmount absent -> invalid. - err := ValidateInvoiceRequestWrite(ir) + err := validateInvoiceRequestWrite(ir) require.ErrorIs(t, err, ErrMissingAmount) // InvreqAmount present -> valid. ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](1000), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) // Case B: OfferAmount present, OfferCurrency absent (Bitcoin). @@ -1457,7 +1497,7 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { ) // InvreqAmount is optional (MAY omit it). - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // If set, it MUST be >= OfferAmount * Quantity // (1000 * 2 = 2000). InvreqAmount < expected -> @@ -1465,14 +1505,14 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](1999), ) - err := ValidateInvoiceRequestWrite(ir) + err := validateInvoiceRequestWrite(ir) require.ErrorIs(t, err, ErrAmountBelowExpected) // InvreqAmount >= expected -> valid. ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](2000), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) // Case C: OfferAmount present, OfferCurrency present @@ -1497,11 +1537,11 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { // InvreqAmount < OfferAmount * Quantity is allowed // because currency conversion is checked dynamically // at runtime, not statically inside - // ValidateInvoiceRequestWrite. + // validateInvoiceRequestWrite. ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](100), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) }) } @@ -1541,7 +1581,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { } // Absent chain is OK. - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // Bitcoin chain is OK. ir.InvreqChain = tlv.SomeRecordT( @@ -1549,13 +1589,13 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { bitcoinMainnetGenesisHash, ), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // Non-bitcoin chain is OK. ir.InvreqChain = tlv.SomeRecordT( tlv.NewPrimitiveRecord[tlv.TlvType80](testnetHash), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) // 2. Responding to an offer. @@ -1590,7 +1630,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ir := baseRequest() // InvreqChain absent (valid, defaults to bitcoin). - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain == bitcoin (valid). ir.InvreqChain = tlv.SomeRecordT( @@ -1598,7 +1638,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { bitcoinMainnetGenesisHash, ), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain != bitcoin (invalid). ir.InvreqChain = tlv.SomeRecordT( @@ -1607,7 +1647,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ), ) require.ErrorIs( - t, ValidateInvoiceRequestWrite(ir), + t, validateInvoiceRequestWrite(ir), ErrUnsupportedChain, ) }) @@ -1625,7 +1665,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ) // InvreqChain absent (valid, defaults to bitcoin). - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain == bitcoin (valid). ir.InvreqChain = tlv.SomeRecordT( @@ -1633,7 +1673,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { bitcoinMainnetGenesisHash, ), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain == testnet (invalid, not in offer // chains). @@ -1643,7 +1683,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ), ) require.ErrorIs( - t, ValidateInvoiceRequestWrite(ir), + t, validateInvoiceRequestWrite(ir), ErrUnsupportedChain, ) @@ -1659,7 +1699,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { // InvreqChain absent (invalid, defaults to bitcoin // which is not in offer chains). require.ErrorIs( - t, ValidateInvoiceRequestWrite(ir), + t, validateInvoiceRequestWrite(ir), ErrUnsupportedChain, ) @@ -1669,7 +1709,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { testnetHash, ), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain == regtest (invalid, not in offer // chains). @@ -1679,7 +1719,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ), ) require.ErrorIs( - t, ValidateInvoiceRequestWrite(ir), + t, validateInvoiceRequestWrite(ir), ErrUnsupportedChain, ) }) @@ -2049,7 +2089,7 @@ func TestValidateInvoiceRequestAmountOverflow(t *testing.T) { require.ErrorIs(t, readErr, ErrAmountBelowExpected) // The writer MUST reject it too (same rule, both sides). - writeErr := ValidateInvoiceRequestWrite(newRequest()) + writeErr := validateInvoiceRequestWrite(newRequest()) require.ErrorIs(t, writeErr, ErrAmountBelowExpected) } @@ -2096,7 +2136,7 @@ func TestValidateInvoiceAmountOverflow(t *testing.T) { tlv.NewRecordT[tlv.TlvType170](TUint64(0)), ) - err := ValidateInvoiceAgainstRequest(inv, req) + err := validateInvoiceAgainstRequest(inv, req) require.ErrorIs(t, err, ErrAmountBelowExpected) } @@ -2274,7 +2314,7 @@ func encodeInvBypassValidate(inv *Invoice) ([]byte, error) { } // TestValidateInvoiceRead table-drives every reader-side rejection in -// ValidateInvoiceRead. +// validateInvoiceRead. func TestValidateInvoiceRead(t *testing.T) { t.Parallel() @@ -2464,7 +2504,7 @@ func TestValidateInvoiceRead(t *testing.T) { inv := baseline() tc.mutate(inv) - err := ValidateInvoiceRead( + err := validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -2473,10 +2513,10 @@ func TestValidateInvoiceRead(t *testing.T) { } } -// TestValidateInvoiceReadAcceptsSignatureRange pins the rule that an unknown -// odd TLV anywhere in the signature range (240-1000) is ignored rather than -// rejected. -func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) { +// TestValidateInvoiceReadSignatureRange pins both rules for the signature +// range (240-1000): an unknown odd TLV there is ignored, an unknown even one +// is rejected. +func TestValidateInvoiceReadSignatureRange(t *testing.T) { t.Parallel() priv, pub := bobKey() @@ -2536,11 +2576,21 @@ func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) { tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), ) - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) require.NoError(t, err) + + // An unknown even type in the signature range is must-understand: + // the range is exempt from the out-of-range rule only. + inv.decodedTLVs = tlv.TypeMap{242: nil} + + err = validateInvoiceRead( + inv, bitcoinMainnetGenesisHash, + InvoiceKnownFeatures{}, + ) + require.ErrorIs(t, err, ErrUnknownEvenType) } // TestValidateInvoiceExpiry covers the relative-expiry default, an explicit @@ -2629,7 +2679,7 @@ func TestValidateInvoiceExpiry(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := ValidateInvoiceExpiry( + err := validateInvoiceExpiry( tc.inv, time.Unix(tc.now, 0), ) if tc.wantErr != nil { @@ -2754,7 +2804,7 @@ func TestValidateInvoiceAgainstRequest(t *testing.T) { invDecoded, err := DecodeInvoice(invEncoded) require.NoError(t, err) - err = ValidateInvoiceAgainstRequest( + err = validateInvoiceAgainstRequest( invDecoded, irDecoded, ) if tc.wantErr == nil { @@ -2809,12 +2859,12 @@ func TestValidateInvoiceAgainstRequestAmountMirror(t *testing.T) { // Equal amounts pass. matchEnc, _ := encodeInvBypassValidate(build(2500)) matchDec, _ := DecodeInvoice(matchEnc) - require.NoError(t, ValidateInvoiceAgainstRequest(matchDec, irDecoded)) + require.NoError(t, validateInvoiceAgainstRequest(matchDec, irDecoded)) // Mismatched amounts fail. missEnc, _ := encodeInvBypassValidate(build(2501)) missDec, _ := DecodeInvoice(missEnc) - err = ValidateInvoiceAgainstRequest(missDec, irDecoded) + err = validateInvoiceAgainstRequest(missDec, irDecoded) require.ErrorIs(t, err, ErrInvoiceMismatch) require.Contains(t, err.Error(), "invoice_amount") } @@ -2898,7 +2948,7 @@ func TestValidateInvoiceAgainstRequestOfferAmount(t *testing.T) { invDec, err := DecodeInvoice(invEnc) require.NoError(t, err) - return ValidateInvoiceAgainstRequest(invDec, irDec) + return validateInvoiceAgainstRequest(invDec, irDec) } qty := func(v uint64) *uint64 { return &v } @@ -2947,7 +2997,7 @@ func TestValidateInvoiceAgainstRequestOfferAmount(t *testing.T) { } // TestValidateInvoiceWrite table-drives the writer-side checks of -// ValidateInvoiceWrite by clearing required fields on a valid baseline invoice. +// validateInvoiceWrite by clearing required fields on a valid baseline invoice. func TestValidateInvoiceWrite(t *testing.T) { t.Parallel() @@ -3093,7 +3143,7 @@ func TestValidateInvoiceWrite(t *testing.T) { inv := validInvoice(t) tc.mutate(inv) - err := ValidateInvoiceWrite(inv) + err := validateInvoiceWrite(inv) if tc.wantErr == nil { require.NoError(t, err) } else { @@ -3109,7 +3159,7 @@ func TestValidateInvoiceWrite(t *testing.T) { func TestValidateFeaturesKnownBits(t *testing.T) { t.Parallel() - // Role 1 validation verifies endpoint features on ValidateInvoiceRead. + // Role 1 validation verifies endpoint features on validateInvoiceRead. t.Run("endpoint features (Role 1)", func(t *testing.T) { t.Parallel() @@ -3131,7 +3181,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { ) // An unknown required bit must be rejected. - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -3141,7 +3191,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { known := map[lnwire.FeatureBit]string{ lnwire.MPPRequired: "mpp", } - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{Invoice: known}, ) @@ -3149,7 +3199,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { }) // Role 2 validation verifies routing path features on - // ValidateInvoiceRead. + // validateInvoiceRead. t.Run("routing path features (Role 2)", func(t *testing.T) { t.Parallel() @@ -3177,7 +3227,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { // With no known feature bits there are zero usable paths, so // we expect ErrNoUsablePaths. - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -3188,7 +3238,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { knownBlinded := map[lnwire.FeatureBit]string{ lnwire.MPPRequired: "mpp", } - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{Blinded: knownBlinded}, ) @@ -3205,13 +3255,13 @@ func TestValidateFeaturesKnownBits(t *testing.T) { tlv.NewRecordT[tlv.TlvType174](fv), ) - require.NoError(t, ValidateInvoiceWrite(inv)) + require.NoError(t, validateInvoiceWrite(inv)) }) } // TestValidateInvoiceWriteRejectsNilPubkeys verifies the writer rejects a // present-but-nil mirrored pubkey field, which would otherwise panic the codec -// on encode. Symmetric with ValidateInvoiceRequestWrite. +// on encode. Symmetric with validateInvoiceRequestWrite. func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) { t.Parallel() @@ -3224,7 +3274,7 @@ func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) { (*btcec.PublicKey)(nil), ), ) - require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey) + require.ErrorIs(t, validateInvoiceWrite(inv), ErrNilPublicKey) }) t.Run("present-but-nil offer_issuer_id", func(t *testing.T) { @@ -3236,7 +3286,7 @@ func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) { (*btcec.PublicKey)(nil), ), ) - require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey) + require.ErrorIs(t, validateInvoiceWrite(inv), ErrNilPublicKey) }) t.Run("present-but-nil node_id", func(t *testing.T) { @@ -3248,7 +3298,7 @@ func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) { (*btcec.PublicKey)(nil), ), ) - require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey) + require.ErrorIs(t, validateInvoiceWrite(inv), ErrNilPublicKey) }) } @@ -3314,7 +3364,7 @@ func TestValidateInvoiceErrorWrite(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := ValidateInvoiceErrorWrite(tc.ie) + err := validateInvoiceErrorWrite(tc.ie) if tc.wantErr == nil { require.NoError(t, err) @@ -3341,7 +3391,7 @@ func TestValidateOfferReadVectors(t *testing.T) { t.Run(tc.Description, func(t *testing.T) { t.Parallel() - _, tlvBytes, bech32Err := Decode(tc.Bolt12) + _, tlvBytes, bech32Err := decodeBech32(tc.Bolt12) if bech32Err != nil { if tc.Valid { require.NoError( @@ -3376,7 +3426,7 @@ func TestValidateOfferReadVectors(t *testing.T) { activeChain = c[0] } - valErr := ValidateOfferRead( + valErr := validateOfferRead( offer, now, activeChain, nil, )