LNS — LEZ Name Service: full review

jefdiesel/logos-names @ 238d036 (2026-10-15) · forum post: lns-lez-name-service-lez-names-private-by-default/2016 · testnet registry 66nUvzJtTxA729aUpTcAWDrb7cV9Heb2bDzceHQhLYad · this is the merged, single-file write-up of three separate review passes done over 2026-10-15/16 — a read-only security/privacy review, a mocked build-and-test pass (no nix available yet), and a follow-up real build-and-run through the official nix pipeline and an unmodified Basecamp install.

Worth backing, but not as submitted. The design is genuinely good — content-addressed identifiers, a sealed-record format that degrades gracefully, correct use of LEZ's private-account proof path for holder privacy, and unusually honest spec prose that already discloses most of its own limits. The code itself builds and runs cleanly, both as isolated logic and as a real module inside a real, unmodified Basecamp against the live testnet. But two confirmed, unmitigated bugs in the program itself — front-running on Register and a Configure deploy race — are exploitable today against the live registry, and deserve a fix and re-review before this is treated as the canonical, ecosystem-wide registry SPEC.md positions it as. A third, unrelated bug (doubled thousand-separator commas, confirmed to be a Qt QML engine quirk, not LNS's own code) affects every price and balance shown in the app.

2 blockerprogram bugs, confirmed, unmitigated, exploitable today
19 / 19core Rust tests pass, incl. the spec's own test vector
Real Basecampbuilt via nix, installed, resolved a real name live
Contents
  1. Part 1 — Security & privacy review (read-only, no code run)
  2. Part 2 — Build & test, mocked (no nix available yet: cargo test + an offscreen QML harness)
  3. Part 3 — Build & run, for real (official nix build, real Basecamp, live testnet)
  4. What's still not tested, across all three passes
Part 1 of 3

Security & privacy review

Read-only: cloned, read SPEC.md / docs/RFP-private-naming.md / source, did not run curl | sh, did not post anywhere, did not build or run any code for this part (see Parts 2 and 3 for that).

Seven leads, confirmed or refuted

confirmed — exploitable today

Front-running on Register: no commit-reveal, no binding to the real name

