πŸ”‘ Key Derivation (KDF)

IBEx.Fi deterministically derives wallet keys from a single master secret tied to the user's passkey. One passkey β†’ one master β†’ addresses on every supported blockchain. The server never stores private keys in plaintext.

What is a KDF?

A Key Derivation Function (KDF) takes a high-entropy secret (the master) and produces one or more cryptographic keys. IBEx uses HKDF-SHA256 (RFC 5869):

derivedKey = HKDF(SHA-256, masterBytes, salt, info, length)
                                         β”‚       β”‚      β”‚
                    "ibexfi:derivation:v1"β”˜       β”‚      └── 32 bytes
                                 ecosystem label β”€β”˜

The info string uniquely identifies the target ecosystem, ensuring each chain gets a different key from the same master β€” without the master being recoverable from any derived key.

Architecture Overview

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Passkey (FIDO2 authenticator)                                   β”‚
β”‚                                                                  β”‚
β”‚  Option 1: PRF extension (preferred)                             β”‚
β”‚    salt = SHA-256("ibexfi:prf:v1:master|rpId:<rpId>")            β”‚
β”‚    β†’ authenticator returns prf.first bytes (32 bytes)            β”‚
β”‚                                                                  β”‚
β”‚  Option 2: Sealed master fallback                                β”‚
β”‚    β†’ backend generates 32 random bytes                           β”‚
β”‚    β†’ encrypted with AES-256-GCM, stored in DB                    β”‚
β”‚    β†’ decrypted at runtime when derivations needed                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                              β”‚
                      masterBytes (32 bytes)
                              β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β–Ό               β–Ό               β–Ό
       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
       β”‚  EVM EOA     β”‚ β”‚  Solana   β”‚ β”‚  Bitcoin     β”‚
       β”‚  secp256k1   β”‚ β”‚  ed25519  β”‚ β”‚  secp256k1   β”‚
       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚               β”‚               β”‚
              β–Ό               β–Ό               β–Ό
     And also: Cosmos, Polkadot, Tezos, NEAR, Stellar, Cardano

Master Secret: How It's Obtained

Option 1 β€” PRF (Preferred)

The WebAuthn PRF extension lets the authenticator evaluate a pseudorandom function over a server-provided salt. The result is deterministic for the same passkey + salt, but unpredictable to anyone without the authenticator.

// PRF salt (tenant-scoped, deterministic)
salt = SHA-256("ibexfi:prf:v1:master|rpId:" + effectiveRpId)

// Browser sends to authenticator β†’ returns prf.first (32 bytes)

With PRF, no secret material is stored server-side β€” the master only exists transiently in memory during the authentication response.

Option 2 β€” Sealed Master (Fallback)

When the authenticator doesn't support PRF, the backend generates 32 random bytes and stores them encrypted in the database:

// Encryption key
key = SHA-256(DERIVATION_SECRET + "|" + DERIVATION_SALT)   // 32 bytes

// Seal
AES-256-GCM(key, nonce=12 bytes, plaintext=masterBytes)
β†’ stored as { ct, iv, tag } in Signer.data.derivation.sealedMaster

Decryption requires both the database (ciphertext) and the server secrets (DERIVATION_SECRET / DERIVATION_SALT).

Sealed Envelope in Practice

The choice between PRF and sealed master is made automatically at each authentication ceremony. Here is the concrete flow:

Sign-Up

  1. User registers a passkey (WebAuthn)
  2. Browser returns clientExtensionResults.prf.results.first?
    • Yes (PRF capable) β†’ use it as masterBytes (32 bytes)
    • No (PRF not supported) β†’ backend generates 32 random bytes, encrypts them with sealMaster() (AES-256-GCM), stores { ct, iv, tag } in Signer.data.derivation.sealedMaster
  3. From masterBytes, all chain addresses are derived via HKDF
  4. Derived addresses are stored in Signer.data.derivation

Sign-In

  1. User authenticates with passkey
  2. Check prf.results.first:
    • Present β†’ use as masterBytes, source = PRF
    • Absent β†’ check for sealedMaster in DB:
      • Found β†’ unsealMaster() to decrypt, source = SEALED
      • Not found β†’ generate fresh 32 bytes, seal and persist, source = FRESH
  3. Re-derive all chain addresses (refresh in case new ecosystems were added)
  4. Update Signer.data.derivation in DB
Note: PRF-capable devices produce the same masterBytes every time for the same passkey + salt. The master only exists transiently in memory β€” it is never stored. Non-PRF devices rely on the sealed envelope, which requires both DB access and server secrets to unlock.

