Skip to content

feat: restore reach-by-username with invites and a signed directory - #369

Merged
bahdotsh merged 3 commits into
mainfrom
feat/6-stage2-discovery-records-invite-payload
Aug 17, 2026
Merged

feat: restore reach-by-username with invites and a signed directory#369
bahdotsh merged 3 commits into
mainfrom
feat/6-stage2-discovery-records-invite-payload

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Aug 17, 2026

Copy link
Copy Markdown
Member

What this is

PR #331 re-keyed the Nostr routing tag from SHA-256(username) to
SHA-256(address), correctly: the old preimage was guessable, so anyone who
could 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 DiscoveryRecordV1 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.

Two paths, and invites are the primary one. createInvite() /
parseInvite() produce and verify a self-certifying blob, checkable offline
before create(). That is not a stopgap for discovery: the out-of-band
confirmation 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_resolved event: 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_id became a SyntheticPublication enum
(KeyPackage / Discovery / Deletion). Adding a second record type to the
send 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 match on a variant, so a third record kind fails to
compile at each of them rather than scoring itself as delivery.

Deviations from the design record, both deliberate

  1. A third Deletion discriminator exists, because a NIP-09 deletion also
    rides the send queue and must stay out of the delivery metrics.
  2. Resolution accumulates engine-side and emits one event at
    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

  • 2197 tests green (cargo test --workspace, including doctests)
  • cargo clippy --workspace -- -D warnings clean
  • cargo fmt --all -- --check clean
  • RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps clean
  • tsc clean; JS harness 15/15
  • All four binding sets regenerated together and verified idempotent
  • THIRD-PARTY-NOTICES.md regenerated; license/README/NIP-44 guards pass

All 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_author binding), address-shaped usernames
refused, 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_address
was added and fails under that mutation.

Golden vectors were computed by an independent Python implementation
(BIP-350 reference bech32m, cryptography Ed25519, secp256k1 from the curve
equation, RFC 5869 HKDF), not by pasting encoder output. That script was
itself validated by reproducing the three already-shipped
test_routing_tag_golden_values literals.

Reviewer notes

  • New runtime dependency: unicode-normalization (plus tinyvec,
    tinyvec_macros) for the NFC the record specifies. It is the only part of
    this with a binary-size cost, and this repo has a minisize profile that
    cares. 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.
  • Kind 30777 is unregistered. Nothing in the NIPs registry is assigned
    anywhere in 30700-30800 (re-checked 2026-08-17). Recorded as an open
    question in the spec chapter.
  • No iOS test covers the new config flag, because
    OfflineProtocolModule.swift is excluded from the Swift test target. Same
    gap nostrColdContactEnabled already has; not a regression.
  • Nothing has run against a live relay. That is item 1's territory, and
    this adds a fifth thing for its relay leg to watch.

Wire format, verification order and threat model:
docs/spec/username-discovery.md.

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.
@bahdotsh
bahdotsh merged commit 97a66da into main Aug 17, 2026
23 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 17, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant