Chapter 49 - Encryption
49
Nostr Implementation Possibility - Hex № 49

Private Key Encryption (ncryptsec)

Lock a private key with a password. The result is one portable ncryptsec string that only the password can open.

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.

Hint!

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.
Key derivation
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 cost table
LOG_NRounds (2^n)Memory requiredApprox. time on a fast computer
1665,53664 MiB100 ms
18262,144256 MiB—
201,048,5761 GiB2 seconds
212,097,1522 GiB—
224,194,3044 GiB—
Password from the user NFKC normalize scrypt salt, log_n, r=8, p=1 Key 32 bytes
Fig. 49-1 — Key derivation. Normalize the password, then stretch it with scrypt into a temporary 32-byte key.

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.

KEY_SECURITY_BYTE values
ValueMeaning
0x00The key is known to have been handled insecurely.
0x01The key is NOT known to have been handled insecurely.
0x02The 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.

Encryption
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.

Concatenation and encoding
VERSION_NUMBER = 0x02
PAYLOAD = concat(VERSION_NUMBER, LOG_N, SALT,
                 NONCE, ASSOCIATED_DATA, CIPHERTEXT)  → 91 bytes
ENCRYPTED_PRIVATE_KEY = bech32_encode('ncryptsec', PAYLOAD)
Payload byte layout (91 bytes before bech32)
OffsetLengthFieldContent
01VERSION_NUMBERAlways 0x02.
11LOG_NThe scrypt work factor byte.
216SALTThe random scrypt salt.
1824NONCEThe random XChaCha20 nonce.
421ASSOCIATED_DATAThe KEY_SECURITY_BYTE.
4348CIPHERTEXT32 encrypted key bytes + 16-byte Poly1305 tag.
91-byte payload → bech32("ncryptsec", …) 02 n salt nonce ad ciphertext + tag 1 B 1 B 16 B 24 B 1 B 32 B + 16 B
Fig. 49-2 — Payload layout. Six fields, fixed sizes, fixed order. The total is always 91 bytes before bech32 encoding.

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.

Tech Note

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:

NFKC normalization vector
StageUnicode codepointsUTF-8 bytes
inputU+212B U+2126 U+1E9B U+0323E2 84 AB E2 84 A6 E1 BA 9B CC A3
NFKCU+00C5 U+03A9 U+1E69C3 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:

Decryption vector
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.

Decryption walkthrough
-- 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

Warning!

Do not publish ncryptsec strings to Nostr. When an attacker can amass many encrypted private keys, cracking any one key may become easier.

Warning!

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

AuthorMichael Dilger
First commit2024-01-30 — “NIP-49: Private key encryption (#133)”
Pull requestnostr-protocol/nips #133
Statusdraft - optional — clients choose whether to offer it
Source49.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; ncryptsec follows the same style as nsec and npub.
  • 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.