Security model

Who we defend against, what is out of scope, and the design we shipped broken and then killed.

Who this defends against#

#ActorWhat they can doDefence
T1Curious node operatorRead all server-side data and memoryThe node never receives key material
T2Malicious node operatorServe modified JS to their visitorsSigner-origin isolation; SRI and reproducible builds
T3Public observerKnows every user's X ID, handle and npubNo secret is derivable from public data
T4Relay operatorSees every published event and connecting IPNIP-44 for private payloads; relay diversity
T5X, the platformRevoke OAuth, ban accounts, change APIsX is on the auth path, never the correctness path
T6ImpersonatorRegister lookalike handles and npubsTwo-way binding; claimed never rendered as verified
T7Supply-chain attackerCompromise a dependency, CDN or releaseNo CDN, pinned deps, reproducible signed releases
T8This projectWe host the default signer originThe signer cannot decrypt; self-hosting is first-class
T9Whoever holds the signer domain nextObtain the identity key of every tier-1 user who authenticates after the transferNot defended against. Tiers 0 and 2 avoid it entirely; the fix for tier 1 is specified and unbuilt

T8 is not decoration. A protocol that requires you to trust its authors has not solved the problem it claims to solve, and the correct response to "why should we trust you" is a design where you do not have to.

T9 is the one we got wrong for a long time. WebAuthn binds every credential to an RP ID derived from the signer origin, and the authenticator will release the secret to any page served under that name — it has no way to tell one operator from another. Domain lapse, sale, registrar action or a court order all hand that ability over, with no compromise of any system. Everything under Practices below defends the bytes served under a name; this is an attack on the name, and none of it applies.

Two things bound the damage, and neither removes it. A future name-holder can sign as the user going forward but cannot alter, delete or claim what the user already published — signed events on relays are not theirs to touch. And nodes running the signer gate refuse an origin whose bytes do not match an attestation signed by a pinned, offline key, which a new name-holder cannot produce — protecting users who arrive through a gated node, not users who go to the signer origin directly.

The documentation used to present the three tiers as differing only in friction. That was false. Custody tiers now ranks them correctly and states the resolution path.

Out of scope, stated rather than hand-waved#

These are real. They are not defended against here, and pretending otherwise would be worse than admitting it.

The design we killed#

The first version derived the identity key from a public value:

nsec = HKDF(x_user_id, public_salt)          ← every input is public

X user IDs are public. The salt was in the source. So anyone could compute anyone's private key from information printed on their profile. Zero bits of security — not weak, not "sufficient for now": none.

Adding a PIN would not have saved it. A six-digit PIN falls to exhaustive offline search in about nine seconds on one CPU core, and the test suite proves that by doing it, rather than asserting it.

The broken derivation is kept in the repository under src/quarantine/, excluded from the build, imported by exactly one test file, with CI asserting all three conditions. attackerRecoversV1Key('12345678') takes nothing but a public integer and returns the victim's private key. It is the slowest test in the suite and worth every second, because a design failure that is merely described gets rediscovered, while one that is executed cannot be.

The fix was not a patch. It was replacing the entropy source with the WebAuthn PRF extension: 32 bytes computed inside the authenticator, not derivable from anything public.

Guarantees, and where they stop#

Proven by tests you can run. Derivation stability and purity; scalar boundary handling; NIP-01 canonical serialization; NIP-44 v2 against official cross-implementation vectors; multi-device wrapping including tampered, swapped and wrong-credential blobs; that the v1 derivation is attacker-reproducible; that a forged event from a hostile relay is dropped; that one relay accepting is not reported as published.

Enforced by the browser, not by us. That a passkey cannot be used from a second origin. This is the WebAuthn RP-ID guarantee and it cannot be unit-tested in Node. What is tested is our half — that the derivation refuses to run anywhere but the configured signer origin. The browser half is a manual checklist, run per platform, before release. We would rather say "partial" than imply coverage we do not have.

Not a cryptographic guarantee at all. Guardian rotation. It is social consensus and should never be described as key recovery.

Practices#

Every claim above has a command that checks it — see Verify it yourself. The boundaries this model does not cover are listed in Limits.

Reporting#

Security issues: open a GitHub issue for anything already public; for anything that is not, use the contact address in SECURITY.md rather than a public tracker. We will publish the finding and the fix — including, as above, our own.