fix(security): correct the failure direction on five peer/network-facing input surfaces - #453
fix(security): correct the failure direction on five peer/network-facing input surfaces#453MichaelTaylor3d wants to merge 8 commits into
Conversation
…are one off-token #285: relay endpoint parsing accepted any scheme and turned an unparsable port into None, which relay_socket_addr resolved with .unwrap_or(443) -- so one malformed string made the node silently dial 443 at a host that may not be the one the operator wrote. Replaced with a fail-closed parse transcribed from dig_nat::relay::parse_relay_endpoint (the designated survivor, still private in the published 0.21.0), so adopting the export later is a deletion. #282/#352: the three network-reaching isolation knobs each carried a private off-token predicate reading a different vocabulary, so DIG_PEER_NETWORK=OFF left the peer network running while DIG_RELAY_URL=OFF disabled the relay. One shared is_off_token now serves all three, and DIG_RELAY_URL= means no relay rather than the compiled-in public one. Bootstrap resolution announces which branch it took. Co-Authored-By: Claude <noreply@anthropic.com>
…ssibly-in-flight bundle #349: dig.getPeers served the wildcard address to REMOTE peers as a dial candidate, because is_usable_contact had been adopted call site by call site and pool_peers was the fifth site nobody looked at. The check now lives in a ContactAddr type whose construction IS the question and whose address_json is the only renderer of the wire entry. The ADDRESS is withheld, never the peer: the row survives with an empty addresses array, the shape both consumers already handle. #348: reservation was gated on an untrusted 'accepted' alone, so an unexplained denial or a post-transmit transport failure freed coins of a bundle that may be in flight. Anything short of a STATED mempool rejection now holds to the TTL. Co-Authored-By: Claude <noreply@anthropic.com>
Progress — branch
|
…t reservation rule Both behaviours are now normative rather than incidental: a peer row with no dialable destination keeps the row and empties the addresses ARRAY (#349), and a push reserves its inputs unless the mempool DEFINITIVELY refused it (#348). Co-Authored-By: Claude <noreply@anthropic.com>
Revert-proof — each test fails when, and only when, its own fix is revertedWork was committed first, so the reverts were applied to the working tree and undone with Three fixes reverted together in
Counts read from the Green runs (fixes in place):
|
…rvably Root 0.189.0 -> 0.190.0, dig-node-core 0.64.0 -> 0.65.0, dig-wallet 0.43.0 -> 0.44.0 (both touched). Minor rather than patch because two behaviours narrow in ways a consumer can observe: parse_relay_host is pub and now refuses a scheme-less endpoint it used to accept, and DIG_RELAY_URL= changes meaning from 'the compiled-in public relay' to 'no relay'. Not major -- a 0.x line, and nothing left the public surface but the private relay_port. Cargo.lock carries the three version lines and no dependency churn. Co-Authored-By: Claude <noreply@anthropic.com>
Final state —
|
Correction to the comment above: the suite was NOT stalledI reported the full What I got wrong, because it is the reusable part: I inferred "stalled" from a flat That is the same shape as the traps this repo already documents: elapsed time measured from the Standing state: 1020 passed, 0 failed, still running. No test has failed at any point. I will |
Full suite: GREEN. The earlier "stalled" report is retracted.
Local verification is now complete:
Notably the full run confirms the two deliberate behaviour changes —
|
loop-security — IN PROGRESS, not the verdictAuditing head Interim 1 — the off-token vocabulary sweep (brief's priority check 1)Shared: confirmed. But the claim is broader than the fix.
The remaining switches are all truthy-only opt-ins ( And the lane's second claim does not survive its own code. The brief says
So Ranking and gate/no-gate deferred to the verdict. Continuing on: #285 transcription faithfulness against published dig-nat 0.21.0, #349's other peer-address render paths, #348 reachability of the wider hold. |
loop-security — IN PROGRESS, not the verdict (2 of 3)Head Interim 2 — #348: the guard is unreachable from the only production pusher, and SPEC.md states a property the code does not haveThis is my highest-severity finding and I want it durable before I finish.
Ok(if status.inclusion.is_admitted() {
PushOutcome { accepted: true, transaction_id: Some(..), rejection: None }
} else {
PushOutcome { accepted: false, transaction_id: None,
rejection: Some(match status.error.as_deref().map(str::trim) {
Some(reason) if !reason.is_empty() => format!("{}: {reason}", status.status),
_ => status.status.clone(), // <-- ALWAYS Some
}) }
})The Consequence — the attack the fix names is still fully open. Walk it:
The doc comment at And SPEC.md:5356-5366 states this normatively, born false:
A source that denies a relay it performed does return the coins to selection, today, through the only pusher that exists. A normative MUST that the implementation in the same commit does not satisfy is worse than no clause. What the fix DOES change on the production path is one thing only: Severity: MEDIUM as a vulnerability (the double-select window is unchanged from before this PR, so nothing regresses) and HIGH as a claim defect — SPEC.md now asserts a property the node does not have, and the next reader will believe #348 closed a window that is open. Under the brief's own rule, a fix that overstates its own guarantee is a finding. Two ways out, either acceptable to me: keep the guard and correct both the doc and the SPEC clause to say the untrusted-source half is not closed and why (cheapest); or key "definitive" on the mempool's own Interim 2b — #349: a second renderer of the same wire shape does not go through
|
Pure reflow of lines the type-boundary change made over-width. No behaviour change; `git diff -w` against the prior head is empty. Refs #349
09095c9 to
212b290
Compare
loop-security: CHANGES-REQUIREDHead audited: I gate on three claims the diff makes that its own code does not support — one of them a normative MUST NOT in the money path. Every remedy is text-only and cheap. I am NOT gating on any underlying guard and I am not asking for new behaviour. The five behaviour changes are, on balance, an improvement and I say so below. GATINGG1 — HIGH (claim) / MEDIUM (residual vuln). #348's headline guard is unreachable from the only production pusher, and SPEC.md states a MUST NOT the code permits
Exploit, unchanged by this PR: a hostile or faulty chia peer relays the bundle, then answers with any non-admitted status (PENDING, FAILED). The doc at SPEC.md:5356-5366 is worse, because it is normative and internally inconsistent. It defines definitive as "the mempool stated its reason" and then asserts "a source that denies a relay it performed MUST NOT thereby return the coins to selection". Given Required — either discharges the gate:
I have no preference. (a) is a text change. G2 — MEDIUM.
|
| knob | file:line |
OFF set |
|---|---|---|
DIG_WALLET_ENABLE_CHAIN_SYNC |
crates/dig-node-service/src/config.rs:419 |
0 false no off |
DIG_NODE_DIGLOCAL |
crates/dig-node-service/src/config.rs:362 |
0 false no off |
DIG_HOLDINGS_INGEST |
crates/dig-node-core/src/seams/dig_peer/holdings.rs:1069 |
0 false off no |
DIG_NODE_STORE_MELT |
crates/dig-node-core/src/seams/dig_peer/store_melted.rs:551 |
off 0 false no |
Everything else in the repo is a truthy-only opt-in that fails toward not reaching the network — DIG_NODE_ALLOW_REMOTE (config.rs:445), DIG_WALLET_ENABLE_LIVE_BROADCAST (config.rs:376), DIG_NODE_ADVERTISE_LOOPBACK (net.rs:280), DIG_NODE_INBOUND_DEMAND_CACHE (download.rs:281), DIG_NODE_BACKFILL_ON_MISS (download.rs:242), DIG_NODE_FORWARD_ON_MISS (via dig_sex::discovery::parse_enabled). Those are not in this class.
On the carve-out and #459: I accept parse_dig_local_flag (a local listener). I do not accept the stated reason for parse_chain_sync_flag. The brief calls both "non-network-reaching"; config.rs:150-155, the field's own doc, says the opposite in its own words — "a supervisor dials the network: it probes 127.0.0.1:8444 and then the Chia DNS introducers." So DIG_WALLET_ENABLE_CHAIN_SYNC=disabled leaves a node dialling public introducers while the identically-spelled DIG_RELAY_URL=disabled isolates. DIG_HOLDINGS_INGEST is the sharper case: it exists specifically to let an operator shed inbound work under an announcement flood without downgrading (holdings.rs:400), and =disabled silently does nothing during exactly that flood.
Required in THIS PR: scope the peer.rs:478 sentence to the three knobs it actually covers. Not required here: fixing the knobs — that is #459's job. #459 should be widened to name DIG_HOLDINGS_INGEST and DIG_NODE_STORE_MELT, and to drop the "non-network-reaching" rationale for chain sync.
G3 — MEDIUM. net.rs:122 claims address_json is "the only way to render the shipped {host, port, kind} entry". There is a second renderer, and it carries attacker input to remote callers
ContactAddr does what #349 asked for on dig.getPeers — confirmed below. But provider_json (crates/dig-node-core/src/download.rs:3019) serialises p.addresses straight into the providers array of the dig.getAvailability miss answer and the -32008 redirect — both peer-facing — with no destination check. Those CandidateAddr values come from remote peer input via parse_candidate_addr (crates/dig-node-core/src/seams/dig_peer/forwarded_ask.rs:521), which validates only that host is a string and port fits a u16. 0 is a valid u16; "::" is a valid string.
Exploit (pre-existing, not introduced here): a peer answering this node's forwarded ask returns a provider whose one address is {"host":"::","port":0}. It passes parse_candidate_addr, survives the addresses.is_empty() drop at forwarded_ask.rs:507 because the array is non-empty, becomes a ProviderRecord, and is re-served to the next asking node by provider_json. The wildcard #349 removed from dig.getPeers can therefore still be injected on purpose by a stranger and relayed onward, for the cost of one JSON object — strictly worse than the accidental relay-circuit case that motivated the ticket, whose harm the diff itself states: "a peer that dials it wastes one of its few dial slots, and a peer that caches it caches a hole."
Required in THIS PR: correct the net.rs:117-122 claim. Not required here: the guard on parse_candidate_addr / provider_json — file it; it is pre-existing and outside this diff.
NON-GATING
- LOW — the new "empty means off" is silent on two of the three knobs. sec(config): on Windows DIG_BOOTSTRAP_PEERS= is DELETED not empty — an isolated node fell back to mainnet and peered with the production gateway #352's own remedy was to announce which branch was taken, "because the three branches are indistinguishable from the outside and one of them reaches the public network" (
bootstrap.rs:45). That log went to bootstrap only.crates/dig-node-service/src/server.rs:2157isif peer_network_enabled() { spawn_peer_network(..) }with no else and no log, soDIG_PEER_NETWORK=(empty — e.g. a template rendering an unset variable) now silently disables the entire peer network with zero output. Same forDIG_RELAY_URL=. The direction is fail-closed so it is not a hole; it is the diagnosability defect the PR names and then half-fixes. - LOW — the Relay endpoint parsing accepts any scheme and silently dials 443 on a malformed port #285 transcription diverges from dig-nat 0.21.0 in two places. Both harmless today, but together they falsify "adopting the export later is a deletion rather than a re-derivation" (
net.rs:381):- dig-node trims the whole endpoint (
net.rs:391); dig-nat does not (relay.rs:1165). Neutralised in practice —resolve_relay_url(peer.rs:437) trims before any caller. wss://[2001:db8::1]junk— dig-node refuses it (net.rs:404, theNone => return Nonearm); dig-nat accepts it at the default port (relay.rs:1187, an unconditionalNone => default_port). dig-node is stricter, so the direction is safe, but a future swap to dig-nat's export would silently loosen this case. One sentence naming the two deltas stops that being a surprise.
- dig-node trims the whole endpoint (
- CI is RED and blocks merge regardless (§2.4a).
Rustfmtfails — I reproduced it locally,cargo fmt --all -- --checkRC=1, purely cosmetic wrapping atpeer.rs:1363(thepool_peer_rowsignature) andpeer.rs:4483/4505(the new test's arrays).Lint commit messagesalso fails.Test + coveragewas still pending when I read it. Neither is a security defect; both are merge preconditions.
PASSED — what I checked and found clean
- Off-token genuinely shared, and genuinely not a prefix. One
pub(crate) fn is_off_tokenatpeer.rs:498, reached byis_relay_enabled(peer.rs:458),is_peer_network_enabled(peer.rs:473) andbootstrap.rs:135. Not three copies.eq_ignore_ascii_caseon a trimmed value — equality, so astarts_withimplementation is excluded, and the discriminating negatives the lane claimed do exist:offlineandno-thanksare asserted NOT off-tokens atpeer.rs:4508. - The old test WAS vacuous, and the new one is not.
peer_network_enabled_default_on_off_only_for_opt_outlistedoff/0/falseandon; the old impl (!matches!(env, Some("off")|Some("0")|Some("false"))) and the new one agree on every one of those rows, so it passed under both and could never have caught Backfill-on-miss config fails OPEN on a malformed value while the sibling switch fails CLOSED #282. The replacement assertsOFF,off,Off,disabled,DISABLED,False,No, empty and whitespace against both knobs — the old peer-network impl returns enabled for all nine, so the test fails against it. Non-vacuous, and it pins the boundary from both sides. - Both directions of Backfill-on-miss config fails OPEN on a malformed value while the sibling switch fails CLOSED #282/sec(config): on Windows DIG_BOOTSTRAP_PEERS= is DELETED not empty — an isolated node fell back to mainnet and peered with the production gateway #352 hold. Unset leaves the defaults intact on all three (
is_relay_enabled(None)andis_peer_network_enabled(None)asserted atpeer.rs:4485-4486;bootstrap_targets_from_env's None arm takes the compiled-in anchors). No over-correction into "empty means isolated everywhere" —is_off_tokenis applied to those three knobs and nothing else. I checked specifically that an operator who sets nothing still gets a normal node. - sec(peers): dig.getPeers serves the wildcard address to REMOTE peers as a dial candidate — move is_usable_contact to the type boundary #349's type boundary is real for
dig.getPeers.ContactAddr::new(net.rs:133) is the only constructor and gates onis_usable_contact;address_json(net.rs:145) is an inherent method, so the entry cannot be minted for a non-destination. The peer row survives with an emptyaddressesarray, the shapeparse_forwarded_providersalready drops by design anddig.announceaccepts. The test fixture carries a truthful control beside each wildcard, so "emit everything" and "emit nothing" both fail. Loopback is deliberately kept dialable (peer.rs:4455), correctly preventing a tightening regression on single-host runs. The other guarded emitter,connected_peers_json(peer.rs:339), is the localcontrol.peerStatussurface and emits a plainaddr.to_string(), not this shape — not a counterexample. - Relay endpoint parsing accepts any scheme and silently dials 443 on a malformed port #285 centralization is genuinely blocked, and the transcription is faithful in substance.
parse_relay_endpointisfn, notpub fn, atdig-nat-0.21.0/src/relay.rs:1164, andstruct RelayEndpointat:1156is private too — read from the published crate in the cargo registry, not the workspace path;Cargo.lockpinsdig-nat 0.21.0fromregistry+crates.io. All four rules match: scheme required and ws/wss only (:1168), an unparsable port is an error (:1186/:1193), userinfo stripped (:1175), path+query+fragment dropped (:1174). The test vectors really are dig-nat's own assertions — the no-scheme and wrong-scheme and:notaportrows (relay.rs:1670-1672), the userinfo case (:1663), the ws-to-80 default (:1648), the bracketed IPv6 pair (:1653-1658). - Relay endpoint parsing accepts any scheme and silently dials 443 on a malformed port #285 fails closed without taking the relay tier down.
parse_relay_hostandrelay_socket_addrboth project throughparse_relay_endpoint, and a test pins thatDEFAULT_RELAY_URL(dig_constants::DIG_RELAY_URL,peer.rs:77) still parses — so the stricter parser cannot refuse the compiled-in relay. The one behaviour an existing operator loses is the scheme-less endpoint, and dig-nat already refused those (relay.rs:1670), so such a node was deriving a STUN server for a relay it could never connect to. Aligning is the right direction, not a regression. - sec(reservation): gated on an untrusted 'accepted' — the under-claim direction fails OPEN into the double-select window #348's transport-failure half is real, live and correct.
push()returningErrpreviously propagated with?before any reservation; it now reserves and still returnsPushError::Unreachable, and the test asserts both at once, so a fix that swallowed the error to reach the reserve call would fail. The 10-minuteRESERVATION_TTL_MS(rpc.rs:527) bounds the hold and self-heals, and the refusing-control test pins the guard from the other side so "reserve unconditionally" cannot pass. I considered whether the wider hold is a new denial primitive: a source that can silently deny can already deny service by not relaying at all, so the marginal denial is a bounded 10-minute coin hold on top of an already-failing send. Not a finding. - Secrets / custody / privilege. No key, token, credential or PAT introduced, logged or committed. The new
tracing::info!inbootstrap.rslogs theDIG_BOOTSTRAP_PEERSvalue — peer ids and addresses, not a secret. Section 908 intact: nothing in the diff signs, andpush_signed_bundlestill refuses a node-custodied spend before reaching the pusher (rpc.rs:2095). No new endpoint, RPC method, permission, FFI, install root, service registration or file-permission change. No dependency added, removed or loosened —Cargo.lock's only delta is the three version bumps. - SemVer verified from the files. Workspace
0.189.0to0.190.0,dig-node-core0.64.0to0.65.0,dig-wallet0.43.0to0.44.0. Minor is right:parse_relay_hostispub(net.rs:428) and now refuses input it accepted;parse_relay_endpoint,RelayEndpointandContactAddrare newpubitems;relay_portwas private, so its removal is not a surface change.
What I could not reach
- I did not compile or run the suite. Seven sibling lanes share this box, and the coordinator confirmed the lane re-ran at this head:
cargo test -p dig-node-core --libunfiltered, 1026 passed / 0 failed / 0 filtered out, RC=0, plusdig-wallet --lib4/4. I judged a competing ten-minute build not worth the contention, and none of my findings depends on execution — G1 and G3 are each established by reading two code paths. The one thing I did run iscargo fmt --all -- --check(no compile), which reproduced the CI failure. CI'sTest + coverageremains the authoritative gate and was still pending when I read it. .gitnexusis stale, soimpactwould return a false-safe zero. Every call graph above is grep plus direct read, stated as such.- I did not audit
dig-sex,dig-gossipordig-natthemselves beyond readingdig-nat 0.21.0'sparse_relay_endpointanddig-sex 0.4.0'sparse_enabledfor the two comparisons above.
Re-gate scope: all three gating items are text-only. If the fix stays inside doc comments and SPEC.md, this leg alone re-runs — no need for the full triple.
…ld can fire `is_definitive_rejection` (`sage/rpc.rs`) frees a bundle's inputs back into selection only for a refusal the mempool STATED, and holds them to the TTL otherwise -- because a peer that relayed the bundle and then answered with a bare verdict may still have put it in flight. `ChainTransport::push` manufactured a reason from `status.status`, so `rejection` was `Some(..)` on EVERY non-admitted answer, the guard was true every time, and the hold could never fire. The fix read as shipped and was vacuous; the double-select window #348 exists to close was untouched. SPEC.md §18.7 was already correct -- "a bare denial carrying no reason ... MUST be treated as POSSIBLY IN FLIGHT" -- so this is the code being brought to the spec, not a spec change. The decision is extracted as `stated_rejection` and asserted directly, because an end-to-end assertion on a held reservation passes for many reasons and only one of them is this mapping being right. Revert-proved: restoring the manufactured reason fails it on `left: Some("PENDING"), right: None`. Also corrects two comments that claimed more than the code does: the off-token is shared by the three ISOLATION knobs, not by every network-reaching `DIG_*` knob (four others read a narrower vocabulary -- #459); and `ContactAddr` closes the `dig.getPeers` emitter, not the class, since `provider_json` still relays remote-supplied candidates unchecked. Refs #348 #349 #459 Co-Authored-By: Claude <noreply@anthropic.com>
loop-security scoped re-gate — IN PROGRESS, not the verdictHead audited: Subject: content delta Read entirely from git objects ( G1 — the guard chain now fires. Verified in both directions.1. Does the hold actually fire? Yes. Traced the whole chain, not just the mapping:
So a bare I checked the vacuity had not merely RELOCATED into 2. The other direction — does a stated rejection still FREE? Yes, and there is no lockout. 3. Is 4. Operator visibility. The stated reason survives verbatim, verdict-prefixed, to 5. SPEC 18.7 — the code was changed to match the spec, NOT the reverse. Why the pre-existing tests did not catch this — for the gate-rot log. The three 348 tests Finding 1 — MEDIUM: the fix swallowed
|
Narrowing `rejection` to a STATED reason is what lets #348's hold fire, but taken alone it left an operator debugging a bare `PENDING` with `accepted:false`, `transaction_id:null`, `rejection:null` and no label at all. The response did not lie -- "not accepted, no reason given" is true -- but it said strictly less than before, which is its own regression on a money path. `PushOutcome` gains `verdict`, always present. The two fields answer different questions and only one of them may drive the hold, which is why they are separate rather than one string: folding the label back into `rejection` would re-break the guard, and dropping it re-blinds the operator. `control.wallet.broadcast` surfaces it, and the doc above it no longer claims a refusal always answers `{accepted:false, rejection}` -- a shape that stopped always holding when `rejection` narrowed. Caught by the security re-gate as a LOW observability finding on my own fix. Refs #348 Co-Authored-By: Claude <noreply@anthropic.com>
Five peer/network-facing security defects, all of the same shape — a stranger's input decides
something — and every fix is a correction of the failure direction.
Failure direction, before and after
dig.getPeers(peer wire){"host":"::","port":0}to REMOTE peers as a dial candidateaddresses: []); the check moved into a type.unwrap_or(443), so it silently dials 443 at a host that may not be the one writtenDIG_PEER_NETWORKOFFleft the peer network RUNNINGDIG_RELAY_URL=Blast radius checked
.gitnexusis stale for this repo (impactreturns a false-safeimpactedCount: 0), so the radiuswas established by grep + direct read, and that is stated rather than implied.
parse_relay_host/relay_port/relay_socket_addr— all call sites enumerated: two inpeer.rs(stun_servers_from_relay,relay_socket_addrat the NAT bring-up), the rest internalto
net.rs.relay_portwas private and is deleted.is_usable_contact— five emitters found; the fifth (pool_peers) was the unguarded one. Theother four keep working and now have a type to reach for.
push_signed_bundle— the reservation seam;PushOutcomegained no field.DEFAULT_RELAY_URLis asserted to still parse under the stricter parser, so a fail-closed parsecannot take the relay tier down on a stock node.
No HIGH/CRITICAL-risk symbol was edited blind, but note two deliberate behaviour changes a
reviewer should weigh:
parse_relay_host(pub) now refuses a scheme-less endpoint, andDIG_RELAY_URL=now disables the relay. Both narrow what is accepted, and both are why this is aminor bump.
Already-shipped check (§2.0) — it changed two of the five
main:resolve_backfill_on_missfails closed and its doc citesBackfill-on-miss config fails OPEN on a malformed value while the sibling switch fails CLOSED #282 by number. What remained was the ticket's own question — "check whether a third exists" —
and there was one: the
DIG_PEER_NETWORKdivergence above.platform trap is already documented in
SPEC.md. What remained was the sibling knob carryingthe identical defect, and the "fail loud" diagnostic.
Neither was rebuilt.
Tests — what each catches
a_relay_endpoint_that_cannot_be_read_yields_no_destinationrelay_port_comes_from_the_endpoint_or_its_scheme_never_a_post_failure_defaultunwrap_or(443). Thews://rows are load-bearing: withwss://only, "the scheme's default" and "hardcoded 443" are indistinguishableget_peers_withholds_a_non_destination_address_but_keeps_the_peera_wire_address_can_only_be_rendered_from_a_checked_contact{host, port}; loopback is asserted usable, pinning the guard against being tightenedthe_three_isolation_knobs_share_one_off_vocabularyoffline/no-thankspin the boundary from the other side against astarts_withimplementationa_bundle_denied_without_a_reason_is_held_rather_than_freeda_transport_failure_holds_the_inputs_and_still_reports_the_failureRevert-proof: each test was shown to fail when, and only when, its own fix was reverted
(3 named failures in
dig-node-core, 2 indig-wallet, with both pre-existing controls stillpassing). Full output in the thread. Counts read from the
test result:line, never an exit status.SemVer — minor,
0.189.0->0.190.0Also
dig-node-core 0.64.0 -> 0.65.0anddig-wallet 0.43.0 -> 0.44.0(both touched). Minor, notpatch, because two behaviours narrow in ways a consumer can observe:
parse_relay_hostispuband now refuses input it used to accept, and
DIG_RELAY_URL=changes meaning. Not major: this is a0.xline and nothing was removed from the public surface except the privaterelay_port.Cargo.lockcarries the three version lines and no dependency churn.Residual — stated, not hidden
#285's centralization is NOT done, and cannot be done from this repo. The ticket's designated
survivor
dig_nat::relay::parse_relay_endpointis private in the publisheddig-nat 0.21.0, sodig-node cannot call it. The security half — the failure direction — is fixed here; the rival pair
still exists. The parser is transcribed rule-for-rule from dig-nat's own implementation, with its own
test vectors, so adopting the export is a deletion rather than a re-derivation. Making it
pubisan additive dig-nat release and needs a lane in that repo.
#348 does not make the
acceptedflag trustworthy. A hostile source can fabricate a rejectionstring, and nothing here can verify one without an independent chain read. What the fix does is put
the cheapest lie and the accidental case on the safe side. Said plainly in the doc comment rather
than claimed as a closure.
Closes #349
Closes #352
Closes #348
Closes #285
Closes #282