Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ breakdown of the raw request, and the decoded JSON.
## Not yet supported

BOLT 12 invoice requests (`lnr`) and payer proofs (`lnp`) are recognised and reported as
unsupported rather than mis-parsed. Both are signed forms, so they need the BIP-340 Schnorr
and merkle-root layer that isn't built yet.
unsupported rather than mis-parsed. The merkle-root and BIP-340 Schnorr layers both forms
need are built, and payer proof decoding and verification are implemented; neither prefix is
wired into the page yet.

BOLT 12 invoices have no bech32 prefix at all — they travel over onion messages — so there
is nothing for a string decoder to accept.
BOLT 12 defines human-readable prefixes for those three forms only. Invoices normally travel
over onion messages rather than as strings, and the spec defines no prefix for them — though
its own payer proof test vectors serialise invoices as `lni1…`, as some implementations do.

## URL parameter

Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ <h1>Lightning Payment Request Decoder</h1>
<script src="js/address.js"></script>
<script src="js/bolt11.js"></script>
<script src="js/bolt12.js"></script>
<script src="js/bolt12proof.js"></script>
<script src="js/dispatch.js"></script>
<script src="js/render.js"></script>
</html>
216 changes: 216 additions & 0 deletions js/bolt12proof.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// BOLT 12 payer proof decoding: partial merkle reconstruction and two signatures.

const INVREQ_METADATA_TYPE = 0n;
const SIGNATURE_TYPE = 240n;
const PROOF_SIGNATURE_TYPE = 241n;
const PROOF_PREIMAGE_TYPE = 1001n;
const PROOF_OMITTED_TLVS_TYPE = 1002n;
const PROOF_MISSING_HASHES_TYPE = 1003n;
const PROOF_LEAF_HASHES_TYPE = 1004n;
const PROOF_NOTE_TYPE = 1005n;

const PREIMAGE_LENGTH = 32;
const HASH_LENGTH = 32;

// Signature TLV types are excluded from the tree they sign.
const SIGNATURE_RANGE_START = 240n;
const SIGNATURE_RANGE_END = 1000n;

// The ranges a proof's own tree covers: the invoice fields and the custom range above them.
const PROOF_TREE_RANGES = [[1n, 239n], [1000000000n, 3999999999n]];

const MARKER_CUSTOM_RANGE_START = 1000000000n;
const MARKER_LOW_RANGE_END = 239n;

const PROOF_FIELD_NAMES = new Map([
[0n, 'invreq_metadata'],
[2n, 'offer_chains'], [4n, 'offer_metadata'], [6n, 'offer_currency'], [8n, 'offer_amount'],
[10n, 'offer_description'], [12n, 'offer_features'], [14n, 'offer_absolute_expiry'],
[16n, 'offer_paths'], [18n, 'offer_issuer'], [20n, 'offer_quantity_max'],
[22n, 'offer_issuer_id'],
[80n, 'invreq_chain'], [82n, 'invreq_amount'], [84n, 'invreq_features'],
[86n, 'invreq_quantity'], [88n, 'invreq_payer_id'], [89n, 'invreq_payer_note'],
[90n, 'invreq_paths'], [91n, 'invreq_bip_353_name'],
[160n, 'invoice_paths'], [162n, 'invoice_blindedpay'], [164n, 'invoice_created_at'],
[166n, 'invoice_relative_expiry'], [168n, 'invoice_payment_hash'], [170n, 'invoice_amount'],
[172n, 'invoice_fallbacks'], [174n, 'invoice_features'], [176n, 'invoice_node_id'],
[240n, 'signature'], [241n, 'proof_signature'],
[1001n, 'proof_preimage'], [1002n, 'proof_omitted_tlvs'], [1003n, 'proof_missing_hashes'],
[1004n, 'proof_leaf_hashes'], [1005n, 'proof_note']
]);

const REQUIRED_PROOF_TYPES = [
[88n, 'invreq_payer_id'],
[168n, 'invoice_payment_hash'],
[176n, 'invoice_node_id'],
[240n, 'signature'],
[241n, 'proof_signature'],
[1001n, 'proof_preimage'],
[1003n, 'proof_missing_hashes'],
[1004n, 'proof_leaf_hashes']
];

