Deriving and protecting a Nostr / did:nostr identity with a FIDO2 passkey via the WebAuthn PRF extension
Draft — version 1
A Nostr identity is a secp256k1 keypair; its 64-character lowercase hex x-only public key is also a
did:nostr decentralized identifier. Managing the private half of that keypair is the entire
user-facing problem: passphrases are forgettable and phishable, and pasted nsec strings leak.
FIDO2 passkeys solve custody but cannot sign Nostr events — WebAuthn authenticators do not produce BIP-340 Schnorr signatures. The bridge is the WebAuthn PRF extension: a per-credential pseudo-random function whose 32-byte output is only released after user verification. This specification defines how that PRF output is turned into — or used to protect — a Nostr secret key, deterministically, so that any conforming implementation holding the same credential and the same salts derives the same identity.
Podkey (a Manifest V3 browser extension) is the primary implementation. Other clients (for example, forum software offering extension-less login) must implement the derivation in §3 byte-for-byte to be construction-compatible: identical config schema, identical audited derivation, validated against the shared test vectors in §3.1. Identities such clients create are scoped to their own relying party.
This specification defines two mutually exclusive modes:
| Mode | Root of trust | Definition |
|---|---|---|
derived |
The passkey | The Nostr secret key is a pure function of the PRF output and two stored salts (§3). The same passkey and salts always recreate the same identity; nothing secret is stored. |
wrapped |
An existing key | A pre-existing Nostr secret key is encrypted (AES-256-GCM) under a key derived from the PRF output (§4). The passkey becomes an unlock method; other recovery paths (e.g. a passphrase vault) remain valid. |
prfSalt) — 32 random bytes, generated client-side at credential
creation, passed as prf.eval.first in every WebAuthn ceremony. Selects the PRF input.derivationSalt, derived mode only) — 32 random bytes,
generated client-side, used as the HKDF salt.Implementations persist one versioned record per identity. All binary fields are encoded as base64url without padding (RFC 4648 §5).
// mode "derived"
{
"v": 1,
"mode": "derived",
"credentialId": "<base64url credential rawId>",
"prfSalt": "<base64url 32 bytes>",
"derivationSalt": "<base64url 32 bytes>"
}
// mode "wrapped"
{
"v": 1,
"mode": "wrapped",
"credentialId": "<base64url credential rawId>",
"prfSalt": "<base64url 32 bytes>",
"wrapped": {
"salt": "<base64url 32 bytes>", // HKDF salt for the wrapping key
"iv": "<base64url 12 bytes>", // AES-GCM nonce
"ct": "<base64url ciphertext + 16-byte tag>"
}
}
derived) — normativeGiven the 32-byte PRF output prf and the 32-byte derivationSalt:
counter = 0.info = "podkey/nostr-secret/v1" || byte(counter)
(the 22 ASCII bytes of the label followed by one counter byte).candidate = HKDF-SHA-256(ikm = prf, salt = derivationSalt, info = info, length = 32).candidate, read as a big-endian integer, is a valid secp256k1 secret scalar
(nonzero and less than the group order n), it is the Nostr secret key. Stop.counter and repeat from step 2.
Implementations must bound the loop (256 iterations) and fail if exhausted.
The counter loop makes derivation total and deterministic: the probability that
counter = 0 is invalid is ≈ 2−128, but when it happens every conforming
implementation advances identically, so all derive the same key. The public key is the BIP-340
x-only form, and did:nostr:<pubkey-hex> is the corresponding DID.
Implementations must reproduce this vector exactly.
prf = 0707070707070707070707070707070707070707070707070707070707070707
derivationSalt = 0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b
secret key = 35b9688c42b950406cd91257e11a2f8a76c61ef7b59dcdbe85250e06896582b9
public key = a71f3a2f075fdfe99d801dc0658a4bcf2acf8fdf832be28ee2c64dada773eda8
did = did:nostr:a71f3a2f075fdfe99d801dc0658a4bcf2acf8fdf832be28ee2c64dada773eda8
nsec = nsec1xkuk3rzzh9gyqmxezft7zx303fmvv8hhkkwum059y58qdzt9s2usyx2avn
wrapped) — normativeTo wrap an existing 32-byte Nostr secret key under the PRF output prf:
salt and 12-byte iv
for every wrap operation.wrapKey = HKDF-SHA-256(ikm = prf, salt = salt, info = "podkey/wrap/v1", length = 32).ct = AES-256-GCM(key = wrapKey, nonce = iv, plaintext = secretKeyBytes)
with the default 128-bit tag.{salt, iv, ct} in the configuration record.Unwrapping reverses the process; any authentication failure must be reported as a generic unlock failure without distinguishing wrong-passkey from tampered-ciphertext.
info strings
(podkey/nostr-secret/v1 vs podkey/wrap/v1), so the two constructions can never
yield the same bytes from the same inputs. Wrap-key check vector:
HKDF-SHA-256(prf, salt, "podkey/wrap/v1") with prf and salt as in
§3.1 = 20066d41137e1e47957febffb8ba84259e9dc6ba232803e55e70d93a7d15c892.
| Parameter | Value | Requirement |
|---|---|---|
pubKeyCredParams | ES256 (alg: -7) | should (the credential's own algorithm does not affect derivation; PRF support does) |
userVerification | required | must — the PRF output gates the identity |
residentKey | preferred | should |
attestation | none | should — attestation adds nothing here |
extensions.prf.eval.first | the stored prfSalt | must, on both create and get |
Key material must be obtained from an assertion-time PRF evaluation
(navigator.credentials.get() with allowCredentials = [credentialId]) — the same
operation every future unlock performs. A creation-time PRF output must not be
used for key material: some authenticators return a different value at creation than at assertion, which
would bake in an identity the passkey can never reproduce. Implementations
must fail cleanly when the authenticator does not support PRF at all.
key. Web implementations are similarly bound to their domain.
Consequently the passkey-plus-salts recovery path (§6.4) only works within the
relying-party scope that created the credential; recovery across an origin or extension-ID change
must go through the exported backup. Cross-device "hybrid" transports may
evaluate the PRF differently per device; implementations should treat a PRF
output that fails to reproduce the expected identity as a wrong-authenticator condition, not corruption.
prfSalt (32 random bytes); create the credential with the parameters in §5.derivationSalt; derive the secret key per §3.nsec) and require explicit user
acknowledgment that it has been stored, before persisting anything. If the flow is abandoned
here, no state may remain.allowCredentials = [credentialId]
and prf.eval.first = prfSalt.Requires the identity to be unlocked; then §6.1 steps 1–2 followed by §4. Existing recovery methods (e.g. passphrase vault) must remain intact.
From the backup nsec/hex via ordinary key import (which may
then be re-protected with a new passkey), or — in derived mode, and only within the relying-party scope
that created the credential (§5) — from the same passkey plus the stored salts.
The exported backup is the sole recovery path across installations, origins, or extension-ID changes.
Deletes the configuration record and any vault. Implementations must warn that in derived mode this destroys the salts — the identity then survives only in exported backups — and that the WebAuthn credential itself remains on the authenticator (WebAuthn offers no programmatic deletion) but is no longer referenced.
src/passkey.js); both modes.