Binding Nostr identity to Radicle

A single-file browser tool for cryptographically linking a Nostr secret key to a Radicle Ed25519 identity, without leaking either one.


I’m already on Tangled. Have been for a while. The tool I’m about to describe ships there: tangled.org/metaend.eth.xyz/nsec-to-radicle.

But Tangled isn’t the whole answer. Radicle is, by design, the more decentralized of the two: peer-to-peer git replication, no central server, identity rooted in cryptographic keys rather than handle registration. The two aren’t competitors so much as different points on the spectrum from “federated sovereign” to “fully sovereign”. I want my projects on both, with one identity that anchors them back to who I already am on Nostr.

That last part turns out to be the hard part.

The social-graph problem

Moving off a code host is technically trivial. Git is git. The new forge takes the same protocol, you push the remote, you’re done. What you lose isn’t the code. What you lose is the social graph attached to the old identity: the discoverability, the reputation, the ability for anyone who knew you under your old handle to find you again.

This is what nobody warns you about when they say “just leave GitHub”. You don’t just lose a code host. You lose your professional identity in the developer social graph, and rebuilding social trust from scratch is the kind of work nobody has time for.

The only fix that doesn’t involve starting over is binding your new identity cryptographically to an identity you already have. For a lot of people I know, that existing identity lives on Nostr. Nostr is sovereign by default, has the gossip-network properties that make a developer’s posts findable across clients, and the npub is already half the way to a portable cryptographic identity.

So: bind the Radicle key to the npub. The question is how to do that without compromising either.

The curve mismatch

Nostr uses secp256k1 with x-only Schnorr signatures, the same curve Bitcoin uses. Radicle uses Ed25519. Two different elliptic curves, two different key formats, no direct conversion. They don’t share a primitive, they don’t share a wire format, they don’t even share a serialization library.

What they do share is a useful property: both treat the secret key as 32 bytes of high-entropy material, and both let you derive a public key from that material deterministically.

So while you can’t convert an nsec into a did:key, you can use the nsec as input to a key derivation function that produces a fresh Ed25519 keypair. With a domain-separated salt, this gives you a stable Radicle identity bound to your Nostr identity without exposing the secret across curves.

The recipe:

seed = HKDF-SHA256(
  ikm  = nsec_bytes,                    // 32 bytes from bech32 decode
  salt = "radicle-from-nostr-v1",       // domain separator
  info = "ed25519-seed",
  L    = 32
)

priv_key = Ed25519.from_seed(seed)      // RFC 8032
pub_key  = priv_key.public_key()
did      = "did:key:z" + base58btc(0xed 0x01 ‖ pub_key)

The salt string radicle-from-nostr-v1 is normative. Every implementation has to use exactly that string or you get a different Radicle identity from the same Nostr key. Forking the tool means versioning the salt, not silently producing different bindings under the same name.

The tool

nsec-to-radicle is a single HTML file, ~58KB, no dependencies, no fonts loaded from CDNs, no analytics, no network calls. The Content Security Policy is locked to default-src 'none'; connect-src 'none'; frame-ancestors 'none'. The browser itself blocks any network request the page might attempt, including ones an attacker tried to inject.

It has three modes.

Derive takes your nsec and produces:

  • did:key:z6Mk... , your Radicle identity
  • npub1... , computed locally from the same nsec via secp256k1 scalar multiplication
  • The Ed25519 public key as hex
  • Two ready-to-use OpenSSH key files (radicle.pub and radicle) for ~/.radicle/keys/

Attest signs a canonical message with the derived Ed25519 key:

radicle-nostr-bridge/attestation-v1
sub did:key:z6Mk...
ref nostr:<hex_npub>
iat <iso8601_utc>

The signature is base64-encoded and packaged as a Nostr kind 30078 event (parameterized replaceable, d tag = radicle-identity). You publish the event via your normal Nostr client. Anyone who finds it can verify two things: that the npub holder published it (Schnorr sig on the event), and that the Radicle key holder endorsed it (Ed25519 sig in the content). Together: mutual control.

Verify takes any kind-30078 attestation event JSON and walks every check (kind, tags, did:key decode, signature verification) in front of you, telling you exactly where bad ones break. Tampered npub, tampered did, tampered signature, tampered timestamp all produce specific failure modes you can read.

What I deliberately didn’t do

