Skip to main content
Sovran uses industry-standard cryptographic key derivation from a single BIP-39 mnemonic seed phrase. This page documents the technical implementation.

Overview

All keys in Sovran are deterministically derived from a 12-word BIP-39 mnemonic using standardized derivation paths:

BIP-39

12-word mnemonic seed phrase (128 bits entropy)

BIP-32

Hierarchical Deterministic (HD) key derivation

NIP-06

Nostr key derivation from mnemonic

NUT-13

Cashu wallet derivation (unofficial)

Key Derivation Architecture

BIP-39 Mnemonic Generation

Sovran generates a 12-word mnemonic (128 bits of entropy) for new wallets:
async function generateMnemonic(): Promise<string> {
  try {
    // Generate 128 bits of entropy (16 bytes) for a 12-word mnemonic
    const entropy = new Uint8Array(16);
    crypto.getRandomValues(entropy);

    // Generate mnemonic from entropy
    const mnemonic = bip39.entropyToMnemonic(entropy, wordlist);

    console.log('Generated new mnemonic');
    return mnemonic;
  } catch (error) {
    console.error('Failed to generate mnemonic:', error);
    throw new Error('Failed to generate mnemonic');
  }
}

Secure Storage

Mnemonics are stored using expo-secure-store:
  • iOS: Keychain Services (encrypted in Secure Enclave)
  • Android: Keystore System (hardware-backed encryption)
The mnemonic never leaves secure storage except during key derivation operations.

Nostr Key Derivation (NIP-06)

Nostr keys follow NIP-06 standard derivation:

Derivation Path

m/44'/1237'/<account>'/0/0
  • 44': BIP-44 purpose (HD wallets)
  • 1237': Nostr coin type (registered in SLIP-0044)
  • <account>': Account index (default: 0)
  • 0/0: Change index / Address index (always 0 for Nostr)

Implementation

export function deriveNostrKeys(mnemonic: string, accountIndex: number = 0): DerivedNostrKeys {
  const { privateKey: sk, publicKey: pk } = nip06.accountFromSeedWords(
    mnemonic,
    undefined,
    accountIndex
  );

  return {
    npub: nip19.npubEncode(pk),  // Bech32-encoded public key
    nsec: nip19.nsecEncode(sk),  // Bech32-encoded private key
    pubkey: pk,                   // Hex public key
    privateKey: sk,               // Raw private key bytes
  };
}

Derived Key Formats

FormatDescriptionExample
npubBech32 public keynpub1abc...xyz
nsecBech32 private keynsec1def...uvw
pubkeyHex public key (32 bytes)a1b2c3d4...
privateKeyRaw bytes (32 bytes)Uint8Array(32)

Cashu Wallet Derivation

Cashu wallets use a custom derivation path (not yet standardized in NUT-13):

Derivation Path

m/44'/129372'/0'/<account>'/0/0
  • 44': BIP-44 purpose
  • 129372': Cashu coin type (unofficial, may change)
  • 0': Wallet index (always 0)
  • <account>': Account index (matches Nostr account)
  • 0/0: Change / Address (always 0)

Two-Stage Derivation

Cashu derivation is a two-stage process:
  1. Derive child private key (32 bytes) from BIP-32 path
  2. Re-encode as BIP-39 mnemonic (24 words) for Cashu wallet
const CASHU_DERIVATION_PREFIX = `m/44'/129372'`;

export function deriveCashuMnemonic(mnemonic: string, accountIndex: number = 0): string {
  const seed = bip39.mnemonicToSeedSync(mnemonic);
  const root = HDKey.fromMasterSeed(seed);
  const path = `${CASHU_DERIVATION_PREFIX}/0'/${accountIndex}'/0/0`;
  const child = root.derive(path);
  return bip39.entropyToMnemonic(child.privateKey as Uint8Array, wordlist);
}
The Cashu mnemonic (24 words) is derived deterministically and can be re-derived from the root mnemonic at any time. It is cached in secure storage for performance.

Key Caching & Performance

To avoid expensive re-derivation, Sovran caches derived keys in secure storage:

Cache Structure

Cache Types (helper/secureStorage.ts:16-22)
export interface CachedDerivedKeys {
  npub: string;
  nsec: string;
  pubkey: string;
  privateKeyHex: string;
  mnemonicHash: string;  // Fingerprint to detect mnemonic changes
}

Cache Invalidation

Caches are invalidated when:
  • Root mnemonic changes (detected via hash)
  • User explicitly clears secure storage
  • App is uninstalled