// Types at or above a billion are the self-assigned experimental range, so they have no
// spec name to look up.
function proofFieldName(type) {
let name = PROOF_FIELD_NAMES.get(type);
if (name !== undefined) return name;
if (type >= MARKER_CUSTOM_RANGE_START) return 'experimental_' + type.toString();
return 'unknown_' + type.toString();
}

function isSignatureType(type) {
return type >= SIGNATURE_RANGE_START && type <= SIGNATURE_RANGE_END;
}

function inProofTreeRanges(type) {
return PROOF_TREE_RANGES.some(range => type >= range[0] && type <= range[1]);
}

// Splits a value into 32-byte hashes, requiring an exact multiple.
function splitHashes(bytes, what) {
if (bytes.length === 0 || bytes.length % HASH_LENGTH !== 0) {
throw new Error('Malformed request: ' + what + ' must be a whole number of 32-byte hashes');
}
let hashes = [];
for (let at = 0; at < bytes.length; at += HASH_LENGTH) {
hashes.push(bytes.slice(at, at + HASH_LENGTH));
}
return hashes;
}

function readMarkers(bytes) {
let reader = byteReader(bytes);
let markers = [];
while (reader.remaining() > 0) {
markers.push(reader.readBigSize('proof_omitted_tlvs entry'));
}
return markers;
}

// A marker hides a real type number while preserving order. Each is one greater than the
// previous marker, one greater than an included type, or the start of the custom range
// after 239.
function requireValidMarkers(markers, includedTypes) {
let previous = 0n;
for (const marker of markers) {
if (marker === 0n) {
throw new Error('Malformed request: proof_omitted_tlvs contains 0');
}
if (marker <= previous) {
throw new Error('Malformed request: proof_omitted_tlvs must strictly increase');
}
if (!inProofTreeRanges(marker)) {
throw new Error('Malformed request: proof_omitted_tlvs entry ' + marker
+ ' is outside 1 to 239 and 1000000000 to 3999999999');
}
if (includedTypes.has(marker)) {
throw new Error('Malformed request: proof_omitted_tlvs entry ' + marker
+ ' is the type of an included field');
}
let sequential = marker === previous + 1n;
let afterIncluded = includedTypes.has(marker - 1n);
let entersCustomRange = marker === MARKER_CUSTOM_RANGE_START
&& previous === MARKER_LOW_RANGE_END;
if (!sequential && !afterIncluded && !entersCustomRange) {
throw new Error('Malformed request: proof_omitted_tlvs entry ' + marker
+ ' does not follow the previous entry or an included field');
}
previous = marker;
}
}

// Rebuilds the invoice's merkle root. Present fields hash their own bytes against a nonce
// supplied by the proof, because the nonce tag needs invreq_metadata, which is absent.
function reconstructInvoiceRoot(treeRecords, leafHashes, missingHashes, markers) {
if (leafHashes.length !== treeRecords.length) {
throw new Error('Malformed request: proof_leaf_hashes has ' + leafHashes.length
+ ' hashes for ' + treeRecords.length + ' disclosed fields');
}

let leaves = treeRecords.map((record, at) => ({
key: record.type,
hash: branchNode(leafHash(record.tlv), leafHashes[at])
}));
// Type 0 is always omitted, so it is implied rather than carried in the markers.
let absent = [INVREQ_METADATA_TYPE].concat(markers).map(key => ({ key: key }));

let slots = leaves.concat(absent);
slots.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
return reconstructRoot(slots, missingHashes);
}