Three rejected paths worth naming, because they shape what’s in the tool today.

Bundling @noble/secp256k1 to do Schnorr signing of the Nostr event itself. The library is excellent and widely audited, but it’s 24KB of third-party code, and pulling it in weakens the audit story for a feature the user can do in any Nostr client. Better to produce a signed canonical message that the user wraps and publishes themselves than to be the thing that signs Nostr events on someone’s behalf.

In-browser passphrase encryption of the OpenSSH private key output. OpenSSH uses bcrypt-pbkdf for KDF, which isn’t in Web Crypto, and reimplementing it would mean ~200 lines of unaudited cryptography for a step the user can do trivially with ssh-keygen -p -f radicle after download. Save the file, encrypt it, done.

Hand-rolling secp256k1 from scratch to avoid even the 50-line inline implementation. I tried this. Got the wrong answer for scalars above ~0x64 because BigInt division on negative numbers truncates toward zero, which breaks extended Euclidean inside modInv. Caught it by cross-checking against a known-good library in about five minutes. The naive impl now ships with a one-line fix (reduce input mod m before running Euclidean) and a regression test against @noble/secp256k1 on the canonical test vector and ten random scalars. Hand-rolled crypto is exactly what people warn about, and they’re right. Always cross-verify.

Why single file

The single-file constraint is the security model.

You can read the whole thing in less. Save it to disk, open it offline from file://, and the browser refuses to load anything external. SHA-256 the file, compare against the published hash on multiple channels, run the test vector before trusting any output:

input  : @aqd2…v8jq
did    : did:key:z6MkiTNRZhXMtV5fDf5DQVbEQgoWqW5fUuPtfzzqLrZhNiSD
pub-hex: 3b75f5d5de974d289048da5bede699d5d837d9d76f734fd6f77437fae0339c02

If those match in your browser, the cryptographic pipeline is intact. If they don’t, the file has been tampered with or you have a different version. There’s no supply chain to attack, no CI pipeline to compromise, no npm package to hijack. The whole tool is one document you can audit in an afternoon.

The page also wipes sensitive state on Esc and on pagehide. It tells password managers explicitly not to capture the input via data-lpignore="true". It zeroizes Uint8Array buffers after derivation. None of this matters against a fully compromised browser, but each one closes a real attack class against a partially compromised one.

The full threat model is in SECURITY.md in the repo, including the disclosure policy and what’s explicitly out of scope (compromised host OS, browser extensions with broad permissions, OS swap writing memory to disk). Real limits, named.

What this is the wedge for

nsec-to-radicle is the smallest piece of a larger thing.

The full plan is radicle-nostr-bridge: a daemon that watches Radicle repositories on a seed node and publishes their activity (repo announcements, patches, issues, collaborative-object updates) as Nostr events using NIP-34 git-over-Nostr conventions. Reactions and comments on Nostr can flow back as Radicle COB updates. The canonical git data stays on Radicle. Discovery and social interaction live on Nostr. The two networks reinforce each other instead of competing for the same scarce thing (developer attention).

The bridge daemon needs three things to exist:

  1. A way for Radicle keys and Nostr keys to be cryptographically bound. (This tool.)
  2. A way for any Nostr client to verify those bindings without trusting the bridge. (The attestation event format, eventually published as a NIP draft.)
  3. The bridge daemon itself. (Rust, ~1,500–2,500 lines, PRD already in the repo.)

The first one ships today. The other two are next on the roadmap. The PRD for the bridge daemon lives at radicle-nostr-bridge-prd.md in the same repo, alongside the tool.

Try it

Live: nostrad.qstorage.quilibrium.com

Source: tangled.org/metaend.eth.xyz/nsec-to-radicle

Run the test vector before trusting it with a real key. If your browser produces the expected did:key:z6MkiTNRZhXMtV5fDf5DQVbEQgoWqW5fUuPtfzzqLrZhNiSD for the published test nsec, the pipeline is intact. If it doesn’t, open an issue.

The tool is MIT-licensed. The repo is on Tangled, which is itself sovereignty-minded code hosting. The whole thing is dogfooding: I’m publishing the source for the Nostr-Radicle bridge tool on a Nostr-aware code host, not GitHub, because that’s the kind of stack I want to live in. The tool you build to leave should be the first thing you publish on the place you’re going.

Write a comment
No comments yet.