Accounts, imports and seed derivation
Validate imported keys and derive compatible sub-accounts from a seed.
This detailed API reference is maintained in English and follows the linked SDK source. Code examples with application variables need your own signer, keys, storage or UI.
Derivation (NIP-06)
import { deriveFromMnemonic, derivationPath, generateMnemonic } from '@nostr-wot/identity/accounts';
const mnemonic = generateMnemonic(); // 24 words; pass 128 for 12
derivationPath(3); // "m/44'/1237'/0'/0/3"
const { privkey, pubkey, path } = deriveFromMnemonic(mnemonic, 0);
// privkey is live key material — zero it once the account is built.
privkey.fill(0);generateMnemonic() defaults to 256 bits rather than BIP-39's 128-bit minimum. Post-quantum keys are derived from the same seed, and at 12 words the seed, not the algorithm, becomes the weakest link.
Sub-account paths: read this before changing them
Sub-account n is at m/44'/1237'/0'/0/{n} — the last component varies, the account component stays at 0'. Some other signers vary the account component instead, deriving m/44'/1237'/{n}'/0/0, which is the stricter reading of NIP-06.
This package follows the browser extension, deliberately, because the extension has shipped. Someone who created sub-accounts there and then restores the same seed phrase in the mobile app has to get the same identities back. Deriving the other way would hand them a different set of keys with no error at all, and the only reasonable conclusion they could draw is that their accounts were lost. Protocol purity loses to not destroying identities that already exist.
Index 0 is m/44'/1237'/0'/0/0 under either convention, so the published NIP-06 test vector holds regardless. The two only diverge from index 1 onward.
standardDerivationIndex(derivationPath(n)) === n for every valid n; that round-trip is what lets a stored path recover its account index, and it is covered by a test.
Import
parseImportInput validates; it does not merely match a prefix. It returns null for a bech32 string whose checksum does not hold, an nsec or hex key whose bytes are not a valid secp256k1 scalar, an npub whose x-coordinate is not on the curve, and an ncryptsec with an unknown version byte or a payload of the wrong length. Every one of those is material that looks importable and is not: an accepted-but-wrong npub becomes a watch-only account pointing at a pubkey nobody holds, and the import screen is the last moment anyone can be told.
import { parseImportInput } from '@nostr-wot/identity/accounts';
parseImportInput('nsec1…'); // { kind: 'nsec', privkey }
parseImportInput('npub1…'); // { kind: 'npub', pubkey }
parseImportInput('ncryptsec1…'); // { kind: 'ncryptsec', payload }
parseImportInput('bunker://<64 hex>?relay=…'); // { kind: 'bunker', uri }
parseImportInput('<64 hex>'); // { kind: 'hex-private', privkey }
parseImportInput('twelve or twenty four words…'); // { kind: 'mnemonic', mnemonic }
parseImportInput('npub1qqqq…'); // null — bad checksum
parseImportInput('0'.repeat(64)); // null — not a valid secp256k1 scalar
// null — an npub whose x-coordinate has no point on the curvedetectImportKind — for the error message, never for the decision
Strict parsing alone leaves the UI with nothing useful to say. A seed phrase with one mistyped word is, to parseImportInput, indistinguishable from random text, and "unrecognized input" is a cruel message to show someone in the middle of recovering an identity.
detectImportKind classifies by shape and validates nothing, so the caller can name what the user was evidently trying to paste:
import { detectImportKind, parseImportInput } from '@nostr-wot/identity/accounts';
const input = 'ladder monkey parrot …'; // one word mistyped
parseImportInput(input); // null — do not import this
detectImportKind(input); // 'mnemonic' — "that seed phrase has a typo"Never branch on detectImportKind to decide that material is usable. That is what parseImportInput is for.
NIP-49 (ncryptsec)
| Parameter | Value |
|---|---|
| KDF | scrypt, N = 2^16, r = 8, p = 1, dkLen = 32 |
| Password | NFKC-normalized |
| Cipher | XChaCha20-Poly1305, key_security_byte as AAD |
| Payload | version(1) + log_n(1) + salt(16) + nonce(24) + key_security(1) + ciphertext(48) |
import { decryptNcryptsec, encryptNcryptsec } from '@nostr-wot/identity/accounts';
const encoded = encryptNcryptsec(privkey, password);
const recovered = decryptNcryptsec(encoded, password); // throws on the wrong passwordDecoding also accepts the legacy local-only 0x01 format the extension used to export (PBKDF2-SHA256 at 210 000 iterations plus AES-256-GCM), so older backups still import. Nothing writes that format any more.
privkey is borrowed: the bytes are not zeroed here, because an export runs inside a withPrivkey scope that owns the copy and zeroes it, and zeroing it here as well would blank that scope's key underneath it. Bytes are the only accepted form, and decryptNcryptsec returns bytes too, so nothing rounds a key through a string: a string cannot be overwritten, so building one leaves a second copy of the key in the heap that nothing can zero.
The logn argument on encryptNcryptsec can only raise the scrypt cost above the default (MIN_LOG_N = DEFAULT_LOG_N = 16, 64 MiB, what the extension writes and NIP-49 recommends); anything lower is refused. A backup leaves the device and can be guessed at offline indefinitely, so the cost of a guess is its whole protection. decryptNcryptsec still opens a backup another client wrote at a lower cost, since that file is theirs to have written weakly.
The scrypt memory bound
scryptMaxMem(logN) is the maxmem handed to @noble/hashes, and it is deliberately a few blocks above what the algorithm needs rather than equal to what any one noble version checks. It has been equal twice, and broke twice. 128·r·(N + p) is 2.0.1's expression, and 2.2.0 started counting a scratch block it had always allocated, so every encode and decode threw "maxmem" limit was hit for anyone who resolved from the declared range instead of a lockfile. The repair, 128·r·(N + p + 1), is 2.4.0's expression: same coupling, one version along, zero room for the next revision.
SCRYPT_MAXMEM_SLACK_BLOCKS is that room, four blocks, which is 4 KiB beside a V table of N blocks. It is a compatibility bound and not a safety one: it is computed from the cost factor in the payload, so it can never reject an expensive backup. MAX_LOG_N is what bounds that.
The general rule this leaves behind: a numeric bound handed to a library must be derived from what the algorithm or format requires, never copied from what a particular version of that library happens to check, and never pinned exactly to it. The two agree right up until the library revises its own accounting, and a lockfile-pinned suite cannot tell you when that happens. test/scrypt-maxmem.test.ts asks the installed library what it requires instead of restating anyone's arithmetic, and .github/workflows/deps-in-range.yml runs the suite against the newest version each declared range admits.