core/src/lib.rs, plan()'s Register arm (~line 470) requires only that payer signs and can afford the fee — nothing ties the call to knowledge of the name behind id. apply()'s Effect::Register (~line 628) accepts any first caller: if !pre_data.is_empty() { assert!(after(held.expires, GRACE_MS) <= now) } — that is the entire check. id is a plain instruction field, not something a private-account proof hides (account-privacy hides who signed, not the instruction's own data). An observer who sees the real registrant's id in a pending transaction can submit their own Register for the same id first, with empty sealed and any payer — they can never learn the name (that's not reversible), but they can permanently squat the slot and grief the real registrant, and can renew indefinitely to extend the block. No commit-reveal exists anywhere in the program.

Why it matters: exploitable today against the live testnet registry, by anyone watching the mempool, for the cost of one registration fee — the strongest finding in this review.

confirmed — matches the spec's own disclosure, but the mitigation is informal

Configure deploy race: first caller sets admin, treasury and prices

core/src/lib.rs, Effect::WriteSettings (~line 682): if let Some(settings) = load_settings(pre_data) { assert!(signer == settings.admin) } — when the settings account is empty (fresh deploy), load_settings returns None and the check is skipped entirely; the first Configure call, from anyone, sets admin, treasury and prices. SPEC.md §8 already discloses this ("whoever deploys a registry configures it at once and checks that the admin is theirs") but the mitigation is informal — "at once" is not atomic with deployment, and nothing in the program enforces a race-free window. A bot watching for new program deployments of this bytecode could front-run the real Configure call and redirect every future registration/renewal fee to itself.

Suggested fix: bind the first Configure to the deploying key, or require it in the same transaction as deployment — given SPEC.md §11 treats this as the canonical registry others are expected to converge on, this deserves an actual program fix, not just a documented race.

confirmed, and the spec understates it

Global salt makes the name→key dictionary reusable forever, against every registry

seal/src/lib.rs:16,83: MASTER_SALT = b"logos-names/v2" is a fixed literal, pbkdf2_hmac_array::<Sha256, 32>(name, MASTER_SALT, 100_000). SPEC.md §1 frames the cost as protection against "a targeted guess" — but a global salt means a candidate-name dictionary is computed once and reused forever, against every registry, past or future. PBKDF2-SHA256 at 100k iterations is not GPU-resistant (it's one primitive repeated, not a memory-hard function like Argon2); a single consumer GPU plausibly does low tens of thousands of derivations/sec, so a dictionary of a few million short/common names (exactly the names people actually register) is hours, not years, on one card, and embarrassingly parallel across many. This is a bulk, reusable, precomputable attack, not a targeted one.

Suggested fix: SPEC.md's "100,000 times the cost of one hash... does not protect a short or obvious name from a targeted guess" sentence should say plainly that the real cost is paid once and amortized over the whole dictionary and every future registry — readers should not come away thinking per-name guessing is the threat model.

confirmed by design — spec wording could be sharper

owner_tag enumeration against public accounts, retroactively

core/src/lib.rs:307: owner_tag(id, account) = SHA256("logos-names/v3/owner" ‖ id ‖ account) — plain SHA-256, no slow derivation, by design (the doc comment says "anyone who knows both can compute it"). That's correct for its purpose (verifying a claimed owner), but it means: given a known id (i.e. a name you already know) and the enumerable set of public accounts that have ever appeared on-chain, testing "is account X this name's owner" is a cheap SHA-256 per candidate. "Holders are hidden" holds for private-account holders; for a name held by a public account, a third party can discover the holder after the fact, not just by observing the registration live as §1 already says ("a public holder is seen signing") — they can do it retroactively too, with no observation of the original transaction required.

Side note tying this to the salt lead above: whoever runs the sequencer's RPC already sees which record address a client reads, per SPEC.md §1's own "connection" caveat — combined with a precomputed name→id dictionary, an RPC operator is specifically well-positioned to correlate "which names are being looked up, from where, how often" over time. Not a new hole, but worth naming as the compounding case.

Suggested fix: a sharper line in SPEC.md §1 than the current "is seen signing," which only covers real-time observation.

checked — no abuse path found

now-claim abuse on Register/Renew/grace

Traced it through: the only constraint is "valid only in a block whose timestamp is ≥ now" (plan.timestamp_window(now..)). A low now claim can't shorten someone else's name (Renew takes max(expires, now) + term, so a lower claim is simply absorbed), and a high now claim can't pull a lapse forward, because the transaction itself isn't includable until the chain's real timestamp reaches that claimed value — by which point the name would have genuinely lapsed anyway. This part is sound.

checked — not a contradiction, already disclosed

Gateway vs. "no server in between"

The forum post's claim is correctly scoped ("the client talks to the LEZ sequencer directly") and resolver/names.js does exactly that — the gateway (gateway/worker.js) is a separate, optional HTTP convenience layer, and README.md:197-198 is explicit: "A gateway is a convenience, not a trust anchor or a private channel: it learns every name asked of it and can alter what it serves." That's an honest disclosure, not a hidden tension. Did not run the curl | sh installer per instruction; it's a standard fetch-and-pipe pattern, worth a signed-release path eventually but out of scope for what this pass was asked to check.

out of scope — correctly delegated

npk/vpk payment unlinkability

LNS doesn't implement its own private-transfer cryptography; SPEC.md §6 has a name publish a static npk/vpk and payments go through LEZ's own existing shielded-transfer mechanism (same reused-address pattern as a Zcash unified address). Whether repeated payments to one name are unlinkable is a property of LEZ's own shielded pool, not of this registry — auditing that is a separate, much larger task outside this repo. Worth flagging to whoever owns that layer, not a finding against LNS specifically.

Scope/tier realism

The 2–3 week estimate is already validated by elapsed history (first commit 2026-10-06, head 2026-10-15 — literally within that window), so it's not a speculative guess. The S-tier sizing is a different question: this is proposed as the canonical, ecosystem-wide name registry (SPEC.md §11 — "clients on a network must agree on one registry, as they agree on one token contract"), and the two confirmed program bugs above (both exploitable against the live registry today, both with zero mitigation in the program logic) are exactly the class of defect that a quick S-tier review pass is liable to miss. The code itself isn't large or sprawling; the review burden is high because of what it's being positioned as. Recommendation: fund it, but gate fee release on the front-running and deploy-race bugs being fixed and re-reviewed — not on backing out the tier.

Part 2 of 3

Build & test, mocked

basecamp-module/ (core, Rust, module id names) and basecamp/Main.qml (view, module id names_ui) · no nix or real Basecamp install available in this sandbox at the time, so the core's own logic was built and tested directly with cargo, and the real, unmodified view was loaded and driven through a real Qt 6 engine with a scripted mock of its core-module calls. Superseded for the "is it real" question by Part 3 below, kept here because it independently confirms the Rust logic against the spec's own test vectors and caught the comma-formatting bug.

builds and tests clean

19/19 tests pass, including one that checks against the spec's own worked example

basecamp-module/rust-lib's real logic (registry.rs, changes.rs — name derivation, sealing/opening, instruction encoding; 2,176 of the crate's ~2,600 lines) doesn't touch the Logos module SDK at all. The one place that does (lib.rs's wallet submodule, which calls the LEZ wallet module) is #[cfg(not(test))] — compiled out entirely under cargo test. So a 25-line stub of the one SDK method it references was enough to let Cargo resolve the dependency graph, with the stub's own body never actually reached. Result:

running 19 tests
test registry::tests::a_name_derives_the_identifier_and_key_the_specification_gives ... ok
...
test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.69s

That specific test decodes the name jef through the crate's own derive() and checks the result against SPEC.md §10's published test vectors byte for byte — so this isn't just "the code is internally consistent," it's "the shipped code agrees with the document that's supposed to describe it." Full output in probes/cargo_test_output.txt.

Also tried cargo build --lib — the real deployable artifact, not just tests. That fails cleanly at a missing generated/provider_gen.rs, which only the official logos-module-builder (via nix) produces from the NamesModule trait. Expected, not a defect — this is the genuine boundary of what's buildable without the project's own nix toolchain. Part 3 crosses this boundary with the real builder.

loads clean, no stub modules needed

The real, unmodified QML view — five screens, zero warnings or errors

basecamp/Main.qml imports only QtQuick and QtQuick.Layouts — no Logos.Theme/Logos.Controls to stub out, unlike an unrelated dufs-basecamp view reviewed earlier in the same pass. It talks to its core module the same way that one did: logos.callModule/logos.callModuleAsync/logos.request. Built a small bridge (probes/harness3.cpp) implementing all three with canned JSON matching SPEC.md §12.4's documented shapes exactly, keyed by module/method and (for resolve/page) by the actual name argument — so both a registered and an unregistered name produce their real, different screens, not a single canned response reused everywhere.

Loaded and drove it through five real states (search, a resolved name, an unregistered name's register flow, the pay panel, settings): zero QML warnings, zero errors, on every single one. That specifically ruled out a recurrence of a bug class seen elsewhere — a signal or property assigned on a Qt type that doesn't actually have it, only caught by loading through a real engine, not by reading the source.

The search home screen
Home: search, "My names" (from a mocked wallet), app settings link.
A resolved name's detail screen
Resolving "jef.lez" (mocked data): identicon, Send/Message/Copy link, the full record detail.
The register panel for an unregistered name
An unregistered name: account and term picker, real prices from the mocked settings — this is where the comma bug below was first spotted.
The send/pay panel
Paying "jef.lez": public and private sender accounts, with an accurate, well-written explanation of what each hides.
The registry settings screen
Settings: the real testnet registry ID, sequencer URL, wallet file paths — and a developer-facing "last reply" debug line at the bottom.
confirmed — but the root cause is Qt's QML engine, not this app's code

Every price and balance in the app displays with doubled commas

Noticed it first in the register and pay screenshots above: 100,,000, 5,,000,,000. Main.qml:661-663's grouped() function is the standard, textbook JS idiom for thousand-separators:

function grouped(digits) {
    return String(digits).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}

Checked whether this was an artifact of the mock bridge or the harness: it is not. Isolated it completely — a 7-line QML file with no app code at all (probes/regex_test.qml), run through a different, simpler harness:

qml: RESULT1: 5,,000,,000
qml: RESULT2: 100,,000

Ran the identical regex through Node (V8) for comparison: 5,000,000 and 100,000 — correct, single commas, as the standard idiom should produce. This is a genuine regex-engine bug in Qt 6.4.2's QML JavaScript engine (a global replace with a zero-width \B assertion combined with a capturing group inside a lookahead produces a duplicate match at each insertion point) — not a logic bug in grouped() itself, which is exactly the code any other JS engine would run correctly.

Still not independently re-confirmed against the real, nix-packaged Basecamp build's own Qt version — see Part 3, which got a real Basecamp running but couldn't reach this specific screen without a funded LEZ Wallet.

Suggested fix: replace the regex with a manual grouping loop (trivial, five lines, no engine-specific regex behavior to depend on), or test the existing regex specifically against the actual Qt6 version the release build pins before shipping — don't assume Node/browser testing during development transfers to the QML runtime.

Part 3 of 3

Build & run, for real

Follow-up pass once nix became available in this sandbox: built both .lgx packages the official way via logos-module-builder, installed them into a real, unmodified LogosBasecamp-Desktop AppImage, and drove it with xdotool/ffmpeg against the live testnet — no mocks anywhere in this part.

confirmed — nothing compiled from source locally

Both .lgx packages built the official way, via the binary cache

Installed nix single-user as root (no sudo): manual /nix creation, and — since nix refuses to run as root if root itself is a member of nixbld — a dedicated non-root nixbld1 user as the group's sole member, rather than adding root. nix.conf: experimental-features = nix-command flakes, sandbox = false (sandboxed builds need user namespaces, unavailable here), accept-flake-config = true (trusts each flake's own nixConfig.extra-substituters, here cache.nix.logos.co/public). Installed nix 2.35.3.

cd basecamp-module && nix build .#lgx-portable   # → logos-names-module-lib.lgx
cd ../basecamp      && nix build .#lgx-portable   # → logos-names_ui-module.lgx

Checked both build logs directly for what was actually built locally versus fetched as a pre-built store path — this was the explicit stop condition for this task (report immediately if nix tries to compile Qt from source):

grep -c "^building '" probes/nix-build-core.log   →  1
grep -c "^building '" probes/nix-build-ui.log     →  7

All 8 local build steps are small packaging derivations specific to this repo — metadata.json, the .lidl asset, the bundle, the final .lgx — not Qt, not boost, not openssl. Everything else (cairo, qtbase, cargo 1.89.0, …) came from cache.nix.logos.co/public as substituted store paths. Confirmed via tar -tzf that both outputs are real, complete module packages (manifest.json, .lidl asset, and — for the core — the real names_plugin.so plus its linked boost/openssl shared libraries; for the UI — the real Main.qml, qmldir, metadata.json). This is the real, officially-generated artifact — provider_gen.rs and the staged logos-rust-sdk-src that logos-module-builder produces — not the hand-stubbed SDK substitute Part 2 had to use to get cargo test --lib running without nix.

confirmed — real AppImage, real Basecamp shell, after three environment fixes

Getting an unmodified LogosBasecamp-Desktop running in this cage

Downloaded the real AppImage from logos-co/logos-basecamp releases (the correct repo name — logos-co/Basecamp doesn't exist). No FUSE in this cage, so extracted instead of running it directly (--appimage-extract). Three environment problems had to be fixed, none of them in the app's own code:

After those three fixes, the app started cleanly on Xvfb (:77, xcb platform) and both LNS .lgx files were installed by hand into modules/names/ and plugins/names_ui/.

confirmed end to end — live testnet, real on-chain data

"jef.lez" resolved for real, and the unregistered-name flow works too

Basecamp welcome screen
First run, empty --user-dir: the real "Logos Basecamp. ALPHA" welcome screen.
Applications / Discover view listing apps
Discover → Applications, scrolled to "local (2)": Basecamp sees "names" as a real local package, not something injected out of band.
names_ui empty state
The real, unmodified names_ui opened for the first time — compare to Part 2's mocked version above.
jef.lez resolved against the live testnet
Real resolution against the live LEZ testnet registry: Held by HEuPeXNm…MRR1Hi, Held until 15 October 2027, Paid privately: Yes, a real logos.account hex value. Live on-chain data, not a mock.
Unregistered-name register CTA
Searching an unregistered name correctly falls through to "Nobody holds this name. / Registering it takes an account that can pay the fee." with an "Open LEZ Wallet" CTA, since no wallet instance is running in this cage.
one data point, not reliably reproduced

A search right after another once left the result panel stale

On the first attempt to search an unregistered name right after the "jef" lookup, the search field visibly updated but the result panel kept showing the stale previous result — through both pressing Return and then explicitly clicking the search button (media/bc-shot10.png). A second, otherwise identical attempt a few seconds later worked correctly on the first try. The app's own log doesn't record individual resolve calls at any visible level, so this couldn't be root-caused with certainty; the failed attempt landed within a second or two of one of the app's own periodic (every 30s) background wallet-connectivity polls, which the log shows blocking for up to 3000 ms — a plausible but unconfirmed explanation, not a confirmed bug.

What's still not tested, across all three passes

Environment: Linux 6.17 x86_64. Part 1 was read-only, no code run. Part 2 used system Qt 6.4.2 (Debian bookworm packages) and Rust 1.94.0 (rustup; the pinned 1.98.1 wasn't yet published in that rustup channel). Part 3 used nix 2.35.3, the project's own pinned logos-module-builder revision, and the real LogosBasecamp-Desktop AppImage. The repo was cloned fresh and never modified in any of the three passes; all build/test work happened in scratch directories outside it. Probes and raw logs to re-run or verify any of this are in probes/.