diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-26 22:43:41 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-26 22:43:41 +0000 |
| commit | 3fe9280fc11bc4ceaff3bb21533224b7b3e88da7 (patch) | |
| tree | 8179a95dbede51a2b3910b3c0bacae48c8c3b2d0 /src | |
| parent | bbe04dc8c96b4c4766d2771d3438233bf82687c0 (diff) | |
btfaddr: reject descriptors whose onion endpoint is not signed
Upstream 1.2.19 signs the onion endpoint only under the optional sig3, and
VerifyDescriptor treated sig3 failure as 'drop the onion, accept the rest'.
Any relay could therefore strip an onion endpoint down to a rendezvous-only
descriptor (or, under /oniononly, cut the peer off) by deleting one field.
Now an onion field must arrive with a valid sig3 over the V3 digest or the
whole descriptor is rejected. Honest 1.2.19 writers always emit both fields,
so interop is preserved. The full two-field strip still verifies -- it is
indistinguishable from a peer that never had an onion -- and needs the onion
bound into the primary sig, which only upstream can do without breaking every
1.2.18/1.2.19 verifier; reported as an upstream protocol defect.
test_btfaddr flips the two upstream checks that enshrined the soft drop and
adds unsigned-onion and full-strip cases.
Diffstat (limited to 'src')
| -rw-r--r-- | src/btfaddr.cpp | 40 | ||||
| -rw-r--r-- | src/test_btfaddr.cpp | 19 |
2 files changed, 41 insertions, 18 deletions
diff --git a/src/btfaddr.cpp b/src/btfaddr.cpp index ce2ac86..502220e 100644 --- a/src/btfaddr.cpp +++ b/src/btfaddr.cpp | |||
| @@ -319,20 +319,32 @@ bool VerifyDescriptor(void* ctxv, const std::string& jsonStr, | |||
| 319 | std::string onion; | 319 | std::string onion; |
| 320 | if (v.contains("onion")) | 320 | if (v.contains("onion")) |
| 321 | { | 321 | { |
| 322 | if (v["onion"].is_string() && v.contains("sig3") && v["sig3"].is_string()) | 322 | // A descriptor that carries an onion endpoint must prove it. The |
| 323 | { | 323 | // endpoint is covered by sig3 and only sig3, so any failure here -- |
| 324 | std::string onionNorm; | 324 | // missing sig3, bad hex, unnormalised endpoint, bad signature -- |
| 325 | std::string onionRaw = v["onion"].get<std::string>(); | 325 | // means the descriptor was tampered with, and a tampered |
| 326 | if (NormalizeOnionEndpoint(onionRaw, onionNorm) && onionNorm == onionRaw) | 326 | // descriptor is rejected outright. Upstream dropped just the onion |
| 327 | { | 327 | // and accepted the rest, which let any relay convert an onion |
| 328 | unsigned char sig3[64]; | 328 | // descriptor into a rendezvous-only one by deleting a field. |
| 329 | unsigned char digest3[32]; | 329 | // |
| 330 | DescriptorDigestV3(expect_pubkey, enc, meeting_node, onionNorm, created, digest3); | 330 | // Residual, by design: removing BOTH "onion" and "sig3" still |
| 331 | if (HexDecode(v["sig3"].get<std::string>(), sig3, 64) && | 331 | // verifies, because the result is indistinguishable from a peer |
| 332 | secp256k1_schnorrsig_verify(ctx, sig3, digest3, 32, &xpub)) | 332 | // that never had an onion endpoint. Closing that requires binding |
| 333 | onion = onionNorm; | 333 | // the onion into the primary sig (the V2 digest), which every |
| 334 | } | 334 | // upstream 1.2.18/1.2.19 verifier would reject -- an upstream |
| 335 | } | 335 | // protocol change, not something this fork can do unilaterally. |
| 336 | if (!v["onion"].is_string()) return false; | ||
| 337 | if (!v.contains("sig3") || !v["sig3"].is_string()) return false; | ||
| 338 | std::string onionNorm; | ||
| 339 | std::string onionRaw = v["onion"].get<std::string>(); | ||
| 340 | if (!NormalizeOnionEndpoint(onionRaw, onionNorm) || onionNorm != onionRaw) | ||
| 341 | return false; | ||
| 342 | unsigned char sig3[64]; | ||
| 343 | unsigned char digest3[32]; | ||
| 344 | DescriptorDigestV3(expect_pubkey, enc, meeting_node, onionNorm, created, digest3); | ||
| 345 | if (!HexDecode(v["sig3"].get<std::string>(), sig3, 64)) return false; | ||
| 346 | if (!secp256k1_schnorrsig_verify(ctx, sig3, digest3, 32, &xpub)) return false; | ||
| 347 | onion = onionNorm; | ||
| 336 | } | 348 | } |
| 337 | 349 | ||
| 338 | memcpy(out.pubkey, expect_pubkey, 32); | 350 | memcpy(out.pubkey, expect_pubkey, 32); |
diff --git a/src/test_btfaddr.cpp b/src/test_btfaddr.cpp index 8de426e..9d33d63 100644 --- a/src/test_btfaddr.cpp +++ b/src/test_btfaddr.cpp | |||
| @@ -133,12 +133,23 @@ int main() | |||
| 133 | std::string d3 = btf::SignDescriptor(ctx, sk, ENC_HEX, "node-1", 5, ONION); | 133 | std::string d3 = btf::SignDescriptor(ctx, sk, ENC_HEX, "node-1", 5, ONION); |
| 134 | json j3 = json::parse(d3); | 134 | json j3 = json::parse(d3); |
| 135 | j3["onion"] = "bcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcde.onion:8433"; | 135 | j3["onion"] = "bcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcde.onion:8433"; |
| 136 | CHECK(btf::VerifyDescriptor(ctx, j3.dump(), pk, o) && o.onion.empty(), | 136 | CHECK(!btf::VerifyDescriptor(ctx, j3.dump(), pk, o), |
| 137 | "tampered onion ignored rather than trusted"); | 137 | "tampered onion rejects the descriptor"); |
| 138 | json j4 = json::parse(d3); | 138 | json j4 = json::parse(d3); |
| 139 | j4["sig3"] = std::string(128, '0'); | 139 | j4["sig3"] = std::string(128, '0'); |
| 140 | CHECK(btf::VerifyDescriptor(ctx, j4.dump(), pk, o) && o.onion.empty(), | 140 | CHECK(!btf::VerifyDescriptor(ctx, j4.dump(), pk, o), |
| 141 | "bad onion signature ignored rather than trusted"); | 141 | "bad onion signature rejects the descriptor"); |
| 142 | json j5 = json::parse(d3); | ||
| 143 | j5.erase("sig3"); | ||
| 144 | CHECK(!btf::VerifyDescriptor(ctx, j5.dump(), pk, o), | ||
| 145 | "unsigned onion rejects the descriptor"); | ||
| 146 | // Removing both fields still verifies: the result is indistinguishable | ||
| 147 | // from a peer that never had an onion endpoint. That residual strip | ||
| 148 | // needs the onion bound into the primary sig, an upstream change. | ||
| 149 | json j6 = json::parse(d3); | ||
| 150 | j6.erase("onion"); j6.erase("sig3"); | ||
| 151 | CHECK(btf::VerifyDescriptor(ctx, j6.dump(), pk, o) && o.onion.empty(), | ||
| 152 | "full strip indistinguishable from no-onion (documented residual)"); | ||
| 142 | } | 153 | } |
| 143 | 154 | ||
| 144 | printf("full_resolve_chain_from_address\n"); | 155 | printf("full_resolve_chain_from_address\n"); |