export function hashMnemonic(mnemonic: string): string {
  let hash = 0;
  for (let i = 0; i < mnemonic.length; i++) {
    hash = (hash * 31 + mnemonic.charCodeAt(i)) | 0;
  }
  return hash.toString(36);
}

Fast Path vs Slow Path

From providers/NostrKeysProvider.tsx:300-345:
Cache Logic
const mHash = hashMnemonic(mnemonicToUse);

// Try loading cached keys from SecureStore (fast path)
const [cachedDerived, cachedCashu] = await Promise.all([
  retrieveDerivedKeys(defaultAccountIndex),
  retrieveCashuMnemonic(defaultAccountIndex),
]);

const cacheValid =
  cachedDerived?.mnemonicHash === mHash && cachedCashu?.mnemonicHash === mHash;

if (cacheValid && cachedDerived && cachedCashu) {
  // Fast path: use cached keys
  defaultKeys = {
    npub: cachedDerived.npub,
    nsec: cachedDerived.nsec,
    pubkey: cachedDerived.pubkey,
    privateKey: hexToBytes(cachedDerived.privateKeyHex),
  };
  defaultCashuMnemonic = cachedCashu.value;
} else {
  // Slow path: derive from scratch and persist to SecureStore
  defaultKeys = deriveNostrKeys(mnemonicToUse, defaultAccountIndex);
  defaultCashuMnemonic = deriveCashuMnemonicPure(mnemonicToUse, defaultAccountIndex);

  // Persist to SecureStore in the background (don't block)
  const cachePayload: CachedDerivedKeys = {
    npub: defaultKeys.npub,
    nsec: defaultKeys.nsec,
    pubkey: defaultKeys.pubkey,
    privateKeyHex: bytesToHex(defaultKeys.privateKey),
    mnemonicHash: mHash,
  };
  Promise.all([
    storeDerivedKeys(defaultAccountIndex, cachePayload),
    storeCashuMnemonic(defaultAccountIndex, defaultCashuMnemonic, mHash),
  ]);
}

Multi-Account Support

Sovran supports multiple accounts (profiles) from a single mnemonic:
Account Derivation
// Account 0 (default)
Nostr: m/44'/1237'/0'/0/0
Cashu: m/44'/129372'/0'/0'/0/0

// Account 1
Nostr: m/44'/1237'/1'/0/0
Cashu: m/44'/129372'/0'/1'/0/0

// Account 2
Nostr: m/44'/1237'/2'/0/0
Cashu: m/44'/129372'/0'/2'/0/0
Each account has:
  • Unique Nostr identity (different npub/nsec)
  • Separate Cashu wallet (different ecash proofs)
  • Independent mint trust lists
  • Isolated transaction histories
See Multi-Account for user-facing documentation.

Security Considerations

Mnemonic Entropy

  • 12 words = 128 bits entropy = 2^128 possible seeds
  • Cryptographically secure randomness via crypto.getRandomValues()
  • Wordlist: BIP-39 English (2048 words)

Key Storage

Mnemonic stored in device secure enclave (iOS Keychain / Android Keystore)
Derived keys cached in secure storage with mnemonic hash validation
Private keys never leave secure storage except for signing operations
No keys transmitted over network

Attack Surface

Threats:
  • Device compromise (malware, rooted/jailbroken device)
  • Physical access to unlocked device
  • Side-channel attacks during derivation
  • Weak device passcode (brute-force secure storage)
Mitigations:
  • Hardware-backed encryption (Secure Enclave / Keystore)
  • Passcode lock (app-level protection)
  • No cloud backups of keys
  • Open source code (auditable)

Standards Compliance

StandardPurposeCompliance
BIP-39Mnemonic generation✅ Full (12-word, English wordlist)
BIP-32HD key derivation✅ Full (@scure/bip32)
BIP-44Multi-account hierarchy✅ Full (purpose = 44’)
NIP-06Nostr key derivation✅ Full (coin type 1237’)
NUT-13Cashu deterministic secrets⚠️ Partial (custom path, subject to change)
SLIP-0044Coin type registry✅ Registered (Nostr = 1237)
Cashu derivation path (m/44'/129372'/0'/<account>'/0/0) is not yet standardized in NUT-13. This may change in future versions.

Code References

keyDerivation.ts

Core derivation functions (NIP-06, Cashu)

secureStorage.ts

Mnemonic storage and caching

NostrKeysProvider.tsx

Key derivation orchestration

useSecureStore.ts

React hooks for secure storage

Wallet Recovery

Restore wallet from seed phrase

Multi-Account

Using multiple accounts from one seed

Build docs developers (and LLMs) love