Derivation Table

All derivations use HKDF(SHA-256, masterBytes, salt, info, 32) with salt = "ibexfi:derivation:v1":

Ecosystem Info Label Curve / Algo Address Format
EVM (EOA) global:single_eoa secp256k1 Keccak-256, last 20 bytes, checksum
Solana solana:global Ed25519 base58-encoded public key
Bitcoin P2WPKH bitcoin:global secp256k1 bech32 (witness v0, bc1q...)
Bitcoin Taproot bitcoin:taproot secp256k1 + TapTweak bech32m (witness v1, bc1p...)
Cosmos cosmos:<hrp> secp256k1 bech32 (cosmos1..., osmo1...)
Polkadot polkadot:ss58 Ed25519 SS58 (base58 + BLAKE2b)
Tezos tz1 tezos:tz1 Ed25519 tz1... (base58check)
Tezos tz2 tezos:tz2 secp256k1 tz2... (base58check)
Tezos tz3 tezos:tz3 P-256 tz3... (base58check)
NEAR near:implicit Ed25519 hex public key (implicit account)
Stellar stellar:global Ed25519 G... (StrKey)
Cardano cardano:enterprise Ed25519 addr1... (bech32, BLAKE2b-224)

EVM Derivation: Step by Step

// 1. HKDF-SHA256 extract + expand
ikm  = masterBytes                         // 32 bytes
salt = "ibexfi:derivation:v1"
info = "global:single_eoa"
okm  = HKDF(SHA-256, ikm, salt, info, 32)  // 32 bytes output

// 2. Reduce mod secp256k1 curve order
n   = secp256k1.CURVE.n                    // ~1.158 Γ— 10⁷⁷
num = bytesToNumberBE(okm) % n
if (num == 0) num = 1                      // avoid zero scalar

// 3. Derive public key + Ethereum address
privateKey  = numberToBytesBE(num, 32)
publicKey   = secp256k1.getPublicKey(privateKey, false).slice(1)  // 64 bytes uncompressed
addressHash = Keccak-256(publicKey)
address     = "0x" + last20bytes(addressHash)                     // checksum

Safe Wallet vs Derived Wallets

The Safe{Wallet} and derived wallets use different mechanisms from the same identity anchor (the passkey Signer):

Aspect Safe{Wallet} Derived Wallets (EOA, Solana, BTC...)
Type Smart contract wallet Native chain addresses
Derivation X/Y coordinates of the P-256 passkey public key β†’ WebAuthn signer contract β†’ Safe address Master bytes (PRF or sealed) β†’ HKDF β†’ chain-specific key
Private key None (smart contract, no ECDSA key) Derived in-memory, never stored
Signing WebAuthn assertion (authenticator signs) Passkey PRF β†’ master β†’ derive key β†’ sign

PIN/KDF Wallet Mode

For clients that cannot use passkeys, IBEx supports a PIN/KDF mode (wallet=kdf) where the master is derived client-side from a PIN or password:

  1. Client derives private key from secret (PIN + KDF parameters)
  2. Client signs a challenge, sends signature + public key to server
  3. Server verifies signature β€” confirms the client holds the key
  4. Server stores only the public address (no private key)
  5. Safe address is computed deterministically from the EOA address
Key difference: In passkey mode, the server can request WebAuthn signatures via the authenticator. In KDF mode, the client must sign explicitly β€” the server only verifies.

Security Model

Threat Protection
DB breach (read sealed master) AES-256-GCM β€” requires server secrets to decrypt
Server secret leak Without DB ciphertext, secrets alone are useless
Both DB + secrets compromised Can derive keys β€” mitigate with KMS, rotation, monitoring
PRF mode: backend compromise Master never stored β€” only in memory during auth, from authenticator
Derived key recovery from address HKDF is a one-way function β€” address β†’ key is infeasible

Recommendations

⚠️ Warning: If the master secret is lost β€” passkey deleted without PRF backup, or DERIVATION_SECRET/DERIVATION_SALT lost for sealed envelopes β€” all derived wallets become permanently inaccessible. Funds on those addresses cannot be recovered.

The only safety net is the Recovery module: when enabled, a secondary signer (recovery key or social recovery guardian) can regain control of the Safe{Wallet}. Recovery does not restore the derived multi-chain addresses (Solana, Bitcoin…), only the Safe smart wallet.

Implementation

The derivation logic is in derivationService.ts, using @noble/hashes and @noble/curves:

See Also