1 Overview
A Nostr identity is a secp256k1 private key. If an attacker copies the key, the attacker owns the identity forever. This NIP defines how a client encrypts the private key with a password. It also defines the reverse: how a client decrypts it.
The output is a bech32 string with the prefix ncryptsec. The string carries everything needed for decryption except the password: a version byte, the scrypt work factor, the salt, the nonce, a key-security flag, and the ciphertext. A user can move this one string between clients and computers.
The scheme has two stages. First, key derivation: scrypt stretches the password into a 32-byte symmetric key. Second, symmetric encryption: XChaCha20-Poly1305 encrypts the private key under that symmetric key.
An ncryptsec string is a locked box, not a disguise. A weak password is a weak lock. The scrypt work factor slows an attacker down; it does not replace a strong password.
2 Symmetric Key Derivation
A password makes a poor cryptographic key. Derive the real key from the password with scrypt. The derivation uses four inputs:
- PASSWORD — read from the user. Normalize the password to Unicode NFKC form before use. Normalization makes the same visible password produce the same bytes on every computer and every client.
- LOG_N — one byte. It is a power of two that sets the scrypt round count (for example, 18 represents 262,144 rounds). The user or the implementer chooses it. Larger values need more time and more memory, and give better protection.
- SALT — 16 random bytes. Generate a fresh salt for each encryption.
- scrypt parameters r = 8 and p = 1, fixed.
PASSWORD = nfkc_normalize(user input) LOG_N = 1 byte, chosen work factor SALT = 16 random bytes SYMMETRIC_KEY = scrypt(password=PASSWORD, salt=SALT, log_n=LOG_N, r=8, p=1) → 32 bytes
The symmetric key must be 32 bytes long. The key is temporary. Zero it and discard it after use. Do not store it. Do not reuse it for any other purpose.
| LOG_N | Rounds (2^n) | Memory required | Approx. time on a fast computer |
|---|---|---|---|
| 16 | 65,536 | 64 MiB | 100 ms |
| 18 | 262,144 | 256 MiB | — |
| 20 | 1,048,576 | 1 GiB | 2 seconds |
| 21 | 2,097,152 | 2 GiB | — |
| 22 | 4,194,304 | 4 GiB | — |
3 Encrypting a Private Key
The plaintext is the user's secp256k1 private key as 32 raw bytes. Do not encrypt the hex form. Do not encrypt the bech32 (nsec) form.
One extra byte travels with the key: the KEY_SECURITY_BYTE. It records whether the key was ever handled insecurely, for example stored unencrypted or cut and pasted unencrypted. This byte is the associated data of the AEAD cipher. It is authenticated but not encrypted.
| Value | Meaning |
|---|---|
| 0x00 | The key is known to have been handled insecurely. |
| 0x01 | The key is NOT known to have been handled insecurely. |
| 0x02 | The client does not track this data. |
Encrypt with XChaCha20-Poly1305. Generate a fresh 24-byte random nonce for each encryption. The cipher output is the 32-byte encrypted key plus a 16-byte Poly1305 authentication tag: 48 bytes total.
PRIVATE_KEY = 32 raw bytes (not hex, not bech32!) ASSOCIATED_DATA = KEY_SECURITY_BYTE NONCE = 24 random bytes CIPHERTEXT = XChaCha20-Poly1305( plaintext=PRIVATE_KEY, associated_data=ASSOCIATED_DATA, nonce=NONCE, key=SYMMETRIC_KEY ) → 48 bytes
4 The ncryptsec Payload
Set the VERSION_NUMBER to 0x02. Concatenate the six parts in this exact order. Then encode the result with bech32 under the human-readable prefix ncryptsec.
VERSION_NUMBER = 0x02 PAYLOAD = concat(VERSION_NUMBER, LOG_N, SALT, NONCE, ASSOCIATED_DATA, CIPHERTEXT) → 91 bytes ENCRYPTED_PRIVATE_KEY = bech32_encode('ncryptsec', PAYLOAD)
| Offset | Length | Field | Content |
|---|---|---|---|
| 0 | 1 | VERSION_NUMBER | Always 0x02. |
| 1 | 1 | LOG_N | The scrypt work factor byte. |
| 2 | 16 | SALT | The random scrypt salt. |
| 18 | 24 | NONCE | The random XChaCha20 nonce. |
| 42 | 1 | ASSOCIATED_DATA | The KEY_SECURITY_BYTE. |
| 43 | 48 | CIPHERTEXT | 32 encrypted key bytes + 16-byte Poly1305 tag. |
Decryption operates in reverse. Decode the bech32 string. Split the payload at the fixed offsets above. Re-run scrypt with the stored salt and LOG_N and the user's NFKC-normalized password. Then decrypt the ciphertext with XChaCha20-Poly1305 under the stored nonce and associated data. The Poly1305 tag fails if the password is wrong or the data is corrupt.
Three soft spots in the source spec. First, the version byte is 0x02, but no version 0x01 is documented and no rule says how to handle an unknown version byte; reject anything that is not 0x02. Second, decryption is specified only as "the reverse" — error handling (bad tag, bad length, bad prefix) is left to the implementer. Third, the LOG_N cost table leaves the approximate-time cells for 18, 21, and 22 empty.
5 Test Vectors
Password Unicode normalization
The visible password ÅΩẛ̣ can arrive from a keyboard in decomposed or compatibility form. Convert it to NFKC before scrypt:
| Stage | Unicode codepoints | UTF-8 bytes |
|---|---|---|
| input | U+212B U+2126 U+1E9B U+0323 | E2 84 AB E2 84 A6 E1 BA 9B CC A3 |
| NFKC | U+00C5 U+03A9 U+1E69 | C3 85 CE A9 E1 B9 A9 |
Encryption
There is no fixed encryption vector. The process is non-deterministic: the random salt and the random nonce change on every run.
Decryption
The following ncryptsec string, decrypted with password nostr and log_n = 16, yields the hex private key below:
ncryptsec = ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcx fqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vp zmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p password = "nostr" log_n = 16 private key (hex) = 3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683
6 Worked Example
The transcript below decrypts the test vector step by step. Every value is real. Orange lines are inputs the client computes or reads. Blue lines are outputs.
-- 1. bech32-decode, check prefix "ncryptsec", get 91 bytes -- → payload[0] version = 02 → payload[1] log_n = 16 (65,536 rounds) → payload[2:18] salt = 52d7c3f8580e7b41953381e5bc49646b → payload[18:42] nonce = c33f02a7dcaac8bdd8da23cd44978324 0b6ebc12edeea7bf → payload[42] ad = 00 (key was handled insecurely) → payload[43:91] ct+tag = b8e8803440de7b3e9519c3e734cb2ac9 a211ea2dc52312e5117a11a3022d813a b438719ca0b504a1193be510c3aee776 -- 2. derive the key -- → password = nfkc("nostr") ← key = scrypt(password, salt, log_n=16, r=8, p=1) # 32 bytes -- 3. decrypt and verify the Poly1305 tag -- ← plaintext = xchacha20_poly1305_open(key, nonce, ad, ct+tag) ← 3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683 -- 4. zero the key and the password buffers --
7 Design Notes
Why a key derivation function? Two reasons, straight from the spec. First, the symmetric cipher assumes a key with a uniform, functionally random bit distribution; a KDF turns a password into such a key deterministically. Second, the KDF injects a slow, irreversible step, so brute-force password guessing is severely hampered.
Why scrypt? scrypt has been proven maximally memory hard. Several cryptographers told the NIP author it is a better choice than argon2, even though argon2 won a competition in 2015.
Why XChaCha20-Poly1305? Cryptographers typically favor it over AES, and it is less associated with the U.S. government. The cipher (or its earlier non-X variant) sees wide use in TLS and OpenSSH, and most modern crypto libraries provide it.
8 Recommendations
Do not publish ncryptsec strings to Nostr. When an attacker can amass many encrypted private keys, cracking any one key may become easier.
Zero out the memory that holds passwords and private keys before you free it. The derived symmetric key gets the same treatment: zero it, discard it, never store or reuse it.
9 Version History
NIP-49 has been stable since early 2024. One change was breaking. Notable changes, newest first:
- 20262026-06Title standardized to "Private Key Encryption (
ncryptsec)" in a repository-wide renaming pass. - 20242024-02Breaking change: the password must be Unicode NFKC-normalized before scrypt. An NFKC test vector was added (#1054).
- 20242024-02Typo fixes; the term PASSPHRASE was corrected to PASSWORD throughout.
- Origin2024-01Michael Dilger published the spec as "NIP-49: Private key encryption" (#133).
Original Proposal - Credits
| Author | Michael Dilger |
| First commit | 2024-01-30 — “NIP-49: Private key encryption (#133)” |
| Pull request | nostr-protocol/nips #133 |
| Status | draft - optional — clients choose whether to offer it |
| Source | 49.md on GitHub - full change history |
10 See Also
- NIP-01 — defines the secp256k1 keypair that this NIP protects.
- NIP-19 — the bech32 encoding family;
ncryptsecfollows the same style asnsecandnpub. - NIP-06 — derives the private key from a mnemonic seed phrase instead of storing it.
- NIP-46 — remote signing; keeps the key on another device so clients never hold it.
- NIP-44 — encrypted payloads between users; a different scheme for a different job.