feat: restore reach-by-username with invites and a signed directory - #369
Merged
Merged
Conversation
PR #331 re-keyed the Nostr routing tag from SHA-256(username) to SHA-256(address), and it was right to: the old preimage was *guessable*, so anyone who could guess a name could sit on that inbox. What it cost was cold contact. The tag's preimage became a value you can only learn from the peer, which is precisely what "we have never spoken" means you do not have. The machinery was never broken. Given an address, every hop already worked. Only the first one was missing. So: two hops. A signed record sits at a username-derived tag and hands back {address, pubkey}; from there it is the existing published-key-package path, untouched. The record cannot lie about a key, because derive(pubkey) == address is checked the same way every control frame is. It can only lie about a *name*. That is NIP-05's threat model, and it is why resolution returns the whole set of claimants and never a winner: an app that auto-picks has quietly turned a non-authoritative directory into an authoritative-looking one, and its user now believes the name was verified when only a key ever was. Please don't do that. Invites are the other half and the primary one, verifiable offline before create(). Not a stopgap either: the out-of-band confirmation a scanned code represents is the only trust anchor the directory has, so deleting invites would delete its security model. While at it, publication_slot_id became an enum. A second record type riding the send queue is exactly the change that half-wires, and consulting the discriminator at three of four sites lets publication outcomes poison DORS scoring, silently. We have shipped that bug once already. Now every site matches on a variant, so a third record kind will not compile until someone has thought about it. Discovery is off by default. Publishing binds a human-readable name to an address in a public place, and that is a decision an app makes on purpose or not at all.
CI went red on the iOS bridge typecheck. The rest of this is what a review of the same branch turned up. The build break first. `create_invite` took a parameter called `signed`, and uniffi writes UDL parameter names straight into the generated C header, where `signed` is a *type specifier* and not an identifier. `int8_t signed` does not compile, and since it sits in offline_protocolFFI.h it takes the whole module down with it, so every iOS consumer of the generated bindings breaks rather than just this one call. Rust, Kotlin, Swift and Python all accept the name happily, which is why only the iOS typecheck noticed. It is `sign` now, with a note at each spelling telling the next person not to rename it back. Retraction only worked if the relays answered in the convenient order. A tombstone dropped that author's accumulated claim, but a stale copy of the live record arriving afterwards is genuinely signed, passes every check, and stood the retracted claim straight back up. A query is broadcast to every connected relay and a retraction reaches them at different times, so both bodies arrive and nothing controls which lands first. Tombstones are sticky for the life of the resolution now. The suppressed record is not counted as rejected either, because that would only move the order-dependence out of the claim set and into the counter. The username screen refused Cc while its own doc comment claimed it was refusing bidi overrides. It was not. Those are Cf, which char::is_control cannot see, so a claim carrying a right-to-left override sailed through and rendered as a different name than the bytes that were signed. Whitespace-only names were accepted too, for all that the test was already called ..._and_whitespace_only. Both are screened now, Cf through a hand-rolled 21-range table rather than a second Unicode crate: this branch already adds unicode-normalization to a workspace with a binary-size profile that cares, and compounding that for one predicate is silly. While on normalization, the spec mandated the Unicode *simple* lowercase mapping and the code has always used the full one. They disagree wherever a character lowercases to several, so the full mapping expands the dotted capital I to i + U+0307 where simple gives a bare i. An implementation following the spec literally would derive different tags and silently never find us, which is the exact failure that section exists to prevent. The spec was wrong, not the code. Last, a lookup requested while the relay socket was down never answered at all. Queries are pumped only while connected, so the name sat in the transport queue, never minted a query, never started a resolution, and so never tripped the timeout sweep that exists for precisely this. The app waits forever on an event with no trigger. The deadline runs from the request now instead of from the mint, and giving up also cancels the queued lookup, because leaving it there made every later attempt at that name return "already queued" without ever emitting. One transient offline moment should not make a name unlookupable for the life of the process. Every fix above is negative-controlled: each was reintroduced and confirmed to fail a test. While at it, dropped a pointless borrow dance in next_query.
A tombstone's body is a constant, so nothing inside it is signed and its entire meaning is *who published it* -- while the discovery seal key is public by construction. Anyone who knew a username could therefore seal a retraction attributed to any author, and a single hostile relay could use it to erase an honest claimant from the resolved set even while every other relay served that claimant's genuine record. Retraction is deliberately sticky for the life of a resolution, which is what made the forgery total rather than racy. That inverts what querying many relays is for: a claim needs one honest relay to survive, and a retraction needed one hostile relay to succeed. A squatter running a popular relay could have made their own claim the only one a user ever saw, which is the authoritative-looking directory the whole set-shaped API exists to prevent. Discovery events are now checked against their own BIP-340 signature before they are opened, with the event id recomputed rather than trusted -- an event claiming a genuine record's id would otherwise consume its per-query dedup slot and have the real record dropped behind it. The check is scoped to this record kind, because it is the only one whose meaning is not carried by an inner signature. Also: - Invite petnames are screened for the control and format characters a username already refuses. The petname is what an app renders in the confirmation dialog after a scan, and on a *signed* invite a bidi override would arrive bound to a valid signature. One screen now serves both, so the two cannot drift. - resolve_username no longer folds "no answer is coming" into `false`. Both `true` and `false` now promise exactly one username_resolved event; discovery-off and queue-full throw instead. Dedup covers already-minted resolutions as well as the transport queue, so a second lookup cannot mint a duplicate query and emit a second event. - The resolver keys claims by an author string but verifies decoded bytes, so the author is lower-cased at the transport boundary. Spec gains the event-authenticity requirement and one residual the review found undocumented: the publishing key is a linkable identifier, so two names claimed from one install are publicly linkable as one device.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
PR #331 re-keyed the Nostr routing tag from
SHA-256(username)toSHA-256(address), correctly: the old preimage was guessable, so anyone whocould guess a name could watch that inbox. The cost was cold contact, because
the tag's preimage became a value you can only learn from the peer, which is
exactly what "we have never spoken" means you do not have.
The machinery was never broken. Given an address, every hop already worked.
Only the first one was missing, and this adds it.
Two hops. A signed
DiscoveryRecordV1sits at a username-derived tag andhands back
{address, pubkey}; from there it is the existingpublished-key-package path, untouched. The record cannot lie about a key,
because
derive(pubkey) == addressis checked the same way every controlframe is. It can only lie about a name.
Two paths, and invites are the primary one.
createInvite()/parseInvite()produce and verify a self-certifying blob, checkable offlinebefore
create(). That is not a stopgap for discovery: the out-of-bandconfirmation a scanned code represents is the only trust anchor the
directory has, so removing invites would remove its security model.
The API shape is the security control
Anyone may claim any name, so a username resolves to a set, always, even
for a single-device user (a phone and a laptop are two genuine claims). The
whole set arrives as one
username_resolvedevent: no ranking, no "best"claim, no per-claim event to race.
That shape is deliberate. A per-claim stream would make "take the first" the
easiest thing an app could write, and an app that auto-picks has quietly
turned a non-authoritative directory into an authoritative-looking one. Its
user then believes the name was verified when only a key ever was.
Discovery is off by default and additionally requires cold contact
(a claim pointing at an address with no published key packages resolves and
then dead-ends one hop later, so the two are coupled in the transport rather
than merely documented).
The bug class this closes structurally
publication_slot_idbecame aSyntheticPublicationenum(
KeyPackage/Discovery/Deletion). Adding a second record type to thesend queue is exactly the change that half-wires: consult the discriminator at
three of four sites and publication outcomes start poisoning DORS reliability
scoring, silently. That bug has shipped here once already (PR #289).
Now all four sites
matchon a variant, so a third record kind fails tocompile at each of them rather than scoring itself as delivery.
Deviations from the design record, both deliberate
Deletiondiscriminator exists, because a NIP-09 deletion alsorides the send queue and must stay out of the delivery metrics.
end-of-stored-events, with a tick-driven timeout sweep as backstop, rather
than emitting per claim. See the API-shape reasoning above; the record
names auto-selection as risk 1.
Verification
cargo test --workspace, including doctests)cargo clippy --workspace -- -D warningscleancargo fmt --all -- --checkcleanRUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-depscleantscclean; JS harness 15/15THIRD-PARTY-NOTICES.mdregenerated; license/README/NIP-44 guards passAll six test obligations the design record names are covered and
negative-controlled by mutation: the four-domain non-prefix pin, the
re-authored-record refusal (
nostr_authorbinding), address-shaped usernamesrefused, discovery tag != address tag, resolution returning every claimant
with two devices of one user in the fixture, and the DORS metrics exclusion.
One of my own tests was found weak by its mutation: the original set test
passed when claims were keyed by address instead of by publisher, because the
fixture did not distinguish the two.
test_username_resolution_keys_claims_by_publisher_not_by_addresswas added and fails under that mutation.
Golden vectors were computed by an independent Python implementation
(BIP-350 reference bech32m,
cryptographyEd25519, secp256k1 from the curveequation, RFC 5869 HKDF), not by pasting encoder output. That script was
itself validated by reproducing the three already-shipped
test_routing_tag_golden_valuesliterals.Reviewer notes
unicode-normalization(plustinyvec,tinyvec_macros) for the NFC the record specifies. It is the only part ofthis with a binary-size cost, and this repo has a
minisizeprofile thatcares. An ASCII-only username policy would remove it entirely and close the
homograph hole the record calls its least-defended surface, at the cost of
narrowing the spec. Worth measuring before release.
anywhere in 30700-30800 (re-checked 2026-08-17). Recorded as an open
question in the spec chapter.
OfflineProtocolModule.swiftis excluded from the Swift test target. Samegap
nostrColdContactEnabledalready has; not a regression.this adds a fifth thing for its relay leg to watch.
Wire format, verification order and threat model:
docs/spec/username-discovery.md.