function decodePayerProof(request) {
let split = bolt12ToBytes(request);
if (split.prefix !== 'lnp') {
throw new Error('Malformed request: expected an lnp payer proof, got ' + split.prefix);
}

let records = parseTlvStream(split.bytes);
let byType = new Map(records.map(record => [record.type, record]));

for (const [type, name] of REQUIRED_PROOF_TYPES) {
if (!byType.has(type)) {
throw new Error('Malformed request: payer proof is missing ' + name);
}
}
if (byType.has(INVREQ_METADATA_TYPE)) {
throw new Error('Malformed request: payer proof must not include invreq_metadata');
}

let preimage = byType.get(PROOF_PREIMAGE_TYPE).value;
if (preimage.length !== PREIMAGE_LENGTH) {
throw new Error('Malformed request: proof_preimage must be 32 bytes');
}
let paymentHash = byType.get(168n).value;
if (byteArrayToHexString(sha256(preimage)) !== byteArrayToHexString(paymentHash)) {
throw new Error('Malformed request: proof_preimage does not hash to invoice_payment_hash');
}

let treeRecords = records.filter(record => inProofTreeRanges(record.type));
let includedTypes = new Set(treeRecords.map(record => record.type));
let markers = byType.has(PROOF_OMITTED_TLVS_TYPE)
? readMarkers(byType.get(PROOF_OMITTED_TLVS_TYPE).value)
: [];
requireValidMarkers(markers, includedTypes);

let leafHashes = splitHashes(byType.get(PROOF_LEAF_HASHES_TYPE).value, 'proof_leaf_hashes');
let missingHashes = splitHashes(byType.get(PROOF_MISSING_HASHES_TYPE).value,
'proof_missing_hashes');

let invoiceRoot = reconstructInvoiceRoot(treeRecords, leafHashes, missingHashes, markers);
let nodeId = byType.get(176n).value;
let invoiceSighash = signatureHash('invoice', 'signature', invoiceRoot);
if (!verifyBolt12Signature(byType.get(SIGNATURE_TYPE).value, invoiceSighash, nodeId)) {
throw new Error('Malformed request: signature does not verify against invoice_node_id');
}

let payerId = byType.get(88n).value;
let proofSigned = Array.from(records).filter(record => !isSignatureType(record.type));
let proofRoot = merkleRoot(proofSigned.map(record => record.tlv));
let proofSighash = signatureHash('payer_proof', 'proof_signature', proofRoot);
if (!verifyBolt12Signature(byType.get(PROOF_SIGNATURE_TYPE).value, proofSighash, payerId)) {
throw new Error('Malformed request: proof_signature does not verify against invreq_payer_id');
}

return {
prefix: split.prefix,
payment_preimage: byteArrayToHexString(preimage),
payment_hash: byteArrayToHexString(paymentHash),
payer_id: byteArrayToHexString(payerId),
node_id: byteArrayToHexString(nodeId),
note: byType.has(PROOF_NOTE_TYPE)
? bytesToUtf8String(byType.get(PROOF_NOTE_TYPE).value)
: undefined,
invoice_merkle_root: byteArrayToHexString(invoiceRoot),
proof_merkle_root: byteArrayToHexString(proofRoot),
disclosed_types: treeRecords.map(record => Number(record.type)),
withheld_count: markers.length + 1,
raw_records: Array.from(records).map(record => ({
type: Number(record.type),
name: proofFieldName(record.type),
length: record.length,
hex: byteArrayToHexString(record.value)
}))
};
}
56 changes: 56 additions & 0 deletions js/merkle.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,59 @@ function merkleRoot(tlvs) {
function signatureHash(messageName, fieldName, root) {
return bolt12TaggedHash(textToByteArray('lightning' + messageName + fieldName), root);
}

// Marks a subtree whose every leaf is absent.
const OMITTED_SUBTREE = { omitted: true };

// Pairs leaves into internal nodes by the same rule as combineLevel, retaining the
// structure so a tree with absent leaves can be walked.
function buildTree(leaves) {
if (leaves.length === 0) {
throw new Error('Malformed request: a merkle tree needs at least one tlv record');
}
let nodes = leaves;
while (nodes.length > 1) {
let next = [];
for (let i = 0; i < nodes.length; i += 2) {
next.push(i + 1 < nodes.length ? { left: nodes[i], right: nodes[i + 1] } : nodes[i]);
}
nodes = next;
}
return nodes[0];
}

// Walks post-order depth-first, smallest to largest. Where one side of a node is an
// absent subtree and the other is not, the absent side's hash comes from nextHash.
function resolveNode(node, nextHash) {
if (node.left === undefined) {
return node.hash === undefined ? OMITTED_SUBTREE : node.hash;
}
let left = resolveNode(node.left, nextHash);
let right = resolveNode(node.right, nextHash);
if (left === OMITTED_SUBTREE && right === OMITTED_SUBTREE) return OMITTED_SUBTREE;
if (left === OMITTED_SUBTREE) return branchNode(nextHash(), right);
if (right === OMITTED_SUBTREE) return branchNode(left, nextHash());
return branchNode(left, right);
}

// Rebuilds a root from leaves where some are absent. Each leaf is either { hash } for a
// present record or {} for an absent one. Every hash in missingHashes must be consumed.
function reconstructRoot(leaves, missingHashes) {
let at = 0;
let nextHash = function () {
if (at >= missingHashes.length) {
throw new Error('Malformed request: too few proof_missing_hashes to rebuild the tree');
}
return missingHashes[at++];
};

let root = resolveNode(buildTree(leaves), nextHash);
if (root === OMITTED_SUBTREE) {
throw new Error('Malformed request: every tlv in the tree is omitted');
}
if (at !== missingHashes.length) {
throw new Error('Malformed request: ' + (missingHashes.length - at)
+ ' unused proof_missing_hashes');
}
return root;
}
19 changes: 19 additions & 0 deletions test/extract-vectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// test/vectors/format-string.json BOLT 12 string format
// test/vectors/offers.json BOLT 12 offers
// test/vectors/signature.json BOLT 12 merkle trees and signature hashes
// test/vectors/payer-proof.json BOLT 12 payer proofs
// test/vectors/bip340.json BIP-340 Schnorr, from the bitcoin/bips csv
// test/vectors/bigsize.json BigSize, from a fenced block in BOLT 1

Expand Down Expand Up @@ -135,6 +136,23 @@ async function writeBigSize() {
return `bigsize.json: ${merged.length} vectors from ${blocks.length} block(s)`;
}

// --- BOLT 12 payer proofs: an object rather than an array ---------------------

async function writePayerProof() {
const source = 'bolt12/payer-proof-test.json';
const parsed = JSON.parse(await fetchText(source));
for (const key of ['payer_secret', 'keys', 'valid_vectors', 'invalid_vectors']) {
if (parsed[key] === undefined) throw new Error(`${source} has no "${key}"`);
}
if (!parsed.valid_vectors.length || !parsed.invalid_vectors.length) {
throw new Error(`${source} looks wrong: ${parsed.valid_vectors.length} valid, `
+ `${parsed.invalid_vectors.length} invalid`);
}
fs.writeFileSync(path.join(OUT_DIR, 'payer-proof.json'), JSON.stringify(parsed, null, 2) + '\n');
return `payer-proof.json: ${parsed.valid_vectors.length} valid, `
+ `${parsed.invalid_vectors.length} invalid`;
}

// --- BIP-340: the Schnorr suite BOLT 12 signatures are defined against ----------

async function writeBip340() {
Expand Down Expand Up @@ -186,6 +204,7 @@ async function writeBip340() {
await writeJson('bolt12/format-string-test.json', 'format-string.json', 12),
await writeJson('bolt12/offers-test.json', 'offers.json', 50),
await writeJson('bolt12/signature-test.json', 'signature.json', 4),
await writePayerProof(),
await writeBip340(),
await writeBigSize()
];
Expand Down
2 changes: 1 addition & 1 deletion test/load.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const vm = require('vm');

const SOURCES = ['utils.js', 'bech32.js', 'bytes.js', 'sha256.js', 'merkle.js',
'vendor/secp256k1.js', 'bolt12sig.js', 'address.js', 'bolt11.js', 'bolt12.js',
'dispatch.js'];
'bolt12proof.js', 'dispatch.js'];

function load() {
const dir = path.join(__dirname, '..', 'js');
Expand Down
Loading
Loading