diff --git a/bolt12/bech32.go b/bolt12/bech32.go new file mode 100644 index 0000000000..00aec6b02d --- /dev/null +++ b/bolt12/bech32.go @@ -0,0 +1,320 @@ +package bolt12 + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/btcsuite/btcd/btcutil/bech32" +) + +var ( + // ErrStringTooLong is returned when a string is longer than + // maxBolt12StringLen. It is also 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 + // also returned when a payload has no bytes. + ErrEmptyString = errors.New("empty string") + + // ErrMixedCase is returned when a bech32 string contains both + // uppercase and lowercase characters. + ErrMixedCase = errors.New("string not all lowercase or all uppercase") + + // ErrInvalidSeparator is returned when the '1' separator is missing + // or misplaced. + ErrInvalidSeparator = errors.New("missing or invalid separator") + + // ErrUnsupportedHRP is returned when the human-readable prefix is not + // in validHRPs (lno/lnr/lni). + ErrUnsupportedHRP = errors.New("unsupported HRP") + + // ErrInvalidCharacter is returned when a character outside printable + // ASCII or outside the bech32 charset is encountered. + ErrInvalidCharacter = errors.New("invalid character") + + // ErrInvalidContinuation is returned when '+' placement violates BOLT + // 12 rules. + ErrInvalidContinuation = errors.New("invalid continuation") + + // ErrBaseConversion is returned when base 32 / base 256 conversion + // fails. + ErrBaseConversion = errors.New("base conversion failed") + + // ErrCharConversion is returned when a 5-bit value exceeds the bech32 + // alphabet bounds. + ErrCharConversion = errors.New("char conversion failed") +) + +const ( + // HRPOffer is the human-readable prefix for BOLT 12 offers. + HRPOffer = "lno" + + // HRPInvoiceRequest is the human-readable prefix for BOLT 12 invoice + // requests. + HRPInvoiceRequest = "lnr" + + // HRPInvoice is the human-readable prefix for BOLT 12 invoices. + HRPInvoice = "lni" + + // charset is the set of valid bech32 characters. + charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + // minPrintableASCII is the lower bound for printable ASCII characters + // ('!'). + minPrintableASCII = 33 + + // maxPrintableASCII is the upper bound for printable ASCII characters + // ('~'). + 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. + 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 = 1 << 20 + + // maxBolt12StringLen is the largest BOLT 12 bech32 string the codec + // accepts. 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 +) + +// validHRPs holds the prefixes the BOLT 12 codec accepts, in the order the +// error messages name them. +var validHRPs = []string{HRPOffer, HRPInvoiceRequest, HRPInvoice} + +// isValidHRP tells the caller if hrp is a BOLT 12 prefix. +func isValidHRP(hrp string) bool { + return slices.Contains(validHRPs, hrp) +} + +// unsupportedHRPError reports that hrp is not a BOLT 12 prefix. The message +// names the permitted prefixes from the one list that holds them. +func unsupportedHRPError(hrp string) error { + return fmt.Errorf( + "bolt12: %w %q (want %s)", ErrUnsupportedHRP, hrp, + strings.Join(validHRPs, "/"), + ) +} + +// 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 +// 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) > maxBolt12StringLen { + return "", nil, fmt.Errorf( + "bolt12: %w: input length %d exceeds limit %d", + ErrStringTooLong, len(s), maxBolt12StringLen, + ) + } + + cleaned, err := stripContinuation(s) + if err != nil { + return "", nil, err + } + + if len(cleaned) == 0 { + return "", nil, fmt.Errorf("bolt12: %w", ErrEmptyString) + } + + // The characters must be either all lowercase or all uppercase. + lower := strings.ToLower(cleaned) + if cleaned != lower && cleaned != strings.ToUpper(cleaned) { + return "", nil, fmt.Errorf("bolt12: %w", ErrMixedCase) + } + + cleaned = lower + + // Find the separator. The last '1' separates the HRP from data. + one := strings.LastIndexByte(cleaned, '1') + if one < 1 || one+1 >= len(cleaned) { + return "", nil, fmt.Errorf("bolt12: %w", ErrInvalidSeparator) + } + + hrp := cleaned[:one] + if !isValidHRP(hrp) { + return "", nil, unsupportedHRPError(hrp) + } + dataStr := cleaned[one+1:] + + // Validate and convert each character to its bech32 value. + data5bit, err := toBech32Bytes(dataStr) + if err != nil { + return "", nil, err + } + + // Convert from base32 (5-bit groups) to base256 (8-bit bytes). + data8bit, err := bech32.ConvertBits(data5bit, 5, 8, false) + if err != nil { + return "", nil, fmt.Errorf( + "bolt12: %w: %w", ErrBaseConversion, err, + ) + } + + return hrp, data8bit, nil +} + +// Encode 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) { + hrp = strings.ToLower(hrp) + if !isValidHRP(hrp) { + return "", unsupportedHRPError(hrp) + } + + // 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. + if len(data) == 0 { + return "", fmt.Errorf( + "bolt12: %w: nothing to encode", ErrEmptyString, + ) + } + + if len(data) > maxBolt12DataLen { + return "", fmt.Errorf( + "bolt12: %w: payload length %d exceeds limit %d", + ErrStringTooLong, len(data), maxBolt12DataLen, + ) + } + + // Convert from base256 to base32. + data5bit, err := bech32.ConvertBits(data, 8, 5, true) + if err != nil { + return "", fmt.Errorf("bolt12: %w: %w", ErrBaseConversion, err) + } + + chars, err := toBech32Chars(data5bit) + if err != nil { + return "", fmt.Errorf("bolt12: %w: %w", ErrCharConversion, err) + } + + return hrp + "1" + chars, nil +} + +// stripContinuation removes each '+' marker and the whitespace after it, and +// rejects each byte outside the printable ASCII range. A marker joins two parts +// of one string, so a character that is neither whitespace nor a second marker +// must stand on each side. This rejects a marker at the start or the end, and +// two markers together. +// +// The two characters need not be bech32 characters. The spec does not say what +// to do inside the prefix, and the prefix check and the alphabet scan run after +// this step, so a marker there cannot make an invalid string valid. +func stripContinuation(s string) (string, error) { + var b strings.Builder + b.Grow(len(s)) + + for i := 0; i < len(s); i++ { + c := s[i] + if c != '+' { + if c < minPrintableASCII || c > maxPrintableASCII { + return "", fmt.Errorf( + "bolt12: %w: invalid byte 0x%02x at "+ + "position %d", + ErrInvalidCharacter, c, i, + ) + } + b.WriteByte(c) + + continue + } + + if i == 0 || !isContinuationNeighbour(s[i-1]) { + return "", fmt.Errorf( + "bolt12: %w: '+' must follow a "+ + "non-whitespace character", + ErrInvalidContinuation, + ) + } + + // Skip '+' and any following whitespace. + j := i + 1 + for j < len(s) && isWhitespace(s[j]) { + j++ + } + if j >= len(s) || !isContinuationNeighbour(s[j]) { + return "", fmt.Errorf( + "bolt12: %w: '+' must precede a "+ + "non-whitespace character", + ErrInvalidContinuation, + ) + } + + // Resume at the character the '+' joined to. + i = j - 1 + } + + return b.String(), nil +} + +// isContinuationNeighbour tells the caller if c can stand next to a '+' marker. +// A marker joins string content, so whitespace and a second marker cannot. +func isContinuationNeighbour(c byte) bool { + return c != '+' && !isWhitespace(c) +} + +// isWhitespace tells the caller if c is one of the six ASCII whitespace +// characters: space, tab, line feed, vertical tab, form feed, and carriage +// return. The spec narrows the class nowhere, and this is the set in +// strings.asciiSpace. unicode.IsSpace is the wrong test here, because it also +// accepts the byte 0x85 and the byte 0xA0, which a BOLT 12 string cannot +// hold. +func isWhitespace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\v' || + c == '\f' || c == '\r' +} + +// toBech32Bytes converts a string of bech32 characters to their 5-bit integer +// values. Reported position offsets are relative to the normalized string after +// continuation stripping. +func toBech32Bytes(s string) ([]byte, error) { + result := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + idx := strings.IndexByte(charset, s[i]) + if idx < 0 { + return nil, fmt.Errorf( + "bolt12: %w: invalid character 0x%02x at "+ + "position %d of the cleaned data "+ + "string %s", + ErrInvalidCharacter, s[i], i, s, + ) + } + result[i] = byte(idx) + } + + return result, nil +} + +// toBech32Chars converts 5-bit values to their bech32 character representation. +func toBech32Chars(data []byte) (string, error) { + result := make([]byte, len(data)) + for i, b := range data { + if int(b) >= len(charset) { + return "", fmt.Errorf( + "bolt12: %w: invalid data byte: %d", + ErrCharConversion, b, + ) + } + result[i] = charset[b] + } + + return string(result), nil +} diff --git a/bolt12/bech32_test.go b/bolt12/bech32_test.go new file mode 100644 index 0000000000..1e318c624a --- /dev/null +++ b/bolt12/bech32_test.go @@ -0,0 +1,450 @@ +package bolt12 + +import ( + "math" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// TestBech32FormatStringVectors runs through every test case in the spec's +// format-string-test.json to verify our bech32 encoder/decoder handles +// continuations, case, and edge cases correctly. +func TestBech32FormatStringVectors(t *testing.T) { + t.Parallel() + + vectors := loadFormatStringVectors(t) + require.NotEmpty(t, vectors) + + for _, tc := range vectors { + t.Run(tc.Comment, func(t *testing.T) { + t.Parallel() + + hrp, decoded, err := Decode(tc.String) + + if !tc.Valid { + require.Error(t, err, "expected error for: %s", + tc.Comment) + + return + } + + require.NoError(t, err, "unexpected error for: %s", + tc.Comment) + require.Equal(t, HRPOffer, hrp) + require.NotEmpty(t, decoded) + + // Round-trip: re-encode and decode again. + encoded, err := Encode(hrp, decoded) + require.NoError(t, err) + + hrp2, decoded2, err := Decode(encoded) + require.NoError(t, err) + require.Equal(t, hrp, hrp2) + require.Equal(t, decoded, decoded2) + }) + } +} + +// TestBech32RoundTrip verifies that encoding then decoding returns the original +// data for each supported HRP. +func TestBech32RoundTrip(t *testing.T) { + t.Parallel() + + testData := []byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd} + + for _, hrp := range []string{HRPOffer, HRPInvoiceRequest, HRPInvoice} { + t.Run(hrp, func(t *testing.T) { + t.Parallel() + + encoded, err := Encode(hrp, testData) + require.NoError(t, err) + require.True(t, len(encoded) > len(hrp)+1) + + gotHRP, gotData, err := Decode(encoded) + require.NoError(t, err) + require.Equal(t, hrp, gotHRP) + require.Equal(t, testData, gotData) + }) + } +} + +// TestBech32DecodeErrors verifies that various malformed inputs produce errors. +func TestBech32DecodeErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + }{ + { + name: "empty string", + input: "", + }, + { + name: "no separator", + input: "lnoabcdef", + }, + { + name: "separator only", + input: "1", + }, + { + name: "no data after separator", + input: "lno1", + }, + { + name: "invalid character", + input: "lno1b", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, _, err := Decode(tc.input) + require.Error(t, err) + }) + } +} + +// TestStripContinuation verifies the '+' stripping logic in isolation. +func TestStripContinuation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "no continuation", + input: "lno1acd", + want: "lno1acd", + }, + { + name: "simple continuation", + input: "lno1a+cd", + want: "lno1acd", + }, + { + name: "continuation with whitespace", + input: "lno1a+ cd", + want: "lno1acd", + }, + { + name: "continuation with newline", + input: "lno1a+\ncd", + want: "lno1acd", + }, + { + name: "continuation with crlf and space", + input: "lno1a+\r\n cd", + want: "lno1acd", + }, + { + name: "continuation with vertical tab", + input: "lno1a+\vcd", + want: "lno1acd", + }, + { + name: "continuation with form feed", + input: "lno1a+\fcd", + want: "lno1acd", + }, + { + name: "continuation with every ascii whitespace", + input: "lno1a+ \t\n\v\f\rcd", + want: "lno1acd", + }, + { + name: "trailing plus", + input: "lno1acd+", + wantErr: true, + }, + { + name: "trailing plus with space", + input: "lno1acd+ ", + wantErr: true, + }, + { + name: "leading plus", + input: "+lno1acd", + wantErr: true, + }, + { + name: "leading plus with whitespace", + input: "\n+lno1acd", + wantErr: true, + }, + { + name: "consecutive plus", + input: "lno1a++cd", + wantErr: true, + }, + { + name: "plus joined to plus by whitespace", + input: "lno1a+ +cd", + wantErr: true, + }, + { + name: "plus inside the prefix", + input: "ln+o1pqps7sjq", + want: "lno1pqps7sjq", + }, + { + name: "plus before the separator", + input: "lno+1pqps7sjq", + want: "lno1pqps7sjq", + }, + { + name: "plus after the separator", + input: "lno1+pqps7sjq", + want: "lno1pqps7sjq", + }, + { + name: "plus inside the prefix with whitespace", + input: "ln+\r\n o1pqps7sjq", + want: "lno1pqps7sjq", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := stripContinuation(tc.input) + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestDecodeContinuationAnywhere asserts that a marker at each interior +// position keeps the decoded data the same. The positions include the +// prefix and both sides of the '1' separator. The spec requires removal only +// between two bech32 characters. A writer, however, wraps a line where the +// medium makes it necessary, and the other implementations join anywhere. A +// marker must therefore never change the meaning of a string. +func TestDecodeContinuationAnywhere(t *testing.T) { + t.Parallel() + + payload := []byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef} + encoded, err := Encode(HRPOffer, payload) + require.NoError(t, err) + + for i := 1; i < len(encoded); i++ { + split := encoded[:i] + "+" + encoded[i:] + + hrp, data, err := Decode(split) + require.NoError(t, err, "marker at position %d", i) + require.Equal(t, HRPOffer, hrp) + require.Equal(t, payload, data) + } +} + +// TestEncodeUnknownHRP asserts that Encode takes only the prefixes in +// validHRPs, so a caller cannot make a string that Decode refuses. The message +// must also name each accepted prefix, because the message and the membership +// test read one list. +func TestEncodeUnknownHRP(t *testing.T) { + t.Parallel() + + _, err := Encode("bogus", []byte{0x00}) + require.ErrorIs(t, err, ErrUnsupportedHRP) + + for _, hrp := range validHRPs { + require.Contains(t, err.Error(), hrp) + } +} + +// TestDecodeUnknownHRP asserts that Decode rejects strings with unsupported +// HRPs. +func TestDecodeUnknownHRP(t *testing.T) { + t.Parallel() + + _, _, err := Decode("bogus1pqps7sjq") + require.ErrorIs(t, err, ErrUnsupportedHRP) +} + +// TestDecodeUnprintableCharacter asserts that Decode rejects characters outside +// printable ASCII range (33..126). +func TestDecodeUnprintableCharacter(t *testing.T) { + t.Parallel() + + // The last three characters are whitespace. A string can hold + // whitespace only after a '+' marker. In each other position it is a + // byte below the printable range. + unprintable := []string{ + "l\x1b[31mno1pqps7sjq", + "l\x00no1pqps7sjq", + "ln\no1pqps7sjq", + "ln\vo1pqps7sjq", + "ln\fo1pqps7sjq", + } + + for _, input := range unprintable { + _, _, err := Decode(input) + require.ErrorIs(t, err, ErrInvalidCharacter) + } +} + +// TestDecodeOversizeInput asserts the input length cap fires before any +// allocation. +func TestDecodeOversizeInput(t *testing.T) { + t.Parallel() + + huge := strings.Repeat("a", maxBolt12StringLen+1) + _, _, err := Decode(huge) + require.ErrorIs(t, err, ErrStringTooLong) +} + +// TestHRPLenMatchesBudget asserts the fixed prefix cost that the character +// limit assumes. A prefix longer than bolt12HRPLen would let Encode make one +// more character than Decode accepts. The shared limit exists to prevent this +// difference. +func TestHRPLenMatchesBudget(t *testing.T) { + t.Parallel() + + for _, hrp := range validHRPs { + require.Len(t, hrp, bolt12HRPLen) + } +} + +// TestEncodePayloadSize asserts which payload sizes Encode takes and which it +// rejects. The rows walk the size axis from below the shortest legal payload to +// above the longest, and each accepted row decodes back to its input. The table +// therefore holds both ends of the size contract in one place. +func TestEncodePayloadSize(t *testing.T) { + t.Parallel() + + // maxOfferFields is the payload of an offer that holds a metadata + // field, a description field, and an issuer field, each at the largest + // record the decoder takes. + const maxOfferFields = 3 * (1 + 3 + math.MaxUint16) + + tests := []struct { + name string + payload []byte + wantErr error + + // wantLen, when set, is the exact length of the string that + // Encode must make. + wantLen int + }{ + { + name: "nil payload", + payload: nil, + wantErr: ErrEmptyString, + }, + { + name: "empty payload", + payload: []byte{}, + wantErr: ErrEmptyString, + }, + { + name: "one byte", + payload: make([]byte, 1), + }, + { + name: "three maximal offer fields", + payload: make([]byte, maxOfferFields), + }, + { + name: "longest payload", + payload: make([]byte, maxBolt12DataLen), + wantLen: maxBolt12StringLen, + }, + { + name: "one byte above the longest payload", + payload: make([]byte, maxBolt12DataLen+1), + wantErr: ErrStringTooLong, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + encoded, err := Encode(HRPOffer, tc.payload) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + + return + } + + require.NoError(t, err) + if tc.wantLen != 0 { + require.Len(t, encoded, tc.wantLen) + } + + // Decode takes each string that Encode makes. + hrp, data, err := Decode(encoded) + require.NoError(t, err) + require.Equal(t, HRPOffer, hrp) + require.Equal(t, tc.payload, data) + }) + } +} + +// TestDecodeUppercase pins the spec MUST that readers handle both all-lowercase +// and all-uppercase strings: a payload encoded lowercase, then ToUpper'd in +// transit (e.g. QR code), must decode back to the same HRP and bytes. +func TestDecodeUppercase(t *testing.T) { + t.Parallel() + + payload := []byte{0x01, 0x23, 0x45, 0x67} + encoded, err := Encode(HRPOffer, payload) + require.NoError(t, err) + + uppered := strings.ToUpper(encoded) + require.NotEqual(t, encoded, uppered) + + hrp, data, err := Decode(uppered) + require.NoError(t, err) + require.Equal(t, HRPOffer, hrp) + require.Equal(t, payload, data) +} + +// TestPropertyBech32RoundTrip asserts Encode and Decode form a bijection for +// arbitrary data payloads under each of the three BOLT 12 HRPs. The codec's +// correctness depends on this property. A hand-rolled table can only hit a +// small number of payload sizes, while rapid drives shrinking generators across +// the whole input space and minimizes any counter-example it finds. +func TestPropertyBech32RoundTrip(t *testing.T) { + t.Parallel() + + hrps := []string{HRPOffer, HRPInvoiceRequest, HRPInvoice} + + rapid.Check(t, func(t *rapid.T) { + hrp := hrps[rapid.IntRange(0, len(hrps)-1).Draw(t, "hrp")] + // Draw the payload from the range that both ends of the + // codec accept, because the bijection holds in that range. + // The two limits have their own tests: the empty payload + // in TestEncodeEmptyPayload and the upper limit in + // TestSizeBudgetIsSymmetric. The upper bound here stays + // far below the limit, so rapid works on the content of + // the payload and not on its length. + size := rapid.IntRange(1, 1024).Draw(t, "size") + data := rapid.SliceOfN( + rapid.Byte(), size, size, + ).Draw(t, "data") + + encoded, err := Encode(hrp, data) + require.NoError(t, err) + + decodedHRP, decodedData, err := Decode(encoded) + require.NoError(t, err) + require.Equal(t, hrp, decodedHRP) + require.Equal(t, data, decodedData) + }) +} diff --git a/bolt12/helpers_test.go b/bolt12/helpers_test.go index 78bbdfdffd..a740bb495b 100644 --- a/bolt12/helpers_test.go +++ b/bolt12/helpers_test.go @@ -2,8 +2,17 @@ package bolt12 import ( "bytes" + "encoding/json" + "io" + "os" + "sync" + "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" ) // bobKey returns the deterministic spec test key for Bob, whose 32-byte scalar @@ -22,3 +31,212 @@ func aliceKey() (*btcec.PrivateKey, *btcec.PublicKey) { return priv, pub } + +// formatStringTestVector represents a single test case from the BOLT 12 +// format-string-test.json file. +type formatStringTestVector struct { + Comment string `json:"comment"` + Valid bool `json:"valid"` + String string `json:"string"` +} + +// loadFormatStringVectorsOnce parses format-string-test.json once. +var loadFormatStringVectorsOnce = sync.OnceValues( + func() ([]formatStringTestVector, error) { + data, err := os.ReadFile( + "test-vectors/format-string-test.json", + ) + if err != nil { + return nil, err + } + + var vectors []formatStringTestVector + if err := json.Unmarshal(data, &vectors); err != nil { + return nil, err + } + + return vectors, nil + }, +) + +// loadFormatStringVectors returns the parsed format-string-test.json vectors. +func loadFormatStringVectors(t *testing.T) []formatStringTestVector { + t.Helper() + + vectors, err := loadFormatStringVectorsOnce() + require.NoError(t, err) + + return vectors +} + +// farFutureNow returns a time well past every spec fixture's expiry, so +// structural validation runs without expiry interference. +func farFutureNow() time.Time { + return time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) +} + +// offersTestVector represents a single test case from offers-test.json. +type offersTestVector struct { + Description string `json:"description"` + Valid bool `json:"valid"` + Bolt12 string `json:"bolt12"` + Fields []offersTestField `json:"fields"` +} + +// offersTestField represents an expected TLV field in the test vector. +type offersTestField struct { + Type uint64 `json:"type"` + Length uint64 `json:"length"` + Hex string `json:"hex"` +} + +// loadOffersVectorsOnce parses test-vectors/offers-test.json once and memoizes +// the result for all callers. +var loadOffersVectorsOnce = sync.OnceValues( + func() ([]offersTestVector, error) { + data, err := os.ReadFile("test-vectors/offers-test.json") + if err != nil { + return nil, err + } + + var vectors []offersTestVector + if err := json.Unmarshal(data, &vectors); err != nil { + return nil, err + } + + return vectors, nil + }, +) + +// loadOffersVectors returns the parsed offers-test.json vectors, failing the +// test if the file is unreadable or malformed. +func loadOffersVectors(t *testing.T) []offersTestVector { + t.Helper() + + vectors, err := loadOffersVectorsOnce() + require.NoError(t, err) + + return vectors +} + +// streamToRecords parses an arbitrary TLV byte stream into tlv.Record values +// whose Encode method reproduces the original wire bytes, without going +// through a typed message decoder. +func streamToRecords(t *testing.T, data []byte) []tlv.Record { + t.Helper() + + stream, err := tlv.NewStream() + require.NoError(t, err) + + typeMap, err := stream.DecodeWithParsedTypesP2P(bytes.NewReader(data)) + require.NoError(t, err) + + return lnwire.TlvMapToRecords(typeMap) +} + +// recordFromWireBytes builds a single tlv.Record whose encoding is the +// supplied full TLV byte slice. The slice must be a complete +// type+length+value sequence. Inputs are trusted spec fixtures, so the +// length prefix is allocated without a bound. +func recordFromWireBytes(t *testing.T, full []byte) tlv.Record { + t.Helper() + + var buf [8]byte + r := bytes.NewReader(full) + + typ, err := tlv.ReadVarInt(r, &buf) + require.NoError(t, err) + + length, err := tlv.ReadVarInt(r, &buf) + require.NoError(t, err) + + value := make([]byte, length) + _, err = io.ReadFull(r, value) + require.NoError(t, err) + + return tlv.MakePrimitiveRecord(tlv.Type(typ), &value) +} + +// sigTestVector represents a test case from signature-test.json. +type sigTestVector struct { + Comment string `json:"comment"` + TLV string `json:"tlv"` + Bolt12 string `json:"bolt12"` + + //nolint:tagliatelle // BOLT 12 spec vector key. + FirstTLV string `json:"first-tlv"` + Leaves []json.RawMessage `json:"leaves"` + Branches []json.RawMessage `json:"branches"` + Merkle string `json:"merkle"` + + SignatureTag string `json:"signature_tag"` + Signature string `json:"signature"` +} + +// readSignatureDataOnce reads signature-test.json once so the file is +// parsed only once per test process. +var readSignatureDataOnce = sync.OnceValues(func() ([]byte, error) { + return os.ReadFile("test-vectors/signature-test.json") +}) + +// loadSignatureVectorsOnce parses signature-test.json into typed +// sigTestVectors. The raw-JSON loader is separate because the JSON +// contains a key ("H(signature_tag,merkle)") that cannot be expressed +// via Go struct tags. +var loadSignatureVectorsOnce = sync.OnceValues( + func() ([]sigTestVector, error) { + data, err := readSignatureDataOnce() + if err != nil { + return nil, err + } + + var vectors []sigTestVector + if err := json.Unmarshal(data, &vectors); err != nil { + return nil, err + } + + return vectors, nil + }, +) + +// loadSignatureVectors returns the parsed sigTestVector slice, failing the +// test if signature-test.json is unreadable or malformed. +func loadSignatureVectors(t *testing.T) []sigTestVector { + t.Helper() + + vectors, err := loadSignatureVectorsOnce() + require.NoError(t, err) + + return vectors +} + +// loadSignatureRawOnce parses signature-test.json as a slice of raw +// json.RawMessage so callers can index into keys whose names cannot be +// expressed via struct tags. +var loadSignatureRawOnce = sync.OnceValues( + func() ([]json.RawMessage, error) { + data, err := readSignatureDataOnce() + if err != nil { + return nil, err + } + + var raw []json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + return raw, nil + }, +) + +// loadSignatureRawVectors returns the raw json.RawMessage view of +// signature-test.json, failing the test if the file is unreadable or +// malformed. +func loadSignatureRawVectors(t *testing.T) []json.RawMessage { + t.Helper() + + raw, err := loadSignatureRawOnce() + require.NoError(t, err) + + return raw +} diff --git a/bolt12/invoice_request_test.go b/bolt12/invoice_request_test.go index 71eac71465..1dbe8716ea 100644 --- a/bolt12/invoice_request_test.go +++ b/bolt12/invoice_request_test.go @@ -2,6 +2,7 @@ package bolt12 import ( "bytes" + "encoding/hex" "testing" "github.com/btcsuite/btcd/btcec/v2" @@ -169,3 +170,104 @@ func TestNewInvoiceRequestFromOfferMirrorsUnknownFields(t *testing.T) { } require.True(t, found, "unknown offer TLV not mirrored into request") } + +// TestDecodeInvoiceRequestBech32String decodes the invoice_request string and +// verifies key fields. This exercises the low-level Decode plus +// DecodeInvoiceRequest path; TestDecodeInvoiceRequestString in bolt12_test.go +// covers the DecodeInvoiceRequestString convenience wrapper. +func TestDecodeInvoiceRequestBech32String(t *testing.T) { + t.Parallel() + + // From upstream lightning/bolts signature-test.json: the + // invoice_request bolt12 string. + lnrStr := "lnr1qqyqqqqqqqqqqqqqqcp4256ypqqkgzshgysy6ct5d" + + "pjk6ct5d93kzmpq23ex2ct5d9ek293pqthvwfzadd7jej" + + "es8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpjkppqvj" + + "x204vgdzgsqpvcp4mldl3plscny0rt707gvpdh6ndydfac" + + "z43euzqhrurageg3n7kafgsek6gz3e9w52parv8gs2hlxz" + + "k95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu0" + + "4qz3slje2rfthc89vss" + + _, tlvBytes, err := Decode(lnrStr) + require.NoError(t, err) + + ir, err := DecodeInvoiceRequest(tlvBytes) + require.NoError(t, err) + + // Verify invreq_metadata is set (8 zero bytes). + var metadata []byte + ir.InvreqMetadata.WhenSome( + func(r tlv.RecordT[tlv.TlvType0, tlv.Blob]) { + metadata = r.Val + }, + ) + require.Equal(t, make([]byte, 8), metadata) + + // Verify offer_currency is "USD". + var currency []byte + ir.OfferCurrency.WhenSome( + func(r tlv.RecordT[tlv.TlvType6, tlv.Blob]) { + currency = r.Val + }, + ) + require.Equal(t, "USD", string(currency)) + + // Verify offer_amount is 100. + var amount TUint64 + ir.OfferAmount.WhenSome( + func(r tlv.RecordT[tlv.TlvType8, TUint64]) { + amount = r.Val + }, + ) + require.Equal(t, TUint64(100), amount) + + // Verify offer_description is "A Mathematical Treatise". + var desc []byte + ir.OfferDescription.WhenSome( + func(r tlv.RecordT[tlv.TlvType10, tlv.Blob]) { + desc = r.Val + }, + ) + require.Equal(t, "A Mathematical Treatise", string(desc)) + + // Verify invreq_payer_id is Bob's compressed pubkey (0x424242... + // privkey). + var payerIDSet bool + ir.InvreqPayerID.WhenSome( + func(r tlv.RecordT[tlv.TlvType88, *btcec.PublicKey]) { + payerIDSet = true + }, + ) + require.True(t, payerIDSet) + + // Verify signature is present. + var ( + sig [64]byte + sigSet bool + ) + ir.Signature.WhenSome( + func(r tlv.RecordT[tlv.TlvType240, [64]byte]) { + sig = r.Val + sigSet = true + }, + ) + require.True(t, sigSet) + + expectedSig := "b8f83ea3288cfd6ea510cdb481472575141e8d87" + + "44157f98562d162cc1c472526fdb24befefbdebab4dbb" + + "726bbd1b7d8aec057f8fa805187e5950d2bbe0e5642" + require.Equal(t, expectedSig, hex.EncodeToString(sig[:])) + + // Verify decode populated the canonical record set used by the Merkle + // tree, so every wire TLV must be reachable through AllRecords for + // signature verification to find them. + require.NotEmpty(t, ir.AllRecords()) + + // Re-encode must be byte-identical to the decoded wire bytes: the + // signature is over the Merkle root of this canonical encoding, so any + // reordering, dropped TLV, or non-canonical integer would invalidate + // it. + reencoded, err := ir.Encode() + require.NoError(t, err) + require.Equal(t, tlvBytes, reencoded) +} diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go index f15e1a7532..56f2c74bdc 100644 --- a/bolt12/invoice_test.go +++ b/bolt12/invoice_test.go @@ -185,8 +185,14 @@ func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { t.Parallel() inv := validInvoice(t) + + // Sign with the fixture's node id (Bob) so the read path's signature + // check accepts the invoice. + priv, _ := bobKey() + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) inv.Signature = tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte]([64]byte{}), + tlv.NewPrimitiveRecord[tlv.TlvType240](sig), ) encoded, err := inv.Encode() diff --git a/bolt12/merkle.go b/bolt12/merkle.go new file mode 100644 index 0000000000..548ef51586 --- /dev/null +++ b/bolt12/merkle.go @@ -0,0 +1,135 @@ +package bolt12 + +import ( + "bytes" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" +) + +// errEmptyMerkleInput is returned by merkleRoot when the input contains no +// TLVs. merkleRoot never returns the all-zero digest for an empty input. A +// verifier must reject a signature over an all-zero digest. +var errEmptyMerkleInput = errors.New("cannot compute Merkle root over empty " + + "TLV set") + +// taggedHash computes SHA256(SHA256(tag) || SHA256(tag) || msg) per the BIP-340 +// tagged hash convention. +func taggedHash(tag string, msg []byte) [32]byte { + return *chainhash.TaggedHash([]byte(tag), msg) +} + +// leafHash computes H("LnLeaf", fullTLVBytes) for a single TLV field. +func leafHash(fullTLVBytes []byte) [32]byte { + return taggedHash("LnLeaf", fullTLVBytes) +} + +// nonceHash computes H("LnNonce" || firstTLV, tlvTypeBigSize) for a single TLV +// field. The tag includes the raw bytes of the first TLV in the stream. The +// message is the BigSize-encoded type of the current TLV field. +// +// The tag is the literal byte concatenation of "LnNonce" and the first TLV. Go +// converts []byte to string as a byte-faithful copy. The spec defines the tag +// as byte concatenation, not UTF-8 joining. +func nonceHash(firstTLV []byte, tlvType tlv.Type) [32]byte { + tag := "LnNonce" + string(firstTLV) + + var buf [8]byte + var typeBuf bytes.Buffer + + // WriteVarInt only fails on a Writer error. bytes.Buffer.Write is + // documented to never return one, so the discard is safe. + _ = tlv.WriteVarInt(&typeBuf, uint64(tlvType), &buf) + + return taggedHash(tag, typeBuf.Bytes()) +} + +// branchHash computes H("LnBranch", lesser || greater) where the two child +// hashes are sorted lexicographically with the lesser hash first. +func branchHash(a, b [32]byte) [32]byte { + if bytes.Compare(a[:], b[:]) > 0 { + a, b = b, a + } + + var msg [64]byte + copy(msg[:32], a[:]) + copy(msg[32:], b[:]) + + return taggedHash("LnBranch", msg[:]) +} + +// signableTLVs returns the subset of records that contribute to the signature's +// Merkle root. Everything outside the inclusive range [240, 1000] is included. +// Types 240-1000 are reserved by the BOLT 12 spec for the signature TLV (type +// 240) and similar non-content fields the signer must not commit to. The +// reserved range covers more than just signature, so the filter is symmetric on +// both ends rather than a single-type exclusion. +func signableTLVs(records []tlv.Record) []tlv.Record { + out := make([]tlv.Record, 0, len(records)) + for _, r := range records { + if !bolt12InUnsignedRange(r.Type()) { + out = append(out, r) + } + } + + return out +} + +// merkleRoot computes the Merkle root of the given TLV records. Each record is +// encoded in isolation via its TLV stream form to derive the per-leaf full +// type+length+value bytes that feed both the LnLeaf and LnNonce digests. The +// records must be in canonical order (ascending by type, no duplicates). +// merkleRoot does not check the ordering, so unsorted or duplicate input +// produces an incorrect root silently. +// +// An empty input returns errEmptyMerkleInput. Signing or verifying an empty +// stream would collide with the all-zero digest. +func merkleRoot(records []tlv.Record) ([32]byte, error) { + if len(records) == 0 { + return [32]byte{}, errEmptyMerkleInput + } + + // Encode each record on its own to recover the same per-field + // type+length+value bytes the original wire stream contained. The + // spec's nonce tag binds to the bytes of the first TLV, so the + // per-record encoding must match what the producer signed. + encoded := make([][]byte, len(records)) + for i, r := range records { + buf, err := lnwire.EncodeRecords([]tlv.Record{r}) + if err != nil { + return [32]byte{}, fmt.Errorf("encode record %d (type "+ + "%d): %w", i, r.Type(), err) + } + encoded[i] = buf + } + + firstTLV := encoded[0] + + branches := make([][32]byte, len(records)) + for i, r := range records { + leaf := leafHash(encoded[i]) + nonce := nonceHash(firstTLV, r.Type()) + branches[i] = branchHash(leaf, nonce) + } + + // Combine branches pairwise until a single root remains. + for len(branches) > 1 { + var next [][32]byte + for i := 0; i < len(branches); i += 2 { + if i+1 >= len(branches) { + // Odd element is promoted unchanged. + next = append(next, branches[i]) + continue + } + + combined := branchHash(branches[i], branches[i+1]) + next = append(next, combined) + } + branches = next + } + + return branches[0], nil +} diff --git a/bolt12/merkle_test.go b/bolt12/merkle_test.go new file mode 100644 index 0000000000..7f8ea82826 --- /dev/null +++ b/bolt12/merkle_test.go @@ -0,0 +1,474 @@ +package bolt12 + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "testing" + + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// TestMerkleRootVectors verifies the Merkle root computation against every test +// case in signature-test.json. +func TestMerkleRootVectors(t *testing.T) { + t.Parallel() + + vectors := loadSignatureVectors(t) + require.NotEmpty(t, vectors) + + for _, tc := range vectors { + t.Run(tc.Comment, func(t *testing.T) { + t.Parallel() + + var records []tlv.Record + + switch { + case tc.Bolt12 != "": + // Decode the bech32 string to get TLV bytes, + // then convert into the record view merkleRoot + // consumes. + _, tlvBytes, err := Decode(tc.Bolt12) + require.NoError(t, err) + + records = streamToRecords(t, tlvBytes) + + case tc.TLV == "n1": + // Build records from the leaf descriptions. The + // n1 namespace is synthetic. There is no bech32 + // representation, so we recover each record + // from its hex prefix. + records = buildN1Records(t, tc) + + default: + t.Fatalf("vector %q: neither bolt12 nor "+ + "n1: refusing to assume the "+ + "wrong synthesis path", tc.Comment) + } + + // Keep only the records that participate in the + // signature root. + filtered := signableTLVs(records) + + root, err := merkleRoot(filtered) + require.NoError(t, err) + + expectedRoot, err := hex.DecodeString(tc.Merkle) + require.NoError(t, err) + require.Equal( + t, expectedRoot, root[:], + "merkle root mismatch", + ) + }) + } +} + +// buildN1Records constructs tlv.Record entries for the simple n1 test vectors +// by parsing the leaf hex values from the test JSON. +func buildN1Records(t *testing.T, tc sigTestVector) []tlv.Record { + t.Helper() + + var result []tlv.Record + + for _, leafJSON := range tc.Leaves { + var leafMap map[string]string + require.NoError(t, json.Unmarshal(leafJSON, &leafMap)) + + // Find the LnLeaf key to extract the TLV bytes. + // Key format: H(`LnLeaf`,) + prefix := "H(`LnLeaf`," + for key := range leafMap { + if len(key) <= len(prefix) || + key[:len(prefix)] != prefix { + + continue + } + + // Extract hex between the comma and closing + // paren. + hexStr := key[len(prefix) : len(key)-1] + fullBytes, err := hex.DecodeString(hexStr) + require.NoError(t, err) + + result = append( + result, + recordFromWireBytes(t, fullBytes), + ) + + break + } + } + + return result +} + +// TestLeafHash verifies individual leaf hash computations from the test +// vectors. +func TestLeafHash(t *testing.T) { + t.Parallel() + + const ( + // From the first test vector: H("LnLeaf", 010203e8). + inputStr = "010203e8" + expectedStr = "67a2a995433890d8fe0c18a1765ad19e98f1fc" + + "feff14c13a45bbc80964a78cf7" + ) + + input, err := hex.DecodeString(inputStr) + require.NoError(t, err) + + expected, err := hex.DecodeString(expectedStr) + require.NoError(t, err) + + got := leafHash(input) + require.Equal(t, expected, got[:]) +} + +// TestNonceHash verifies the nonce hash computation. The type 1001 case pins +// the multi-byte BigSize encoding of the type, which none of the vendored +// vectors exercise. +func TestNonceHash(t *testing.T) { + t.Parallel() + + firstTLV, err := hex.DecodeString("010203e8") + require.NoError(t, err) + + tests := []struct { + name string + tlvType tlv.Type + expected string + }{ + { + name: "type 1 nonce", + tlvType: 1, + expected: "255a95f5b6b3c6997e2838dc4d9348807fb6da" + + "8eb7bbc02d30662d144718b6aa", + }, + { + name: "type 2 nonce", + tlvType: 2, + expected: "12bc15565410d8e3251a6fb1c53a2d360f39a9" + + "f65afb8403ef875016e34ff678", + }, + { + name: "type 1001 nonce multi-byte bigsize", + tlvType: 1001, + expected: "793dc046489a1260fd133c5048591f6b59f192" + + "8cbb7f9190219beeabc2b45f4d", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + expected, err := hex.DecodeString(tc.expected) + require.NoError(t, err) + + got := nonceHash(firstTLV, tc.tlvType) + require.Equal(t, expected, got[:]) + }) + } +} + +// TestBranchHash verifies the branch hash computation. +func TestBranchHash(t *testing.T) { + t.Parallel() + + const ( + // From test vector 2: combining the tlv1+nonce and + // tlv2+nonce branches. + aStr = "19d6ecfa3be88d29c30e56167f58526d7695df" + + "ac9cb95e1256deb222c92db4d0" + bStr = "b013756c8fee86503a0b4abdab4cddeb1af5d3" + + "44ca6fc2fa8b6c08938caa6f93" + expectedStr = "c3774abbf4815aa54ccaa026bff6581f01f3be" + + "5fe814c620a252534f434bc0d1" + ) + + a, err := hex.DecodeString(aStr) + require.NoError(t, err) + b, err := hex.DecodeString(bStr) + require.NoError(t, err) + + var aArr, bArr [32]byte + copy(aArr[:], a) + copy(bArr[:], b) + + expected, err := hex.DecodeString(expectedStr) + require.NoError(t, err) + + got := branchHash(aArr, bArr) + require.Equal(t, expected, got[:]) +} + +// TestMerkleVectorIntermediateHashes asserts every named LnLeaf, LnNonce, and +// LnBranch entry from each signature-test.json vector matches the hash this +// implementation produces. The root test alone cannot distinguish an encoding +// bug from a hash-construction bug. Feeding the primitives the spec-stated +// bytes directly localizes a vector failure to a single pipeline stage. +func TestMerkleVectorIntermediateHashes(t *testing.T) { + t.Parallel() + + for _, tc := range loadSignatureVectors(t) { + // The n1 vectors are synthesised. The pubkey-bearing + // invoice_request leaves are recoverable from the bech32 + // string. In both cases the leaf hex appears in the JSON + // `H('LnLeaf', )` keys, so we walk those directly. + t.Run(tc.Comment, func(t *testing.T) { + t.Parallel() + + firstTLV, err := hex.DecodeString(tc.FirstTLV) + require.NoError(t, err) + + for i, leafJSON := range tc.Leaves { + assertLeafEntry(t, leafJSON, firstTLV, i) + } + + // Branch entries each carry exactly one + // H('LnBranch', ) key. + for i, branchJSON := range tc.Branches { + assertBranchEntry(t, branchJSON, i) + } + }) + } +} + +// assertLeafEntry checks the hashes a vector leaf records for a single TLV +// against the values this implementation derives from the leaf bytes and the +// stream's first TLV. +func assertLeafEntry(t *testing.T, leafJSON json.RawMessage, firstTLV []byte, + idx int) { + + t.Helper() + + var entries map[string]string + require.NoError(t, json.Unmarshal(leafJSON, &entries)) + + const ( + leafPrefix = "H(`LnLeaf`," + noncePrefix = "H(`LnNonce`|first-tlv," + branchPrefix = "H(`LnBranch`," + ) + + var ( + leafKey, leafExpected string + nonceKey, nonceExpected string + branchKey, branchExpected string + ) + for k, v := range entries { + switch { + case len(k) > len(leafPrefix) && + k[:len(leafPrefix)] == leafPrefix: + leafKey, leafExpected = k, v + case len(k) > len(noncePrefix) && + k[:len(noncePrefix)] == noncePrefix: + nonceKey, nonceExpected = k, v + case len(k) > len(branchPrefix) && + k[:len(branchPrefix)] == branchPrefix: + branchKey, branchExpected = k, v + } + } + require.NotEmpty(t, leafKey, + "leaf %d: missing LnLeaf key", idx) + require.NotEmpty(t, nonceKey, + "leaf %d: missing LnNonce key", idx) + require.NotEmpty(t, branchKey, + "leaf %d: missing LnBranch key", idx) + + leafHex := leafKey[len(leafPrefix) : len(leafKey)-1] + leafBytes, err := hex.DecodeString(leafHex) + require.NoError(t, err) + + gotLeaf := leafHash(leafBytes) + wantLeaf, err := hex.DecodeString(leafExpected) + require.NoError(t, err) + require.Equal( + t, wantLeaf, gotLeaf[:], "leaf %d: LnLeaf hash mismatch", idx, + ) + + // The nonce key encodes a per-leaf type identifier as the final segment + // after the comma. For older vectors the segment is the type name + // ("tlv1-type"). Newer ones use a raw type number ("1"). We extract the + // leaf's leading TLV type from its hex prefix and use that. The spec + // says the nonce binds to the first TLV plus the leaf's own type. + leafType := leafTypeFromHex(t, leafBytes) + gotNonce := nonceHash(firstTLV, leafType) + wantNonce, err := hex.DecodeString(nonceExpected) + require.NoError(t, err) + require.Equal(t, wantNonce, gotNonce[:], + "leaf %d: LnNonce hash mismatch", idx) + + gotBranch := branchHash(gotLeaf, gotNonce) + wantBranch, err := hex.DecodeString(branchExpected) + require.NoError(t, err) + require.Equal(t, wantBranch, gotBranch[:], + "leaf %d: LnBranch hash mismatch", idx) +} + +// assertBranchEntry validates the branch hash for one entry in the vector's +// `branches` array. Each entry's H('LnBranch', ) key carries the +// two child hashes concatenated. The value is the expected combined hash. +func assertBranchEntry(t *testing.T, branchJSON json.RawMessage, idx int) { + t.Helper() + + var entries map[string]string + require.NoError(t, json.Unmarshal(branchJSON, &entries)) + + const branchPrefix = "H(`LnBranch`," + + var key, expected string + for k, v := range entries { + if len(k) > len(branchPrefix) && + k[:len(branchPrefix)] == branchPrefix { + + key, expected = k, v + } + } + require.NotEmpty(t, key, "branch %d: missing LnBranch key", idx) + + hexConcat := key[len(branchPrefix) : len(key)-1] + concat, err := hex.DecodeString(hexConcat) + require.NoError(t, err) + require.Equal( + t, 64, len(concat), "branch %d: expected 64 bytes of "+ + "child hashes", idx, + ) + + var a, b [32]byte + copy(a[:], concat[:32]) + copy(b[:], concat[32:]) + + got := branchHash(a, b) + want, err := hex.DecodeString(expected) + require.NoError(t, err) + require.Equal(t, want, got[:], "branch %d: LnBranch hash mismatch", idx) +} + +// leafTypeFromHex parses the leading varint of a TLV-encoded leaf to recover +// its type number. The signature-test.json LnNonce entries bind the nonce to +// this type, so we must reproduce the parse here to compute the same nonce +// hash. +func leafTypeFromHex(t *testing.T, leafBytes []byte) tlv.Type { + t.Helper() + + var buf [8]byte + r := bytes.NewReader(leafBytes) + typ, err := tlv.ReadVarInt(r, &buf) + require.NoError(t, err) + + return tlv.Type(typ) +} + +// TestPropertyMerkleOrderSensitivity asserts that for any non-trivial raw TLV +// sequence the Merkle root depends on the input order. The receiver-to-sender +// invoice flow signs a tree built over a type-sorted stream. If the root were +// order-insensitive, an attacker could permute fields without invalidating the +// signature. +func TestPropertyMerkleOrderSensitivity(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + // Need at least two leaves with distinct types. Types are + // tagged into the nonce hash, so identical types would produce + // identical leaves and a swap would be a no-op. + n := rapid.IntRange(2, 8).Draw(t, "leafCount") + records := make([]tlv.Record, n) + for i := range n { + v := drawTLVValue(t) + records[i] = tlv.MakePrimitiveRecord(tlv.Type(i+1), &v) + } + + root1, err := merkleRoot(records) + require.NoError(t, err) + + swapped := make([]tlv.Record, len(records)) + copy(swapped, records) + swapped[0], swapped[1] = swapped[1], swapped[0] + + root2, err := merkleRoot(swapped) + require.NoError(t, err) + + require.NotEqual(t, root1, root2, + "swapping two distinct leaves did not change root") + }) +} + +// drawTLVValue synthesises the value-side payload for a single TLV record. Used +// by the Merkle order-sensitivity property to build leaves that the hash +// functions can ingest. +func drawTLVValue(t *rapid.T) []byte { + payloadLen := rapid.IntRange(1, 8).Draw(t, "payloadLen") + + return rapid.SliceOfN(rapid.Byte(), payloadLen, payloadLen). + Draw(t, "payload") +} + +// TestMerkleRootEmptyInput pins the contract for an empty leaf set: merkleRoot +// returns errEmptyMerkleInput, never the all-zero digest. The all-zero hash is +// a valid SHA-256 output that could collide with a legitimately computed root, +// so a verifier accepting it could be tricked by a forged-but-empty message. +func TestMerkleRootEmptyInput(t *testing.T) { + t.Parallel() + + t.Run("nil slice", func(t *testing.T) { + t.Parallel() + + root, err := merkleRoot(nil) + require.ErrorIs(t, err, errEmptyMerkleInput) + require.Equal(t, [32]byte{}, root) + }) + + t.Run("empty slice", func(t *testing.T) { + t.Parallel() + + root, err := merkleRoot([]tlv.Record{}) + require.ErrorIs(t, err, errEmptyMerkleInput) + require.Equal(t, [32]byte{}, root) + }) +} + +// TestSignableTLVsFilteringBoundaries pins the inclusion rule for the Merkle +// input. The spec excludes types in [240, 1000]. Everything outside that range +// contributes. Drift here would either include type 240 (the signature itself, +// breaking commit-to-tree-root semantics) or exclude experimental types > 1000 +// (silently dropping fields the writer expected to commit to). +func TestSignableTLVsFilteringBoundaries(t *testing.T) { + t.Parallel() + + tests := []struct { + typ tlv.Type + included bool + }{ + {typ: 0, included: true}, + {typ: 239, included: true}, + {typ: 240, included: false}, + {typ: 500, included: false}, + {typ: 1000, included: false}, + {typ: 1001, included: true}, + {typ: 1_000_000_000, included: true}, + } + + records := make([]tlv.Record, 0, len(tests)) + for _, tc := range tests { + // An empty value blob is enough. The filter only inspects each + // record's Type. + var v []byte + records = append(records, tlv.MakePrimitiveRecord(tc.typ, &v)) + } + + got := signableTLVs(records) + gotTypes := make(map[tlv.Type]bool, len(got)) + for _, r := range got { + gotTypes[r.Type()] = true + } + + for _, tc := range tests { + require.Equal( + t, tc.included, gotTypes[tc.typ], + "type %d inclusion mismatch", tc.typ, + ) + } +} diff --git a/bolt12/offer_test.go b/bolt12/offer_test.go index 2a9ae325bc..4bf65ec02f 100644 --- a/bolt12/offer_test.go +++ b/bolt12/offer_test.go @@ -1,8 +1,11 @@ package bolt12 import ( + "bytes" + "encoding/hex" "testing" + "github.com/btcsuite/btcd/btcec/v2" "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) @@ -47,3 +50,66 @@ func TestOfferRoundTrip(t *testing.T) { require.NoError(t, err) require.Equal(t, encoded, reencoded) } + +// TestDecodeOversizedRecord pins the per-record cap by feeding the decoder a +// TLV declaring a length one byte over tlv.MaxRecordSize. +func TestDecodeOversizedRecord(t *testing.T) { + t.Parallel() + + // Build a synthetic TLV with type=22 (offer_issuer_id, known by the + // offer decoder) and declared length one byte over the cap. The value + // bytes are present so the framing itself is consistent. + const oversize = tlv.MaxRecordSize + 1 + var ( + buf [8]byte + w bytes.Buffer + ) + require.NoError(t, tlv.WriteVarInt(&w, 22, &buf)) + require.NoError(t, tlv.WriteVarInt(&w, oversize, &buf)) + w.Write(make([]byte, oversize)) + + _, err := decodeOffer(w.Bytes()) + require.ErrorIs( + t, err, tlv.ErrRecordTooLarge, + "expected an oversize-record rejection, got %v", err, + ) +} + +// TestDecodeOfferString decodes a minimal offer string and verifies the +// issuer ID field is correctly parsed. +func TestDecodeOfferString(t *testing.T) { + t.Parallel() + + // Minimal offer: just offer_issuer_id (type 22). + offerStr := "lno1zcss9mk8y3wkklfvevcrszlmu23kfrxh49p" + + "x20665dqwmn4p72pksese" + + _, tlvBytes, err := Decode(offerStr) + require.NoError(t, err) + + offer, err := decodeOffer(tlvBytes) + require.NoError(t, err) + + // Verify issuer ID is present and correctly typed. + var ( + issuerKey *btcec.PublicKey + set bool + ) + offer.OfferIssuerID.WhenSome( + func(r tlv.RecordT[tlv.TlvType22, *btcec.PublicKey]) { + issuerKey = r.Val + set = true + }, + ) + require.True(t, set, "expected offer_issuer_id to be set") + + expectedHex := "02eec7245d6b7d2ccb30380bfbe2a3648cd7a94" + + "2653f5aa340edcea1f283686619" + require.Equal(t, expectedHex, + hex.EncodeToString(issuerKey.SerializeCompressed())) + + // Re-encode and verify bytes match. + reencoded, err := offer.Encode() + require.NoError(t, err) + require.Equal(t, tlvBytes, reencoded) +} diff --git a/bolt12/signature.go b/bolt12/signature.go new file mode 100644 index 0000000000..dc61a3e1ae --- /dev/null +++ b/bolt12/signature.go @@ -0,0 +1,154 @@ +package bolt12 + +import ( + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" +) + +// signatureTagPrefix is the literal prefix for all BOLT 12 signature tags. +const signatureTagPrefix = "lightning" + +// ErrInvalidSignature is returned by VerifyInvoice and VerifyInvoiceRequest +// when the BIP-340 Schnorr signature does not validate against the message's +// Merkle root and signing key. +var ErrInvalidSignature = errors.New("BOLT 12 signature is invalid") + +// ErrNilPrivateKey is returned by the sign entry paths when the signing key is +// nil. +var ErrNilPrivateKey = errors.New("BOLT 12 signing key is nil") + +// signMessage creates a BIP-340 Schnorr signature over the Merkle root of a +// BOLT 12 message. The tag is "lightning" || messageName || fieldName. +func signMessage(messageName, fieldName string, root [32]byte, + privKey *btcec.PrivateKey) ([64]byte, error) { + + if privKey == nil { + return [64]byte{}, ErrNilPrivateKey + } + + tag := signatureTagPrefix + messageName + fieldName + digest := taggedHash(tag, root[:]) + + sig, err := schnorr.Sign(privKey, digest[:]) + if err != nil { + return [64]byte{}, fmt.Errorf("sign: %w", err) + } + + var result [64]byte + copy(result[:], sig.Serialize()) + + return result, nil +} + +// verifySignature verifies a BIP-340 Schnorr signature over the Merkle root of +// a BOLT 12 message. +func verifySignature(messageName, fieldName string, root [32]byte, sig [64]byte, + pubKey *btcec.PublicKey) error { + + if pubKey == nil { + return ErrNilPublicKey + } + + tag := signatureTagPrefix + messageName + fieldName + digest := taggedHash(tag, root[:]) + + parsedSig, err := schnorr.ParseSignature(sig[:]) + if err != nil { + return fmt.Errorf("parse signature: %w", err) + } + + if !parsedSig.Verify(digest[:], pubKey) { + return ErrInvalidSignature + } + + return nil +} + +// SignInvoiceRequest computes the Merkle root of an invoice request and +// generates a Schnorr signature using the provided private key. The root is +// computed over the signable subset of AllRecords(). +func SignInvoiceRequest(ir *InvoiceRequest, privKey *btcec.PrivateKey) ( + [64]byte, error) { + + if privKey == nil { + return [64]byte{}, ErrNilPrivateKey + } + + root, err := merkleRoot(signableTLVs(ir.AllRecords())) + if err != nil { + return [64]byte{}, err + } + + return signMessage( + "invoice_request", "signature", root, privKey, + ) +} + +// VerifyInvoiceRequest verifies the signature on an invoice request using its +// invreq_payer_id public key. +func VerifyInvoiceRequest(ir *InvoiceRequest) error { + pubKey, err := ir.InvreqPayerID.UnwrapOrErrV(ErrMissingPayerID) + if err != nil { + return err + } + if pubKey == nil { + return fmt.Errorf("%w: invreq_payer_id", ErrNilPublicKey) + } + + sig, err := ir.Signature.UnwrapOrErrV(ErrMissingSignature) + if err != nil { + return err + } + + root, err := merkleRoot(signableTLVs(ir.AllRecords())) + if err != nil { + return err + } + + return verifySignature( + "invoice_request", "signature", root, sig, pubKey, + ) +} + +// SignInvoice computes the Merkle root of an invoice and generates a Schnorr +// signature using the provided private key. The root is computed over the +// signable subset of AllRecords(). +func SignInvoice(inv *Invoice, privKey *btcec.PrivateKey) ([64]byte, error) { + if privKey == nil { + return [64]byte{}, ErrNilPrivateKey + } + + root, err := merkleRoot(signableTLVs(inv.AllRecords())) + if err != nil { + return [64]byte{}, err + } + + return signMessage("invoice", "signature", root, privKey) +} + +// VerifyInvoice verifies the signature on an invoice using its invoice_node_id +// public key. +func VerifyInvoice(inv *Invoice) error { + pubKey, err := inv.InvoiceNodeID.UnwrapOrErrV(ErrMissingNodeID) + if err != nil { + return err + } + if pubKey == nil { + return fmt.Errorf("%w: invoice_node_id", ErrNilPublicKey) + } + + sig, err := inv.Signature.UnwrapOrErrV(ErrMissingSignature) + if err != nil { + return err + } + + root, err := merkleRoot(signableTLVs(inv.AllRecords())) + if err != nil { + return err + } + + return verifySignature("invoice", "signature", root, sig, pubKey) +} diff --git a/bolt12/signature_test.go b/bolt12/signature_test.go new file mode 100644 index 0000000000..bbb8b72bba --- /dev/null +++ b/bolt12/signature_test.go @@ -0,0 +1,480 @@ +package bolt12 + +import ( + "encoding/hex" + "encoding/json" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestSignatureVerifyVector verifies the signature from the invoice_request +// test vector in signature-test.json. +func TestSignatureVerifyVector(t *testing.T) { + t.Parallel() + + vectors := loadSignatureVectors(t) + + // Locate the invoice_request vector. + var tc sigTestVector + for _, v := range vectors { + if v.Bolt12 != "" && v.TLV == "invoice_request" { + tc = v + break + } + } + require.NotEmpty(t, tc.Bolt12) + + // Decode the bech32 string and convert the TLV bytes into the record + // view merkleRoot consumes. + _, tlvBytes, err := Decode(tc.Bolt12) + require.NoError(t, err) + + records := streamToRecords(t, tlvBytes) + + // Keep only the records that participate in the signature root. + unsigned := signableTLVs(records) + + // Compute the merkle root of the unsigned records. + root, err := merkleRoot(unsigned) + require.NoError(t, err) + expectedRoot, err := hex.DecodeString(tc.Merkle) + require.NoError(t, err) + require.Equal(t, expectedRoot, root[:]) + + // Recompute the tagged digest the signer produced. + require.Equal(t, "lightninginvoice_requestsignature", + tc.SignatureTag) + sigDigest := taggedHash(tc.SignatureTag, root[:]) + + // The expected digest is stored under a JSON key with a comma which + // can't be parsed via struct tags. Parse it manually from the raw + // vector that carries the invoice_request bech32 string. + rawVectors := loadSignatureRawVectors(t) + + var rawMap map[string]json.RawMessage + for _, raw := range rawVectors { + var probe struct { + TLV string `json:"tlv"` + Bolt12 string `json:"bolt12"` + } + require.NoError(t, json.Unmarshal(raw, &probe)) + + if probe.TLV == "invoice_request" && probe.Bolt12 != "" { + require.NoError(t, json.Unmarshal(raw, &rawMap)) + + break + } + } + require.NotNil(t, rawMap, "invoice_request raw vector not found") + + var expectedDigestHex string + require.NoError(t, json.Unmarshal( + rawMap["H(signature_tag,merkle)"], &expectedDigestHex, + )) + + expectedDigest, err := hex.DecodeString(expectedDigestHex) + require.NoError(t, err) + require.Equal(t, expectedDigest, sigDigest[:]) + + // Verify the vector's signature against Bob's public key. + sigBytes, err := hex.DecodeString(tc.Signature) + require.NoError(t, err) + + var sig [64]byte + copy(sig[:], sigBytes) + + bobPrivKey, bobPubKey := bobKey() + + err = verifySignature( + "invoice_request", "signature", root, sig, bobPubKey, + ) + require.NoError(t, err) + + // Sign with the same key and verify the round-trip. + newSig, err := signMessage( + "invoice_request", "signature", root, bobPrivKey, + ) + require.NoError(t, err) + + err = verifySignature( + "invoice_request", "signature", + root, newSig, bobPubKey, + ) + require.NoError(t, err) +} + +// TestVerifyInvoiceRequestVector drives the typed verify path with the spec's +// signed invoice_request: the wire form is decoded, the vector's signature +// attached, and the result verified against the invreq_payer_id the request +// carries. This pins the tag choice, key extraction, and signable-range filter +// of the public API against the spec. +func TestVerifyInvoiceRequestVector(t *testing.T) { + t.Parallel() + + vectors := loadSignatureVectors(t) + + // Locate the invoice_request vector. + var tc sigTestVector + for _, v := range vectors { + if v.Bolt12 != "" && v.TLV == "invoice_request" { + tc = v + break + } + } + require.NotEmpty(t, tc.Bolt12) + + hrp, tlvBytes, err := Decode(tc.Bolt12) + require.NoError(t, err) + require.Equal(t, "lnr", hrp) + + ir, err := DecodeInvoiceRequest(tlvBytes) + require.NoError(t, err) + + sigBytes, err := hex.DecodeString(tc.Signature) + require.NoError(t, err) + + var sig [64]byte + copy(sig[:], sigBytes) + + ir.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240](sig), + ) + + require.NoError(t, VerifyInvoiceRequest(ir)) +} + +// TestSignatureVerifyRejectsTampering asserts that every way a malicious +// mediator can tamper with a signed message fails verification, so the +// tree-of-fields guarantee cannot collapse. +func TestSignatureVerifyRejectsTampering(t *testing.T) { + t.Parallel() + + bobPriv, bobPub := bobKey() + + var msg [32]byte + for i := range msg { + msg[i] = byte(i + 1) + } + sig, err := signMessage("invoice_request", "signature", msg, bobPriv) + require.NoError(t, err) + + // Sanity: untouched signature still verifies. + require.NoError(t, verifySignature( + "invoice_request", "signature", msg, sig, bobPub, + )) + + t.Run("tampered root", func(t *testing.T) { + t.Parallel() + + tampered := msg + tampered[0] ^= 0x01 + require.ErrorIs( + t, verifySignature( + "invoice_request", "signature", + tampered, sig, bobPub, + ), + ErrInvalidSignature, + ) + }) + + t.Run("tampered signature byte", func(t *testing.T) { + t.Parallel() + + tamperedSig := sig + tamperedSig[0] ^= 0xff + require.ErrorIs(t, + verifySignature( + "invoice_request", "signature", + msg, tamperedSig, bobPub, + ), + ErrInvalidSignature, + ) + }) + + t.Run("wrong public key", func(t *testing.T) { + t.Parallel() + + _, alicePub := aliceKey() + require.ErrorIs(t, + verifySignature( + "invoice_request", "signature", + msg, sig, alicePub, + ), + ErrInvalidSignature, + ) + }) + + t.Run("cross-tag replay rejected", func(t *testing.T) { + t.Parallel() + + // Same root, same signature, but verify under the + // invoice tag instead of invoice_request. + require.ErrorIs(t, + verifySignature( + "invoice", "signature", + msg, sig, bobPub, + ), + ErrInvalidSignature, + ) + }) + + t.Run("malformed 64-byte signature", func(t *testing.T) { + t.Parallel() + + var malformed [64]byte + + require.ErrorIs(t, + verifySignature( + "invoice_request", "signature", + msg, malformed, bobPub, + ), + ErrInvalidSignature, + ) + }) +} + +// TestNilKeyGuards pins the cryptographic key guards in the API. +func TestNilKeyGuards(t *testing.T) { + t.Parallel() + + var ( + root [32]byte + sig [64]byte + ) + + tests := []struct { + name string + call func(t *testing.T) error + want error + }{ + { + name: "sign message nil key", + call: func(t *testing.T) error { + _, err := signMessage( + "invoice_request", "signature", root, + nil, + ) + + return err + }, + want: ErrNilPrivateKey, + }, + { + name: "sign invoice request nil key", + call: func(t *testing.T) error { + _, err := SignInvoiceRequest( + validInvoiceRequest(t), nil, + ) + + return err + }, + want: ErrNilPrivateKey, + }, + { + name: "sign invoice nil key", + call: func(t *testing.T) error { + _, err := SignInvoice(validInvoice(t), nil) + + return err + }, + want: ErrNilPrivateKey, + }, + { + name: "verify signature nil key", + call: func(t *testing.T) error { + return verifySignature( + "invoice_request", "signature", + root, sig, nil, + ) + }, + want: ErrNilPublicKey, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.ErrorIs(t, tc.call(t), tc.want) + }) + } +} + +// TestVerifyInvoiceDirect drives VerifyInvoice end to end using a minimal valid +// Invoice constructed via validInvoice. +func TestVerifyInvoiceDirect(t *testing.T) { + t.Parallel() + + priv, pub := bobKey() + + tests := []struct { + name string + + // mutate adjusts the valid fixture to isolate the case under + // test, signing when the case expects success. + mutate func(t *testing.T, inv *Invoice) + + // wantErr is nil for the happy path. wantContains pins the + // field context in wrapped errors. + wantErr error + wantContains string + }{ + { + name: "valid round-trip verifies", + mutate: func(t *testing.T, inv *Invoice) { + _, err := inv.Encode() + require.NoError(t, err) + + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240]( + sig, + ), + ) + }, + }, + { + name: "missing invoice_node_id", + mutate: func(t *testing.T, inv *Invoice) { + inv.InvoiceNodeID = tlv.OptionalRecordT[ + tlv.TlvType176, *btcec.PublicKey, + ]{} + }, + wantErr: ErrMissingNodeID, + }, + { + // A present-but-nil invoice_node_id passes the + // presence check but has no key to verify + // against. + name: "nil invoice_node_id", + mutate: func(t *testing.T, inv *Invoice) { + inv.InvoiceNodeID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176]( + (*btcec.PublicKey)(nil), + ), + ) + }, + wantErr: ErrNilPublicKey, + wantContains: "invoice_node_id", + }, + { + name: "missing signature", + mutate: func(t *testing.T, inv *Invoice) { + // The fixture carries no signature. + }, + wantErr: ErrMissingSignature, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + inv.InvoiceNodeID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176](pub), + ) + tc.mutate(t, inv) + + err := VerifyInvoice(inv) + require.ErrorIs(t, err, tc.wantErr) + if tc.wantContains != "" { + require.Contains( + t, err.Error(), tc.wantContains, + ) + } + }) + } +} + +// TestVerifyInvoiceRequestDirect drives VerifyInvoiceRequest end to end using a +// minimal valid InvoiceRequest constructed via validInvoiceRequest. +func TestVerifyInvoiceRequestDirect(t *testing.T) { + t.Parallel() + + priv, pub := bobKey() + + tests := []struct { + name string + + // mutate adjusts the valid fixture to isolate the case + // under test, signing when the case expects success. + mutate func(t *testing.T, ir *InvoiceRequest) + + // wantErr is nil for the happy path. wantContains pins + // the field context in wrapped errors. + wantErr error + wantContains string + }{ + { + name: "valid round-trip verifies", + mutate: func(t *testing.T, ir *InvoiceRequest) { + sig, err := SignInvoiceRequest(ir, priv) + require.NoError(t, err) + + ir.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240]( + sig, + ), + ) + }, + }, + { + name: "missing invreq_payer_id", + mutate: func(t *testing.T, ir *InvoiceRequest) { + ir.InvreqPayerID = tlv.OptionalRecordT[ + tlv.TlvType88, *btcec.PublicKey, + ]{} + }, + wantErr: ErrMissingPayerID, + }, + { + // A present-but-nil invreq_payer_id passes the presence + // check but has no key to verify against. + name: "nil invreq_payer_id", + mutate: func(t *testing.T, ir *InvoiceRequest) { + ir.InvreqPayerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88]( + (*btcec.PublicKey)(nil), + ), + ) + }, + wantErr: ErrNilPublicKey, + wantContains: "invreq_payer_id", + }, + { + name: "missing signature", + mutate: func(t *testing.T, ir *InvoiceRequest) { + ir.Signature = tlv.OptionalRecordT[ + tlv.TlvType240, [64]byte, + ]{} + }, + wantErr: ErrMissingSignature, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ir := validInvoiceRequest(t) + ir.InvreqPayerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](pub), + ) + tc.mutate(t, ir) + + err := VerifyInvoiceRequest(ir) + require.ErrorIs(t, err, tc.wantErr) + if tc.wantContains != "" { + require.Contains( + t, err.Error(), tc.wantContains, + ) + } + }) + } +} diff --git a/bolt12/test-vectors/README.md b/bolt12/test-vectors/README.md new file mode 100644 index 0000000000..0659a3597b --- /dev/null +++ b/bolt12/test-vectors/README.md @@ -0,0 +1,7 @@ +# BOLT 12 Spec Test Vectors + +These test vectors are vendored from the upstream [lightning/bolts](https://github.com/lightning/bolts) specification repository. + +- **Source**: `bolt12/` directory in `lightning/bolts` +- **Upstream Commit**: `311119388a46dfa859da3d2eda0ca836cfc5f078` +- **License**: Creative Commons Attribution 4.0 International (CC-BY 4.0) diff --git a/bolt12/test-vectors/format-string-test.json b/bolt12/test-vectors/format-string-test.json new file mode 100644 index 0000000000..46e543b8b2 --- /dev/null +++ b/bolt12/test-vectors/format-string-test.json @@ -0,0 +1,62 @@ +[ + { + "comment": "A complete string is valid", + "valid": true, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "Uppercase is valid", + "valid": true, + "string": "LNO1PQPS7SJQPGTYZM3QV4UXZMTSD3JJQER9WD3HY6TSW35K7MSJZFPY7NZ5YQCNYGRFDEJ82UM5WF5K2UCKYYPWA3EYT44H6TXTXQUQH7LZ5DJGE4AFGFJN7K4RGRKUAG0JSD5XVXG" + }, + { + "comment": "+ can join anywhere", + "valid": true, + "string": "l+no1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "Multiple + can join", + "valid": true, + "string": "lno1pqps7sjqpgt+yzm3qv4uxzmtsd3jjqer9wd3hy6tsw3+5k7msjzfpy7nz5yqcn+ygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd+5xvxg" + }, + { + "comment": "+ can be followed by whitespace", + "valid": true, + "string": "lno1pqps7sjqpgt+ yzm3qv4uxzmtsd3jjqer9wd3hy6tsw3+ 5k7msjzfpy7nz5yqcn+\nygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd+\r\n 5xvxg" + }, + { + "comment": "+ can be followed by whitespace, UPPERCASE", + "valid": true, + "string": "LNO1PQPS7SJQPGT+ YZM3QV4UXZMTSD3JJQER9WD3HY6TSW3+ 5K7MSJZFPY7NZ5YQCN+\nYGRFDEJ82UM5WF5K2UCKYYPWA3EYT44H6TXTXQUQH7LZ5DJGE4AFGFJN7K4RGRKUAG0JSD+\r\n 5XVXG" + }, + { + "comment": "Mixed case is invalid", + "valid": false, + "string": "LnO1PqPs7sJqPgTyZm3qV4UxZmTsD3JjQeR9Wd3hY6TsW35k7mSjZfPy7nZ5YqCnYgRfDeJ82uM5Wf5k2uCkYyPwA3EyT44h6tXtXqUqH7Lz5dJgE4AfGfJn7k4rGrKuAg0jSd5xVxG" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg+" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg+ " + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "+lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "+ lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "ln++o1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + } +] diff --git a/bolt12/test-vectors/offers-test.json b/bolt12/test-vectors/offers-test.json new file mode 100644 index 0000000000..db6a108c31 --- /dev/null +++ b/bolt12/test-vectors/offers-test.json @@ -0,0 +1,652 @@ +[ + { + "description": "Minimal bolt12 offer", + "valid": true, + "bolt12": "lno1zcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pksese", + "fields": [ + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with description (but no amount)", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg", + "field info": "description is 'Test vectors'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "for testnet", + "valid": true, + "bolt12": "lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "chains[0] is testnet", + "fields": [ + { + "type": 2, + "length": 32, + "hex": "43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "for bitcoin (redundant)", + "valid": true, + "bolt12": "lno1qgsxlc5vp2m0rvmjcxn2y34wv0m5lyc7sdj7zksgn35dvxgqqqqqqqq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "chains[0] is bitcoin", + "fields": [ + { + "type": 2, + "length": 32, + "hex": "6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "for bitcoin or liquidv1", + "valid": true, + "bolt12": "lno1qfqpge38tqmzyrdjj3x2qkdr5y80dlfw56ztq6yd9sme995g3gsxqqm0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq9qc4r9wd6zqan9vd6x7unnzcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pksese", + "field info": "chains[0] is liquidv1, chains[1] is bitcoin", + "fields": [ + { + "type": 2, + "length": 64, + "hex": "1466275836220db2944ca059a3a10ef6fd2ea684b0688d2c379296888a2060036fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with metadata", + "valid": true, + "bolt12": "lno1qsgqqqqqqqqqqqqqqqqqqqqqqqqqqzsv23jhxapqwejkxar0wfe3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "metadata is 16 zero bytes", + "fields": [ + { + "type": 4, + "length": 16, + "hex": "00000000000000000000000000000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with amount", + "valid": true, + "bolt12": "lno1pqpzwyq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "amount is 10000msat", + "fields": [ + { + "type": 8, + "length": 2, + "hex": "2710" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with currency", + "valid": true, + "bolt12": "lno1qcp4256ypqpzwyq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "amount is USD $100.00", + "fields": [ + { + "type": 6, + "length": 3, + "hex": "555344" + }, + { + "type": 8, + "length": 2, + "hex": "2710" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with expiry", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucwq3ay997czcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pksese", + "field info": "expiry is 2035-01-01", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 14, + "length": 4, + "hex": "7a4297d8" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with issuer", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucjy358garswvaz7tmzdak8gvfj9ehhyeeqgf85c4p3xgsxjmnyw4ehgunfv4e3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "issuer is 'https://bolt12.org BOLT12 industries'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 18, + "length": 36, + "hex": "68747470733a2f2f626f6c7431322e6f726720424f4c54313220696e6475737472696573" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with quantity", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc5qyz3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "quantity_max is 5", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 20, + "length": 1, + "hex": "05" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with unlimited (or unknown) quantity", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc5qqtzzqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxry", + "field info": "quantity_max is unknown/unlimited", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 20, + "length": 0, + "hex": "" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with single quantity (weird but valid)", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc5qyq3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "quantity_max is 1", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 20, + "length": 1, + "hex": "01" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with feature", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucvp5yqqqqqqqqqqqqqqqqqqqqkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg", + "field info": "feature bit 99 set", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 12, + "length": 13, + "hex": "08000000000000000000000000" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with blinded path via Bob (0x424242...), path_key 020202...", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucs5ypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zyg3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x11*8]", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 161, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200081111111111111111" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "same, with blinded path first_node_id using sciddir", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucs3yqqqqqqqqqqqqp2qgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqyqqqqqqqqqqqqqqqqqqqqqqqqqqqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqqgzyg3zyg3zyg3z93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "short_channel_id is 0x0x42, direction is 0", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 137, + "hex": "00000000000000002a0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200081111111111111111" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with no issuer_id and blinded path via Bob (0x424242...), path_key 020202...", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucs5ypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zygs", + "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x11*8]", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 161, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200081111111111111111" + } + ] + }, + { + "description": "... and with second blinded path via 1x2x3 (direction 1), path_key 020202...", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucsl5qj5qeyv5l2cs6y3qqzesrth7mlzrlp3xg7xhulusczm04x6g6nms9trspqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqqsqqqqqqqqqqqqqqqqqqqqqqqqqqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsqpqg3zyg3zyg3zygpqqqqzqqqqgqqxqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqqgqqqqqqqqqqqqqqqqqqqqqqqqqqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqqsg3zyg3zyg3zygtzzqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxry", + "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x22*8]", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 298, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202001000000000000000000000000000000000020202020202020202020202020202020202020202020202020202020202020202000811111111111111110100000100000200030202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200082222222222222222" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "unknown odd field", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxfppf5x2mrvdamk7unvvs", + "field info": "type 33 is 'helloworld'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + }, + { + "type": 33, + "length": 10, + "hex": "68656c6c6f776f726c64" + } + ] + }, + { + "description": "unknown odd experimental field", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx078wdv5gg2dpjkcmr0wahhymry", + "field info": "type 1000000033 is 'helloworld'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + }, + { + "type": 1000000033, + "length": 10, + "hex": "68656c6c6f776f726c64" + } + ] + }, + { + "description": "Malformed: fields out of order", + "valid": false, + "bolt12": "lno1zcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszpgz5znzfgdzs" + }, + { + "description": "Malformed: unknown even TLV type 78", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpysgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Malformed: empty", + "valid": false, + "bolt12": "lno1" + }, + { + "description": "Malformed: truncated at type", + "valid": false, + "bolt12": "lno1pg" + }, + { + "description": "Malformed: truncated in length", + "valid": false, + "bolt12": "lno1pt7s" + }, + { + "description": "Malformed: truncated after length", + "valid": false, + "bolt12": "lno1pgpq" + }, + { + "description": "Malformed: truncated in description", + "valid": false, + "bolt12": "lno1pgpyz" + }, + { + "description": "Malformed: invalid offer_chains length", + "valid": false, + "bolt12": "lno1qgqszzs9g9xyjs69zcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: truncated currency UTF-8", + "valid": false, + "bolt12": "lno1qcqcqzs9g9xyjs69zcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: invalid currency UTF-8", + "valid": false, + "bolt12": "lno1qcplllhapqpq86q2q4qkc6trv5tzzq6muh550qsfva9fdes0ruph7ctk2s8aqq06r4jxj3msc448wzwy9s" + }, + { + "description": "Malformed: truncated description UTF-8", + "valid": false, + "bolt12": "lno1pgqcq93pqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqy" + }, + { + "description": "Malformed: invalid description UTF-8", + "valid": false, + "bolt12": "lno1pgpgqsgkyypqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs" + }, + { + "description": "Malformed: truncated offer_paths", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqgpzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: zero num_hops in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: truncated onionmsg_hop in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgkyypqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs" + }, + { + "description": "Malformed: bad first_node_id in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: bad path_key in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcpqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: bad blinded_node_id in onionmsg_hop", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: truncated issuer UTF-8", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3yqvqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: invalid issuer UTF-8", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3yq5qgytzzqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqg" + }, + { + "description": "Malformed: invalid offer_issuer_id", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvps" + }, + { + "description": "Contains type >= 80", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp9qgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Contains type > 1999999999", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp06ae4jsq9qgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Contains unknown even type (1000000002)", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp06wu6egp9qgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Contains unknown feature 122", + "valid": false, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucvzqzqqqqqqqqqqqqqqqqqqqqqqqqpvggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs" + }, + { + "description": "Missing offer_description, but has offer_amount", + "valid": false, + "bolt12": "lno1pqpzwyqkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "description": "Missing offer_amount with offer_currency", + "valid": false, + "bolt12": "lno1qcp4256ypgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "description": "Invalid: zero offer_amount", + "valid": false, + "bolt12": "lno1pqqq5qqkyyp4he0fg7pqje62jmnq78cr0ashv4q06qql58tyd9rhp3t2wuyugtq", + "field info": "offer_amount is 0", + "fields": [ + { + "type": 8, + "length": 0, + "hex": "" + }, + { + "type": 10, + "length": 0, + "hex": "" + }, + { + "type": 22, + "length": 33, + "hex": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c" + } + ] + }, + { + "description": "Invalid: zero offer_amount with currency", + "valid": false, + "bolt12": "lno1qcp4256ypqqq5qqkyyp4he0fg7pqje62jmnq78cr0ashv4q06qql58tyd9rhp3t2wuyugtq", + "field info": "offer_amount is 0, offer_currency is USD", + "fields": [ + { + "type": 6, + "length": 3, + "hex": "555344" + }, + { + "type": 8, + "length": 0, + "hex": "" + }, + { + "type": 10, + "length": 0, + "hex": "" + }, + { + "type": 22, + "length": 33, + "hex": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c" + } + ] + }, + { + "description": "Missing offer_issuer_id and no offer_path", + "valid": false, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc" + }, + { + "description": "Second offer_path is empty", + "valid": false, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucsespjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zygszqqqqyqqqqsqqvpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsq" + }, + { + "description": "offer_chains with zero entries", + "valid": false, + "bolt12": "lno1qgqpvggrt0j7j3uzp9n549hxpu0sxlmpwe2ql5qplgwkg628wrzk5acfcskq" + }, + { + "description": "Bech32 padding exceeds 4-bit limit", + "valid": false, + "bolt12": "lno1zcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pkseseq" + } +] diff --git a/bolt12/test-vectors/signature-test.json b/bolt12/test-vectors/signature-test.json new file mode 100644 index 0000000000..00d327071e --- /dev/null +++ b/bolt12/test-vectors/signature-test.json @@ -0,0 +1,137 @@ +[ + { + "comment": "Simple n1 test, tlv1 = 1000", + "tlv": "n1", + "first-tlv": "010203e8", + "leaves": [ + { + "H(`LnLeaf`,010203e8)": "67a2a995433890d8fe0c18a1765ad19e98f1fcfeff14c13a45bbc80964a78cf7", + "H(`LnNonce`|first-tlv,tlv1-type)": "255a95f5b6b3c6997e2838dc4d9348807fb6da8eb7bbc02d30662d144718b6aa", + "H(`LnBranch`,leaf+nonce)": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + } + ], + "branches": [], + "merkle": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + }, + { + "comment": "n1 test, tlv1 = 1000, tlv2 = 1x2x3", + "tlv": "n1", + "first-tlv": "010203e8", + "leaves": [ + { + "H(`LnLeaf`,010203e8)": "67a2a995433890d8fe0c18a1765ad19e98f1fcfeff14c13a45bbc80964a78cf7", + "H(`LnNonce`|first-tlv,tlv1-type)": "255a95f5b6b3c6997e2838dc4d9348807fb6da8eb7bbc02d30662d144718b6aa", + "H(`LnBranch`,leaf+nonce)": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + }, + { + "H(`LnLeaf`,02080000010000020003)": "cc04567fcbff60d4de87afe5142de16b7401531300554838b2d1117341a4ea8d", + "H(`LnNonce`|first-tlv,tlv2-type)": "12bc15565410d8e3251a6fb1c53a2d360f39a9f65afb8403ef875016e34ff678", + "H(`LnBranch`,leaf+nonce)": "19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0" + } + ], + "branches": [ + { + "desc": "1: tlv1+nonce and tlv2+nonce", + "H(`LnBranch`,19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93)": "c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1" + } + ], + "merkle": "c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1" + }, + { + "comment": "n1 test, tlv1 = 1000, tlv2 = 1x2x3, tlv3 = 0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518, 1, 2", + "tlv": "n1", + "first-tlv": "010203e8", + "leaves": [ + { + "H(`LnLeaf`,010203e8)": "67a2a995433890d8fe0c18a1765ad19e98f1fcfeff14c13a45bbc80964a78cf7", + "H(`LnNonce`|first-tlv,1)": "255a95f5b6b3c6997e2838dc4d9348807fb6da8eb7bbc02d30662d144718b6aa", + "H(`LnBranch`,leaf+nonce)": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + }, + { + "H(`LnLeaf`,02080000010000020003)": "cc04567fcbff60d4de87afe5142de16b7401531300554838b2d1117341a4ea8d", + "H(`LnNonce`|first-tlv,2)": "12bc15565410d8e3251a6fb1c53a2d360f39a9f65afb8403ef875016e34ff678", + "H(`LnBranch`,leaf+nonce)": "19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0" + }, + { + "H(`LnLeaf`,03310266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c0351800000000000000010000000000000002)": "47da319b36d61a006e0dbcf6642fe4c822c33a6131af67dfa9293b089c5cbd27", + "H(`LnNonce`|first-tlv,3)": "068cf6e9d2db9258a6c1d3304a8f2e9d4d046ea711664c9a96960234f707a084", + "H(`LnBranch`,leaf+nonce)": "7c879819c09f1525e7bc69b84f7928180de584f92c846e01fa2daf5b17e32967" + } + ], + "branches": [ + { + "desc": "1: tlv1+nonce and tlv2+nonce", + "H(`LnBranch`,19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93)": "c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1" + }, + { + "desc": "1 and tlv3+nonce", + "H(`LnBranch`,7c879819c09f1525e7bc69b84f7928180de584f92c846e01fa2daf5b17e32967c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1)": "ab2e79b1283b0b31e0b035258de23782df6b89a38cfa7237bde69aed1a658c5d" + } + ], + "merkle": "ab2e79b1283b0b31e0b035258de23782df6b89a38cfa7237bde69aed1a658c5d" + }, + { + "comment": "invoice_request test: offer_issuer_id = Alice (privkey 0x414141...), offer_description = 'A Mathematical Treatise', offer_amount = 100, offer_currency = 'USD', invreq_payer_id = Bob (privkey 0x424242...), invreq_metadata = 0x0000000000000000", + "bolt12": "lnr1qqyqqqqqqqqqqqqqqcp4256ypqqkgzshgysy6ct5dpjk6ct5d93kzmpq23ex2ct5d9ek293pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpjkppqvjx204vgdzgsqpvcp4mldl3plscny0rt707gvpdh6ndydfacz43euzqhrurageg3n7kafgsek6gz3e9w52parv8gs2hlxzk95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu04qz3slje2rfthc89vss", + "tlv": "invoice_request", + "first-tlv": "00080000000000000000", + "leaves": [ + { + "H(`LnLeaf`,00080000000000000000)": "cd45d50b8dbb73ba995f92aa48be7c2909331998cb070572f5499bae338a03c6", + "H(`LnNonce`|first-tlv,0)": "edc13c82e89b213a5641b27f0c06c5f31ea948a0cc2fd6495120cc8590cac3f5", + "H(`LnBranch`,leaf+nonce)": "5ced451fad76ab7edc8084b84c8b5086df195b2a503c25b371e6850a280c94ab" + }, + { + "H(`LnLeaf`,0603555344)": "ae61bfe63f8fc81b7a02a962182a5b5e01501365806481d52fbdfbca915266fa", + "H(`LnNonce`|first-tlv,6)": "cc9fc57ce5e82252b6cc8908a93f012b13294a82132768e36dd767b3c3c289e8", + "H(`LnBranch`,leaf+nonce)": "a2ea87a666c1524d25132ff59883c96a118728ff76595d239f5806143e3e9c9e" + }, + { + "H(`LnLeaf`,080164)": "b4f3adb8ca4f4a4c0e7cd9e0b1cafe8634cf8a864e1a730868bdda39fbd3e336", + "H(`LnNonce`|first-tlv,8)": "376180f1ef3b7973ba4989f9391502bd78a1a8a54929fe9adcaec1dd2bfec648", + "H(`LnBranch`,leaf+nonce)": "fa0bb4f0fa2f2625c63eec9bf3a29c9aa304e64d5aa44d38e050a6bd7d6fc5c0" + }, + { + "H(`LnLeaf`,0a1741204d617468656d61746963616c205472656174697365)": "7007775409456c33c47bddd7ce946ecd5a82035f1d5a529cc90e84d146f75a6e", + "H(`LnNonce`|first-tlv,10)": "01926a0c38b4ec71d76b116eeb81ea7999706fdce24a7f5b9d67bf867fd0c4d8", + "H(`LnBranch`,leaf+nonce)": "349379beebd68fd72296e76cb2ae28554b35fa9234853956b81b24c008783230" + }, + { + "H(`LnLeaf`,162102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619)": "bdde38b7b58fa74acee1e943bbc32c04306368cb2aa513856f53f45be461051b", + "H(`LnNonce`|first-tlv,22)": "2e571571c7dd0739dbc4180bb96b7652b055f9e97f80d37337c96689990fdbaa", + "H(`LnBranch`,leaf+nonce)": "384853c9811863028876088ce34e75d784ac027fd564f103ea972cdf96236e47" + }, + { + "H(`LnLeaf`,58210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c)": "f3b92382531e261e16a0f35d65f314ae622306bbb1b206fee00d80153b76eea3", + "H(`LnNonce`|first-tlv,88)": "c31a695332d176217470b705cde5c8cd71cdb611e1f26c5a98f14c0d935c97bd", + "H(`LnBranch`,leaf+nonce)": "73e067757513706491e0da4e8077112e606da55c04239ad13ab609bc82907600" + } + ], + "branches": [ + { + "desc": "1: metadata+nonce and currency+nonce", + "H(`LnBranch`,5ced451fad76ab7edc8084b84c8b5086df195b2a503c25b371e6850a280c94aba2ea87a666c1524d25132ff59883c96a118728ff76595d239f5806143e3e9c9e)": "f0aa4611039a3a8a90dc8331fa75c9acf433be7285cac0983902aaaa8f66aaa9" + }, + { + "desc": "2: amount+nonce and descripton+nonce", + "H(`LnBranch`,349379beebd68fd72296e76cb2ae28554b35fa9234853956b81b24c008783230fa0bb4f0fa2f2625c63eec9bf3a29c9aa304e64d5aa44d38e050a6bd7d6fc5c0)": "92e6478159d6763b19c5d03a8a834e179116f89e0cec700049e5ce921f8c400e" + }, + { + "desc": "3: 1 and 2", + "H(`LnBranch`,92e6478159d6763b19c5d03a8a834e179116f89e0cec700049e5ce921f8c400ef0aa4611039a3a8a90dc8331fa75c9acf433be7285cac0983902aaaa8f66aaa9)": "432097bd1a848ab41eee3695a2c5932c4aea987b27b1a61e58ac950ecce1214a" + }, + { + "desc": "4: node_id+nonce and payer_id+nonce", + "H(`LnBranch`,384853c9811863028876088ce34e75d784ac027fd564f103ea972cdf96236e4773e067757513706491e0da4e8077112e606da55c04239ad13ab609bc82907600)": "2ac9b0261d644027939d9a7bd055cb2468b79d92c6811d56a300c6b8ff97c14d" + }, + { + "desc": "5: 3 and 4", + "H(`LnBranch`,2ac9b0261d644027939d9a7bd055cb2468b79d92c6811d56a300c6b8ff97c14d432097bd1a848ab41eee3695a2c5932c4aea987b27b1a61e58ac950ecce1214a)": "608407c18ad9a94d9ea2bcdbe170b6c20c462a7833a197621c916f78cf18e624" + } + ], + "merkle": "608407c18ad9a94d9ea2bcdbe170b6c20c462a7833a197621c916f78cf18e624", + "signature_tag": "lightninginvoice_requestsignature", + "H(signature_tag,merkle)": "aefe3aa88a69772c246dcaef75ed3e7566c08ecc4e9f995233526a5651fc34cd", + "signature": "b8f83ea3288cfd6ea510cdb481472575141e8d8744157f98562d162cc1c472526fdb24befefbdebab4dbb726bbd1b7d8aec057f8fa805187e5950d2bbe0e5642" + } +] diff --git a/bolt12/validate.go b/bolt12/validate.go index b80daaa49a..9413bb7fe2 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -615,11 +615,8 @@ func getInvreqChain(ir *InvoiceRequest) [32]byte { // Stateful or contextual checks (offer matching, path verification, unit-price // calculations) must be handled externally by the caller. // -// Signature verification is NOT performed yet: the reader MUST also reject a -// request whose Schnorr signature does not verify against invreq_payer_id, but -// that check is deferred until the merkle/signing primitives land with the -// Invoice message (see the TODO at the end of this function). Until then, a -// caller wiring this into a handler MUST verify the signature itself. +// The final check is cryptographic: the reader rejects a request whose +// BIP-340 Schnorr signature does not verify against invreq_payer_id. func ValidateInvoiceRequestRead(ir *InvoiceRequest, activeChain [32]byte, knownFeatures map[lnwire.FeatureBit]string) error { @@ -773,12 +770,7 @@ func ValidateInvoiceRequestRead(ir *InvoiceRequest, // - MUST reject the invoice request if signature is not correct as // detailed in Signature Calculation using the invreq_payer_id. - // TODO(bolt12): implement signature verification. - if !ir.Signature.IsSome() { - return ErrMissingSignature - } - - return nil + return VerifyInvoiceRequest(ir) } // getInvoiceRequestOfferChains returns the chains an invoice request's mirrored @@ -1671,14 +1663,14 @@ type InvoiceFeatureCatalogues struct { // ValidateInvoiceRead validates an invoice against the BOLT 12 reader // requirements, running the stateless structural checks against activeChain -// (the chain the reader supports). +// (the chain the reader supports). The final check is cryptographic: the +// reader rejects an invoice whose BIP-340 Schnorr signature does not verify +// against invoice_node_id. // -// Note: This only performs stateless structural checks. Cryptographic Schnorr -// signature verification and identity-path binding are deferred to the caller -// (see the TODO at the end of this function). Additionally, while it verifies -// that at least one usable path is present, downstream callers must re-apply -// the same features.Blinded filter at path selection time (via -// Invoice.UsablePaths) to avoid selecting paths with unknown required features. +// Note: while it verifies that at least one usable path is present, +// downstream callers must re-apply the same features.Blinded filter at path +// selection time (via Invoice.UsablePaths) to avoid selecting paths with +// unknown required features. func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, features InvoiceFeatureCatalogues) error { // - MUST reject the invoice if invoice_amount is not present. @@ -1815,14 +1807,6 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, return err } - // - MUST reject the invoice if signature is not a valid signature using - // invoice_node_id as described in Signature Calculation. - // TODO(bolt12): implement signature verification. For now only - // presence is enforced, mirroring ValidateInvoiceRequestRead. - if !inv.Signature.IsSome() { - return ErrMissingSignature - } - // - SHOULD prefer to use earlier invoice_paths over later ones if it // has no other reason for preference. // - if invoice_features contains the MPP/compulsory bit: MUST pay @@ -1841,5 +1825,7 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // ValidateInvoiceAgainstRequest; the fallback ignore rules by // UsableFallbackAddresses. - return nil + // - MUST reject the invoice if signature is not a valid signature using + // invoice_node_id as described in Signature Calculation. + return VerifyInvoice(inv) } diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index f0650e6d61..af90248f40 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -1,6 +1,8 @@ package bolt12 import ( + "bytes" + "encoding/hex" "math" "testing" "time" @@ -599,6 +601,19 @@ func TestValidateOfferRead(t *testing.T) { activeChain: bitcoinMainnetGenesisHash, wantErr: nil, }, + { + name: "unexpired offer (future expiry)", + mutate: func(o *Offer) { + expiry := uint64(now.Unix()) + 3600 + o.OfferAbsoluteExpiry = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType14]( + TUint64(expiry), + ), + ) + }, + activeChain: bitcoinMainnetGenesisHash, + wantErr: nil, + }, { name: "symmetric explicit bitcoin chain list " + "(inverted-default invariant)", @@ -690,34 +705,67 @@ func addAmountAndDescription(o *Offer) { } // validInvoiceRequest is the spec-minimal happy-path invoice request that -// each table row mutates to isolate the rule under test. +// each table row mutates to isolate the rule under test. The request is +// encoded, decoded, and signed with Bob's key, so reader validation sees +// the same wire form a peer would send. func validInvoiceRequest(t *testing.T) *InvoiceRequest { t.Helper() - ir := &InvoiceRequest{} + priv, pub := bobKey() - privKey, err := btcec.NewPrivateKey() + ir := &InvoiceRequest{ + OfferDescription: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType10]( + tlv.Blob("description"), + ), + ), + InvreqPayerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](pub), + ), + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + tlv.Blob("metadata"), + ), + ), + InvreqAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType82, TUint64]( + TUint64(1000), + ), + ), + } + + encoded, err := ir.Encode() require.NoError(t, err) - ir.InvreqPayerID = tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType88](privKey.PubKey()), - ) + decoded, err := DecodeInvoiceRequest(encoded) + require.NoError(t, err) - ir.InvreqMetadata = tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType0]( - []byte("metadata"), - ), + sig, err := SignInvoiceRequest(decoded, priv) + require.NoError(t, err) + decoded.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), ) - ir.InvreqAmount = tlv.SomeRecordT( - tlv.NewRecordT[tlv.TlvType82, TUint64](1000), - ) + return decoded +} - ir.Signature = tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType240]([64]byte{0x01}), - ) +// TestValidateInvoiceRequestRead verifies that a freshly signed, decoded +// invoice request passes reader validation. +func TestValidateInvoiceRequestRead(t *testing.T) { + t.Parallel() + + ir := validInvoiceRequest(t) + + err := ValidateInvoiceRequestRead(ir, bitcoinMainnetGenesisHash, nil) + require.NoError(t, err) - return ir + // A request without a signature must be rejected. + irNoSig := *ir + irNoSig.Signature = tlv.OptionalRecordT[tlv.TlvType240, [64]byte]{} + err = ValidateInvoiceRequestRead( + &irNoSig, bitcoinMainnetGenesisHash, nil, + ) + require.ErrorIs(t, err, ErrMissingSignature) } // TestValidateInvoiceRequestWrite pins the BOLT 12 writer-side MUSTs so a @@ -1219,6 +1267,11 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) { mutate func(*InvoiceRequest) known map[lnwire.FeatureBit]string wantErr error + + // resign re-signs the mutated request before validation. + // Rows that mutate a signed field and still expect success + // need a fresh signature over the mutated records. + resign bool }{ { name: "missing payer id", @@ -1446,6 +1499,7 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) { 0: "test_feature", }, wantErr: nil, + resign: true, }, } @@ -1456,10 +1510,13 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) { ir := validInvoiceRequest(t) tc.mutate(ir) - if tc.name == "known even feature bit accepted" { + if tc.resign { + priv, _ := bobKey() + sig, err := SignInvoiceRequest(ir, priv) + require.NoError(t, err) ir.Signature = tlv.SomeRecordT( tlv.NewPrimitiveRecord[tlv.TlvType240]( - [64]byte{0x01}, + sig, ), ) } @@ -1942,7 +1999,7 @@ func TestValidateInvoiceRead(t *testing.T) { func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) { t.Parallel() - _, pub := bobKey() + priv, pub := bobKey() _, intro := aliceKey() _, blinding := bobKey() @@ -1983,17 +2040,22 @@ func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) { BlindedPayInfos{Infos: []BlindedPayInfo{{}}}, ), ), - Signature: tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte]( - [64]byte{}, - ), - ), } // An unknown odd type at 241 sits inside the signature range and must - // be ignored, not rejected as out-of-range or unknown-even. + // be ignored, not rejected as out-of-range or unknown-even. It is + // excluded from the signature's Merkle root, so signing is unaffected + // by it. inv.decodedTLVs = tlv.TypeMap{241: nil} + // Sign with the fixture's node id (Bob) so the read path's signature + // check accepts the invoice. + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), + ) + err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, @@ -2572,11 +2634,6 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { t.Parallel() inv := validInvoice(t) - inv.Signature = tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType240]( - [64]byte{}, - ), - ) // Set MPP required (bit 16, even/required) fv := *lnwire.NewRawFeatureVector(lnwire.MPPRequired) @@ -2584,8 +2641,17 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { tlv.NewRecordT[tlv.TlvType174](fv), ) + // Sign with the fixture's node id (Bob) so the read + // path's signature check accepts the invoice. + priv, _ := bobKey() + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), + ) + // An unknown required bit must be rejected. - err := ValidateInvoiceRead( + err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, ) @@ -2608,11 +2674,6 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { t.Parallel() inv := validInvoice(t) - inv.Signature = tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType240]( - [64]byte{}, - ), - ) // Set an even required feature bit on the path's features (e.g. // bit 16). @@ -2625,9 +2686,18 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { }), ) + // Sign with the fixture's node id (Bob) so the read + // path's signature check accepts the invoice. + priv, _ := bobKey() + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), + ) + // If there are no known features in the catalogue, there are // zero usable paths and we expect ErrNoUsablePaths. - err := ValidateInvoiceRead( + err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, ) @@ -2774,3 +2844,186 @@ func TestValidateInvoiceErrorWrite(t *testing.T) { }) } } + +// TestValidateOfferReadVectors parses and evaluates all test vectors in +// offers-test.json to verify that every valid vector passes all decoding and +// validation stages and every invalid vector is rejected at some stage. +func TestValidateOfferReadVectors(t *testing.T) { + t.Parallel() + + vectors := loadOffersVectors(t) + + // Far-future time so expiry checks don't interfere with structural + // tests. + now := farFutureNow() + + for _, tc := range vectors { + t.Run(tc.Description, func(t *testing.T) { + t.Parallel() + + _, tlvBytes, bech32Err := Decode(tc.Bolt12) + if bech32Err != nil { + if tc.Valid { + require.NoError( + t, bech32Err, + "valid offer should pass "+ + "bech32 decode", + ) + } + + return + } + + offer, decodeErr := decodeOffer(tlvBytes) + if decodeErr != nil { + if tc.Valid { + require.NoError( + t, decodeErr, + "valid offer should pass "+ + "TLV decode", + ) + } + + return + } + + // If the offer specifies a chain, use that for + // validation, otherwise default to mainnet. This is + // necessary because some test vectors are for different + // chains. + activeChain := bitcoinMainnetGenesisHash + if c := getOfferChains(offer); len(c) > 0 { + activeChain = c[0] + } + + valErr := ValidateOfferRead( + offer, now, activeChain, nil, + ) + + if tc.Valid { + require.NoError( + t, valErr, + "valid offer should pass", + ) + + // Verify expected fields are present in decoded + // TLV map with matching length and hex + // encoding. + haveRecords := offer.AllRecords() + require.Equal( + t, len(tc.Fields), len(haveRecords), + "record count mismatch in valid offer", + ) + for _, expectedField := range tc.Fields { + rec, found := findRecord( + haveRecords, expectedField.Type, + ) + require.True( + t, found, + "field type %d missing in "+ + "valid offer", + expectedField.Type, + ) + + var buf bytes.Buffer + require.NoError(t, rec.Encode(&buf)) + gotValBytes := buf.Bytes() + + require.Equal( + t, expectedField.Length, + uint64(len(gotValBytes)), + "field type %d length mismatch", + expectedField.Type, + ) + require.Equal( + t, expectedField.Hex, + hex.EncodeToString(gotValBytes), + "field type %d hex mismatch", + expectedField.Type, + ) + } + + return + } + + require.Error( + t, valErr, + "invalid offer should fail validation: %s", + tc.Description, + ) + }) + } +} + +// TestOfferVectorsLayerCensus verifies that every invalid vector in +// offers-test.json is rejected at the expected layer, pinning the distribution +// of failure modes across bech32 decode, TLV decode, and semantic validation. +func TestOfferVectorsLayerCensus(t *testing.T) { + t.Parallel() + + vectors := loadOffersVectors(t) + now := farFutureNow() + + var ( + bech32Rejections int + tlvRejections int + valRejections int + falseAccepts int + ) + + for _, tc := range vectors { + if tc.Valid { + continue + } + + _, tlvBytes, bech32Err := Decode(tc.Bolt12) + if bech32Err != nil { + bech32Rejections++ + continue + } + + offer, decodeErr := decodeOffer(tlvBytes) + if decodeErr != nil { + tlvRejections++ + continue + } + + valErr := ValidateOfferRead( + offer, now, bitcoinMainnetGenesisHash, nil, + ) + if valErr != nil { + valRejections++ + continue + } + + t.Errorf( + "invalid vector falsely accepted: %s", + tc.Description, + ) + falseAccepts++ + } + + require.Equal( + t, 2, bech32Rejections, "bech32 rejections mismatch", + ) + require.Equal( + t, 16, tlvRejections, "TLV decode rejections mismatch", + ) + require.Equal( + t, 15, valRejections, "validation rejections mismatch", + ) + require.Equal( + t, 0, falseAccepts, "false accepts count mismatch", + ) +} + +// findRecord searches a slice of TLV records for a record with the given type. +func findRecord(records []tlv.Record, typ uint64) (*tlv.Record, bool) { + for i := range records { + if uint64(records[i].Type()) == typ { + return &records[i], true + } + } + + return nil, false +} diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 750a20069c..e1b52942be 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -134,8 +134,27 @@ codec](https://github.com/lightningnetwork/lnd/pull/10958): add the `invoice_error` TLV message to `bolt12/` for onion-message replies. +* [BOLT 12 string codec](https://github.com/lightningnetwork/lnd/pull/11001): + add checksumless bech32 encoding/decoding for BOLT 12 `lno`, `lnr`, and `lni` + strings with continuation line handling. + +* [BOLT 12 Merkle tree and BIP-340 + signatures](https://github.com/lightningnetwork/lnd/pull/11061): add Merkle + tree construction over TLV records and BIP-340 Schnorr message signatures for + invoice requests and invoices, and verify the signature on read so a decoded + message with an invalid signature is rejected. + ## Testing +* [BOLT 12 spec test vectors](https://github.com/lightningnetwork/lnd/pull/11001): + add spec test vectors for offer decoding and format string parsing in + `bolt12/test-vectors/`. + +* [BOLT 12 signature test + vectors](https://github.com/lightningnetwork/lnd/pull/11061): add spec test + vectors pinning Merkle tree construction and BIP-340 signature verification + in `bolt12/test-vectors/`. + ## Database ## Code Health diff --git a/go.mod b/go.mod index 16e0922c6e..da81cc378c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/btcsuite/btcd v0.26.0 github.com/btcsuite/btcd/address/v2 v2.0.0 github.com/btcsuite/btcd/btcec/v2 v2.5.0 + github.com/btcsuite/btcd/btcutil v1.2.0 github.com/btcsuite/btcd/btcutil/v2 v2.0.0 github.com/btcsuite/btcd/chaincfg/v2 v2.0.0 github.com/btcsuite/btcd/chainhash/v2 v2.0.0 diff --git a/go.sum b/go.sum index b0772e0a9c..de75029950 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/btcsuite/btcd/address/v2 v2.0.0 h1:UVu8Hal6Siu4XastFe+JX5JkeBYONbDUIY github.com/btcsuite/btcd/address/v2 v2.0.0/go.mod h1:htJK1AtaeK3bKNfZY63ep2oN8LbrI6qvmPGe1vekb3I= github.com/btcsuite/btcd/btcec/v2 v2.5.0 h1:KioMXOWa76b86sTZZOmbzv/ldaQCmB8KFAyn5PbB8E8= github.com/btcsuite/btcd/btcec/v2 v2.5.0/go.mod h1:+K/MYXcLBtHEQjRbjHuJChuybk4LCgjdjgRwil+e+Kk= +github.com/btcsuite/btcd/btcutil v1.2.0 h1:p3+S2g3Q+7G5NOh4Ji+2UrBOrg5Z0Q4ykzShWG1Dhgs= +github.com/btcsuite/btcd/btcutil v1.2.0/go.mod h1:/Taflm113pYjUpbWKKQEfa6XOtI/+WS8awxeMZpY75k= github.com/btcsuite/btcd/btcutil/v2 v2.0.0 h1:77pgf/4tjWaSBLdos8yiWVWL3rSphxWNqkLwcyONExA= github.com/btcsuite/btcd/btcutil/v2 v2.0.0/go.mod h1:ZF8MMdsx1JGgvHJUanxbigekSO+8bN/ai34LBk/lg3c= github.com/btcsuite/btcd/chaincfg/v2 v2.0.0 h1:M/RTtXfXA9odC1RUEOyZFXj/NXKVHPYZXVjb60xTOok=