Joey (NostrComments)'s avatar
Joey (NostrComments)
slurpnc@coinos.io
npub1ewxm...ds9e
I build NostrComments — a browser extension that adds a censorship-resistant comment section to every website, powered by Nostr — and Attest, a Nostr signer for Firefox that holds your key so a website never has to, and asks before it signs anything. Free and open source, always. NostrComments for Firefox: https://addons.mozilla.org/en-US/firefox/addon/nostrcomments/ NostrComments for Chrome: https://chromewebstore.google.com/detail/nostrcomments/ebmgdpicceaencegknannfaljhbfgido Attest for Firefox: https://addons.mozilla.org/firefox/addon/attest/ Monero: 87aDTPD9HQx2QenKsS7MvHDdqsziFPD7UB37X6G5XVXc2ZPhAs8DdEKUPYJijVcRjj1gU5KvxLCTfWUKWqrd1D5o8uw5EpM
I hand-rolled secp256k1 and BIP-340 Schnorr for a browser extension, and I would like someone who does this properly to read it. Why hand-rolled: the extension ships with no build step and no dependencies, so the file you install is the file in the repository, comparable line for line. A library would have cost that. Whether the trade was worth it is part of what I am asking. What I have already done. It passes all nineteen official BIP-340 test vectors, including the ten that must be rejected — public key off the curve, sig[0:32] not an x coordinate, s equal to the curve order, sG - eP at infinity. I mutation-tested that suite too: removing the even-y check on R is caught by exactly one assertion in the entire project, vector #6, and by nothing else. What I already know is weak. The scalar multiplication is a plain double-and-add branching on secret bits, so it runs in variable time. I do not think it is reachable from a hostile page — a content script's signing cannot be triggered or observed from the page — but I would rather say it than have it pointed out. And BigInt is not constant time in any JS engine, so this is not something I can fully fix in this language. What testing cannot tell me: whether the field arithmetic is right. Vectors prove the implementation behaves on inputs somebody thought to write down. They say nothing about the modular inverse. It is forty lines. I am not asking anyone for a free audit — just for eyes. I would rather find out now than after somebody depends on it.
I was about to post a measurement. I re-ran it first, which I do, and it fell apart in my hands. The claim: across six news domains, the notes on Nostr that link to news articles are essentially one account. 59 of the 63 notes on the five most-linked pages came from a single bot relaying links. Four distinct authors in total. I had built a whole argument on that — I decided not to add URL search to my client because there was nothing worth surfacing. Redone sixteen days later: 3,050 distinct article pages, 17 distinct authors on the five most-linked, largest single author 16%. The most linked pages are ordinary articles on unrelated subjects, each mentioned by different people. Both runs used the same three search relays that answer a NIP-50 filter at all, of seven tried. The difference between them is one function call. The corpus was there the whole time. My script stripped every query string before counting a page, so thousands of distinct URLs collapsed into a handful of buckets. One account that posts a lot of links with tracking parameters then owned whatever was left. The bot was not the corpus. It was the largest thing still standing after my measurement threw the corpus away. The part I find hardest to be relaxed about: my own client already has a URL normaliser that gets this right. It strips tracking parameters and keeps the query string that identifies a page, because that is what the product needs to file a comment under the correct address. The measurement did not use it. So I measured something my own software would never see, and then made a decision about my own software on the result. The decision happens to survive — I still would not build that search, for reasons about relay reliability and about not sending the page you are reading to an index you did not choose. But it survived by luck, not by reasoning, and those are not the same thing. If you measure your own product: measure with the code your product runs. Anything else and you are describing a system nobody uses.
Hello. I have been reading here a while and posting the occasional finding; this is the introduction I skipped. I build a comment layer for web pages, on Nostr. A thread keyed to a page's address, held on relays instead of by the site — so when a publisher closes their comment section, or deletes the correction somebody left, the reply is still there for whoever arrives next. It started with scam sites. An operator removes warnings from their own page within minutes. That is the case that would not let go of me: a reply has to live somewhere the party being discussed does not control. What I actually have: a browser extension for Chrome and Firefox, MIT, no server, no accounts — an identity is a keypair made in your browser. And almost no users. I would rather say that than imply otherwise. The layer exists before the audience does. What I have been posting is mostly measurements, including the ones that went against me. That my own extension left a private key readable in the page's DOM, and why a closed shadow root is not the fix. That I dropped a read path for a reason which turned out to be nonsense, and it took five releases to notice. That NIP-09 deletion has an author check most clients skip. There is more of that coming. I am here for the people who build the plumbing. If you work on relays, clients or NIPs, I would like to know you. #introductions
I moved my comment events from kind 1 to NIP-22 kind 1111 and stopped reading kind 1 entirely. The move was right. The reasoning was wrong, and it took five releases before I noticed what it had cost. I justified dropping the read path with "there is no corpus worth protecting" — which was true of my own history, and completely beside the point. {kinds:[1], "#r":[url]} does not only return comments made with my extension. An r-tagged note is how any Nostr client links a note to a URL. In one release I made everything anyone had ever said about a page, from any client, invisible. It surfaced as something else first. Reply notifications listen on kind 1 as well as 1111, because somebody can mention you in an ordinary note. So the badge would light up for a mention the thread had no way to display. I hit that myself using my own extension and filed it in my head as a cosmetic glitch. Kind 1 notes are read again now, marked as notes rather than comments. Writing is still 1111 only. And NIP-22 is explicit that a comment must not reply to a kind 1, so replying to one publishes a NIP-10 reply instead. If you are planning the same migration: separate the two decisions. What you write is about being a good citizen of the protocol. What you read is about what your users can see, and dropping a read path is not free just because you are not writing that kind any more.
If you're implementing NIP-09 deletion in a Nostr client, there's one check that's easy to miss: A kind 5 only counts when it's signed by the author of the event it targets. Without that check, anyone can publish a kind 5 naming someone else's note, and your client will hide it. You've built a censorship button and handed it to everyone. The fix is two lines — compare the deletion event's pubkey against the target event's pubkey before applying it. But the failure mode is silent: nothing errors, notes just quietly disappear for your users. Ran into this adding deletion to NostrComments. Ended up writing a test for it rather than trusting a careful reading.
↑