NIP Cloud-Account Key Backup

A proposal to unify nostr login with legacy oauth providers with cloud storage of keys.

OceanSlim

28. Juli 2026July 28, 2026 · 13 Min. Lesezeit13 min read

6

(This nip is implemented in mill and you can try it here: https://oslim.dev/nostr-mill)

NIP-XX (draft): Cloud-Account Key Backup

draft optional

A low-assurance convenience mechanism for cross-client backup and recovery of a Nostr secret key, anchored to a third-party cloud account (e.g. "Sign in with Google") and protected by a strong passphrase. A user who creates their key in one client can sign into any other client that implements this NIP — same cloud account, same passphrase — and recover the same key.

This is not custody and not a recovery service. It publishes a passphrase-encrypted secret key to public relays. It exists to lower the friction of starting on Nostr while preserving the option to take full control later. It is not a replacement for the user independently saving their nsec/ncryptsec, and clients MUST treat it as such (see Client requirements). Read the Security considerations before implementing — several risks are inherent to the approach and can only be disclosed, not engineered away.

Motivation#

Onboarding non-technical users to Nostr is hard because the private key is the account and there is no reset. Several clients have independently built "sign in with Google" flows that stash an encrypted key in the user's cloud storage — but each is a silo. Google Drive's appDataFolder is scoped per OAuth client: two different apps get separate, mutually invisible folders for the same user (Google docs). So "log in with Google" yields a different Nostr identity in every app.

This NIP uses the one shared, permissionless medium Nostr already has — relays — as the rendezvous, at an address derived from the cloud account and passphrase, so any implementing client can find and decrypt the backup. The cloud account supplies a stable per-user salt and a familiar login; all confidentiality comes from the passphrase, which is therefore required to be strong.

Terminology#

Derivation#

All parameters are fixed constants so that independent clients reproduce the same values. These constants are frozen for the lifetime of this format version; changing any of them is a new kind, not a new value here (see Versioning).

Let provider be lowercased and sub the raw subject string.

  1. Salt (public, per-user separation; not secret). With US = the ASCII unit separator byte 0x1F: salt = SHA-256( UTF8("nostr-cloud-key-backup" ‖ US ‖ provider ‖ US ‖ sub) ) (32 bytes)

  2. Root from the passphrase: root = scrypt( password = NFKC(passphrase) as UTF-8, salt, N = 2^18, r = 8, p = 1, dkLen = 32 ).

    • NFKC only. No trimming, no case-folding. The same normalized byte string MUST be used here and for the payload (step 4), or the address is found but decryption fails.
  3. Backup identity key via HKDF (domain-separated): backup_seckey = HKDF-SHA256( ikm = root, salt = "" (empty), info = UTF8("nostr-cloud-key-backup/identity"), L = 32 ). Interpret as a secp256k1 scalar. If it is 0 or ≥ n (curve order), reject and re-derive with info suffixed by an incrementing counter byte (probability ≈ 2^-128; specified only for determinism). backup_pubkey = the BIP-340 x-only public key.

Why scrypt cost is a security parameter, not just UX#

backup_pubkey is published in cleartext and is a deterministic function of the passphrase, so it is an offline passphrase-verification oracle: an attacker guesses a passphrase, runs the derivation, and checks for a match — no ciphertext needed. This caps the whole scheme's strength at the addressing scrypt cost, which — unlike the NIP-49 payload's self-describing cost — cannot be raised later without moving every user's address. Two consequences the spec commits to:

Passphrase#

For a permanent, public, offline-crackable blob, a short passphrase is equivalent to no protection. At N = 2^18, a human-chosen 8-character passphrase (~28 bits) falls to a rented GPU cluster in minutes; a 5-word diceware phrase (~65 bits) does not. Therefore:

Encryption#

payload = nip49_encrypt( user_seckey, passphrase, log_n = 18, key_security_byte = 0x00 )

The backup event#

A parameterized replaceable event (NIP-01, range 30000–39999), authored and signed by backup_seckey:

{
  "kind": 30049,                         // PROVISIONAL — pending assignment
  "pubkey": "<backup_pubkey>",
  "content": "ncryptsec1...",            // the NIP-49 payload
  "tags": [
    ["d", "primary"],                    // identity label; "primary" for the default
    ["n", "<monotonic counter>"]         // integer, increments each write
  ],
  "created_at": <unix seconds>,
  "sig": "<signed by backup_seckey>"
}

Relays#

A rendezvous needs an agreed meeting point. Two facts make "just use the user's relays / the open network" unworkable, so this NIP pins a set:

  1. backup_pubkey is a brand-new key with no profile, no NIP-05, no follows, no payment — the exact profile that modern write policies (WoT gates, paid allowlists, anti-spam plugins) reject. Generic public relays will refuse the write.
  2. Two independent clients must publish and read at the same relays or they never meet.

Therefore:

Flows#

Create / import (first backup)#

  1. Obtain user_seckey (generate, or import an existing key).
  2. Authenticate with the provider; obtain sub. Prompt for a passphrase meeting the floor (generate one by default).
  3. Derive backup_seckey; payload = nip49_encrypt(user_seckey, passphrase, 18, 0x00).
  4. Build the kind-30049 event with n = 1, sign with backup_seckey, publish to the pinned relay set.
  5. Before treating the backup as usable, the client MUST have offered the user their raw nsec/ncryptsec (see Client requirements).

Restore (returning user, any client)#

  1. Authenticate; obtain sub. Prompt for the passphrase.
  2. Derive backup_seckey → backup_pubkey.
  3. Query all pinned relays: { "authors": ["<backup_pubkey>"], "kinds": [30049], "#d": ["<label>"] }.
  4. Reconcile: collect all matching events across relays; select the one with the highest n, breaking ties by highest created_at, then lowest event id (NIP-01). A single relay's response is not authoritative.
  5. On a hit: user_seckey = nip49_decrypt(content, passphrase).
  6. On no hit, the client MUST NOT silently create a new identity. It MUST distinguish (a) relay unreachable — a quorum of the pinned relays did not respond → surface "couldn't verify, retry", never new-key; from (b) authoritatively absent — quorum responded, no event → this may be a first-time user or a mistyped passphrase, which are indistinguishable, so the client MUST require an explicit, informed confirmation ("If you have used this Google account before, your passphrase is mistyped. Only continue to create a NEW account if this is genuinely your first time.") before generating a key.

Versioning#

The derivation constants (salt string, scrypt params, HKDF info) are frozen. Any incompatible change MUST use a new kind, so distinct formats occupy distinct addresses and cannot silently clobber each other. The n tag is a per-record counter, not a format version. Do not put a format version inside the salt (it would fork every address on a bump).

Security considerations#

Inherent — disclose, cannot be fixed#

Threat model#

Privacy#

The backup event is authored by the derived key, so it does not link the npub to the provider account in content. That protection is defeated if backup and identity traffic share a relay/connection — hence the relay isolation requirements. d labels other than "primary" are hex npubs (opaque); do not use human-meaningful labels, which leak in cleartext. Note that anyone querying learns backup_pubkey ↔ their own IP; and a lone kind-30049 from a graphless pubkey on the pinned relays self-identifies as a backup user.

Client requirements#

A conforming client:

Test vector#

The backup-identity derivation is deterministic. The NIP-49 payload is not (random salt/nonce) — verify it by decrypting.

provider      = "google"
sub           = "103547991597142817347"
passphrase    = "raven ledger cactus plume amber tunnel"     // illustrative; NOT a safe reuse

salt          = ab3b151969ba0b598b9f3caaf97d34710274a4a5590da48e8b777494c3c60199
root          = b2b42084c2ab00cf65a6caae9561b04137088dbd72c5d1639297034e46e1169d
backup_seckey = 50f4cf826d272bb1ed29b595453f1abc5d308405d2b90b828fe90379f89b4fdf
backup_pubkey = 759d333bfbe82b961dec0cf1971873d50b649ce3c8a2780aca97f6181725bf53   (x-only)

salt = SHA-256("nostr-cloud-key-backup" ‖ 0x1F ‖ "google" ‖ 0x1F ‖ "103547991597142817347"); root = scrypt(NFKC(passphrase), salt, N=2^18, r=8, p=1, 32); backup_seckey = HKDF-SHA256(root, "", "nostr-cloud-key-backup/identity", 32); backup_pubkey is the x-only schnorr public key of backup_seckey.

Prior art#

Open questions#

#backup#key#nip#nostr#passphrase#relay#restore

Written by

Comments6

Kayne

There has gotta be a better alternative to this. Like shit, when someone generates a key pair just ask "do you want to back up to your email" and send a one off email with their nsec and a warning not to share it.
Open in a Nostr client

OceanSlimAuthor

That's just more complex and less secure then just asking them to copy paste it somewhere as a backup and they can't even do that.
Open in a Nostr client

Contents

14 sections

  1. Motivation
  2. Terminology
  3. Derivation
  4. Passphrase
  5. Encryption
  6. The backup event
  7. Relays
  8. Flows
  9. Versioning
  10. Security considerations
  11. Client requirements
  12. Test vector
  13. Prior art
  14. Open questions