Podkey

Podkey Passkey Identity Specification

Deriving and protecting a Nostr / did:nostr identity with a FIDO2 passkey via the WebAuthn PRF extension

Draft — version 1

This document is a work in progress and may be updated, replaced, or obsoleted at any time. It follows the structural conventions of the Nostr DID Method Specification, which defines the did:nostr identifiers this specification produces keys for. The key words must, must not, should, and may are to be interpreted as in RFC 2119.

1. Introduction

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.

Scope of the contract. WebAuthn credentials are scoped to the relying party that created them, and PRF outputs are per-credential (§5). Two applications with different relying-party identifiers therefore cannot re-derive one identity from "the same passkey" — this specification does not make identities portable across origins. An identity moves between applications as a NIP-07 signing capability (the extension signs on the site's behalf) or as an exported backup (§6.4), never by passkey re-derivation.

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.

2. Core concepts

2.1 Modes

This specification defines two mutually exclusive modes:

ModeRoot of trustDefinition
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.

2.2 Salts

Salts are not secrets — the PRF output never leaves the authenticator boundary unverified, and HKDF's security does not rest on salt secrecy. Salts are, however, availability-critical in derived mode: without them, even the surviving passkey cannot recreate the identity. Implementations must store them durably and must not treat their disclosure as key compromise. All implementations must generate salts client-side; a server must not mint or own them.

2.3 Configuration record

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>"
  }
}

3. Key derivation (mode derived) — normative

Given the 32-byte PRF output prf and the 32-byte derivationSalt:

  1. Set counter = 0.
  2. Compute info = "podkey/nostr-secret/v1" || byte(counter) (the 22 ASCII bytes of the label followed by one counter byte).
  3. Compute candidate = HKDF-SHA-256(ikm = prf, salt = derivationSalt, info = info, length = 32).
  4. If 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.
  5. Otherwise increment 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.

3.1 Test vector

Implementations must reproduce this vector exactly.

prf            = 0707070707070707070707070707070707070707070707070707070707070707
derivationSalt = 0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b
secret key     = 35b9688c42b950406cd91257e11a2f8a76c61ef7b59dcdbe85250e06896582b9
public key     = a71f3a2f075fdfe99d801dc0658a4bcf2acf8fdf832be28ee2c64dada773eda8
did            = did:nostr:a71f3a2f075fdfe99d801dc0658a4bcf2acf8fdf832be28ee2c64dada773eda8
nsec           = nsec1xkuk3rzzh9gyqmxezft7zx303fmvv8hhkkwum059y58qdzt9s2usyx2avn

4. Key wrapping (mode wrapped) — normative

To wrap an existing 32-byte Nostr secret key under the PRF output prf:

  1. Generate a fresh random 32-byte salt and 12-byte iv for every wrap operation.
  2. Compute wrapKey = HKDF-SHA-256(ikm = prf, salt = salt, info = "podkey/wrap/v1", length = 32).
  3. Compute ct = AES-256-GCM(key = wrapKey, nonce = iv, plaintext = secretKeyBytes) with the default 128-bit tag.
  4. Persist {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.

The derive path (§3) and wrap path (§4) use distinct HKDF 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.

5. WebAuthn ceremony parameters — normative

ParameterValueRequirement
pubKeyCredParamsES256 (alg: -7)should (the credential's own algorithm does not affect derivation; PRF support does)
userVerificationrequiredmust — the PRF output gates the identity
residentKeypreferredshould
attestationnoneshould — attestation adds nothing here
extensions.prf.eval.firstthe stored prfSaltmust, 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.

Credentials are scoped to the relying-party identifier of the creating context, and the PRF is keyed per-credential — not per passkey account. For a browser extension the relying party is the extension origin, so the extension ID is effectively part of the identity: a credential created under one install path (e.g. unpacked development) cannot be resolved under another (e.g. a store build) unless the ID is pinned via a manifest 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.

6. Operations

6.1 Create (derived identity)

  1. Generate prfSalt (32 random bytes); create the credential with the parameters in §5.
  2. Obtain the PRF output via an assertion on the new credential (§5 — never from the creation result).
  3. Generate derivationSalt; derive the secret key per §3.
  4. Backup gate: present the secret key (as nsec) and require explicit user acknowledgment that it has been stored, before persisting anything. If the flow is abandoned here, no state may remain.
  5. Persist the configuration record, then activate the key for the session.

6.2 Unlock (re-derive / unwrap)

  1. Load the configuration record; run an assertion with allowCredentials = [credentialId] and prf.eval.first = prfSalt.
  2. Derived mode: recompute the key per §3. Wrapped mode: unwrap per §4.
  3. Verify the resulting public key equals the stored identity; reject with a "different identity" error on mismatch.

6.3 Enable passkey unlock for an existing key (wrapped)

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.

6.4 Recover

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.

6.5 Forget

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.

7. Security considerations

8. Privacy considerations

9. Implementations

10. Resources