Nostr WoT

Passwort-Tresor und Schlüsselzugriff

Integrationsanleitung und API-Referenz: Passwort-Tresor und Schlüsselzugriff.

Diese ausführliche API-Referenz wird auf Englisch gepflegt und folgt dem verlinkten SDK-Quellcode. Beispiele mit Anwendungsvariablen benötigen Ihren Signer, Ihre Schlüssel, Ihren Speicher oder Ihre Oberfläche.

Usage

ts
import { iterationsFor, noblePbkdf2, encrypt, decrypt } from '@nostr-wot/identity/vault';

const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await noblePbkdf2.derive(password, salt, iterationsFor(password));

const { iv, ciphertext } = encrypt(key, mnemonic);
const recovered = decrypt(key, iv, ciphertext); // throws if the key is wrong

ciphertext carries the 16-byte GCM tag appended, which is the layout WebCrypto returns and expects. decrypt throws when the tag does not authenticate: a wrong key fails loudly rather than returning plausible garbage.

Both functions reject any key that is not exactly 32 bytes. AES itself accepts 16 and 24, so without that check a port returning a short key would silently produce AES-128 vaults and report success.

The caller owns the lifetime of the derived key and of the recovered plaintext. This package cannot zero either — a JavaScript string is immutable, so a decrypted mnemonic stays readable in the heap until the collector reclaims it. Hand it to whatever consumes it and do not stash it.

The empty password

iterationsFor('') returns the lower work factor on purpose. A "never lock" vault is stored under a password the source code supplies in public, so anyone holding the encrypted blob also holds the password and no amount of stretching makes it harder to open. The work factor buys nothing there and only costs latency on a path that runs at every cold start. A vault with a real password gets the full 600000, where stretching is the whole defence.

Pbkdf2Port

Pure JavaScript PBKDF2 at 600000 iterations costs roughly a second on a phone. The default noblePbkdf2 is pure JavaScript so the package works everywhere with no native dependency; a host that has a hardware accelerated PBKDF2 can implement the same interface and inject it.

ts
interface Pbkdf2Port {
  derive(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array>;
}

It is not on the common unlock path, which uses the hardware wrapped key rather than the password.

Memory accounts

An unlocked account holds its secrets as Uint8Array, not as strings, so lock() can zero them. A JavaScript string cannot be overwritten: an nsec held as one stays readable in the heap until the collector happens to reclaim it.

ts
const mem = toMemoryAccount(stored);   // privkeyBytes, mnemonicBytes, pq*SecretBytes, nip46.{secretBytes,localPrivkeyBytes}
zeroMemoryAccount(mem);                // every byte of key material is now zero
const back = toStorageAccount(mem);    // lossless, including fields this package does not model

The round trip carries unknown fields through untouched. The browser extension stores walletConfig on an account and this package deliberately does not model it; a host that read a vault, dropped the field and saved would silently destroy the user's wallet connection.

The Vault

Vault is the lifecycle over one injected KeyValueStore: create, unlock, lock, destroy, changePassword, the brute-force guard and the auto-lock. Nothing on it ever returns key material. Every secret is reached through a scoped accessor that hands a callback a copy, zeroes the copy on every path, and voids the result if the vault was locked while the callback ran:

AccessorHands fn
withPrivkey(accountId, fn)the 32-byte private key
withMnemonic(accountId, fn)the seed phrase as UTF-8 bytes
withImportedPqKeys(accountId, fn)the imported ML-KEM / ML-DSA secrets, with the public halves and profile
withCacheKey(fn)the 32-byte private-cache key
withRemoteSignerCredentials(accountId, fn)a NIP-46 account's config, local key and connect token as bytes

The callback computes and returns; it must not externalize anything, because a void arrives after a publish has already happened. The doc comment on withPrivkey has the full contract.

Account mutations each re-seal the record under the key already in memory, so none needs the password: addAccount, removeAccount (which also moves the session on, voiding any callback still holding that account's key), updateAccountNip46Keys, setImportedPqKeys and clearImportedPqKeys. A write the store refuses is undone in memory, so the open vault never describes a record that was not saved. listAccounts and getAccountById return public metadata through the SafeAccount allowlist; for a NIP-46 account the latter adds the public half of the connection (bunker pubkey, relay, local pubkey), never the credentials.

Host requirements

The portable account, vault, permission and signing modules use injected storage and host ports. They require TextEncoder, TextDecoder, secure crypto.getRandomValues, timers and AbortController; post-quantum paths additionally require atob/btoa or the supported Buffer fallback. Initialize the necessary React Native/Hermes polyfills before importing these modules, including react-native-get-random-values for secure randomness. Do not substitute a non-cryptographic random source.

The portable cryptographic core does not require the DOM, WebExtension APIs or WebCrypto subtle. Platform adapters such as WebAuthn ceremonies and relay transports have their own requirements. See the identity runtime guide.

This package uses TextEncoder and TextDecoder directly: for the password and the plaintext in crypto.ts, and for the mnemonic and NIP-46 secrets in serialization.ts.

API-Referenz