derivault
v0.3.0
Published
Deterministically derive a wallet owner, RAILGUN seed, and ERC-5564 stealth keys from a WebAuthn PRF passkey or EIP-191 signature.
Maintainers
Readme
derivault
Deterministically derive non-custodial wallet and privacy keys for RAILGUN, ERC-5564 stealth addresses, and an owner EOA from an embedded-wallet signature or a synced WebAuthn PRF passkey.
The ERC-5564 (scheme 1) stealth implementation is tested for interoperability
against the reference SDK, @scopelift/stealth-address-sdk,
so stealth addresses generated by either side are mutually spendable.
Embedded wallets provide strong onboarding UX, but they do not normally hold
protocol-specific privacy keys. The naive fix is to generate those keys and keep
them on a backend, which quietly makes the backend custodial. derivault avoids
that by deriving keys on demand from the wallet's own signature, so nothing
secret has to be stored.
Status: pre-1.0 and unaudited.
derivaultderives keys that can control funds and has not had an external security audit. Read the code and the threat model before using it with real value, and treat the API and the derivation as subject to change until 1.0 (see Stability).
Requirements: a runtime with WebCrypto (globalThis.crypto.subtle) — Node
20+, or any modern browser in a secure context (HTTPS).
Install
npm i derivault viemviem is a normal dependency of this package and is also commonly used by
consumers for wallet types, signing, and account utilities.
Quickstart
import {
createSignatureUnlockSource,
deriveStealthPrivateKey,
generateStealthAddress,
openKeyvault,
parseStealthMetaAddress,
} from "derivault";
import type { Address, Hex } from "viem";
const source = createSignatureUnlockSource({
appName: "Example App",
address: user.wallet.address as Address,
chainId: 42161,
signMessage: async (message) => {
// Privy-style shape; adapt this wrapper to your wallet provider.
const { signature } = await signMessage({ message });
return signature as Hex;
},
});
const keyvault = await openKeyvault(source, { verifyDeterminism: true });
// Require the user to back up keyvault.railgunSeed.mnemonic before receiving
// funds. It is the only recovery path if signature determinism ever breaks.
// Use these with the RAILGUN SDK wallet creation/import flow.
const railgunSeed = keyvault.railgunSeed.mnemonic;
const railgunEncryptionKey = keyvault.railgunSeed.encryptionKey;
// Publish or store this as the user's ERC-5564 receiving meta-address.
const receivingMetaAddress = keyvault.stealthMetaAddress;
// Sender side: generate a stealth address from the recipient's meta-address.
// The returned announcement is safe to publish — it contains no private key.
const recipientMeta = parseStealthMetaAddress(receivingMetaAddress);
const announcement = generateStealthAddress(recipientMeta);
// Receiver side: derive the private key for an announced stealth address.
const stealthPrivateKey = deriveStealthPrivateKey({
spendingPrivateKey: keyvault.stealth.spendingPrivateKey,
viewingPrivateKey: keyvault.stealth.viewingPrivateKey,
ephemeralPublicKey: announcement.ephemeralPublicKey,
});Passkey quickstart
Passkeys must be used in a secure browser context. Registration requires a discoverable credential and reports whether its authenticator supports PRF.
import {
createWebAuthnPrfUnlockSource,
openKeyvault,
registerPasskeyWithPrf,
} from "derivault";
const registration = await registerPasskeyWithPrf({
appName: "Example App",
userName: "[email protected]",
});
if (!registration.prfSupported) throw new Error("PRF is not supported");
const source = createWebAuthnPrfUnlockSource({
appName: "Example App",
credentialId: registration.credentialId,
});
const keyvault = await openKeyvault(source);
// Local ECDSA owner for SIWE and smart-account validators.
const owner = keyvault.walletEoa;How It Works
The unlock source obtains an EIP-191 personal_sign signature over a stable,
human-readable message. Those signature bytes are used as HKDF-SHA256 key
material, then expanded with permanent domain-separated info labels.
embedded wallet signature or WebAuthn PRF output
-> canonical 64-byte r || low-s (encoding-invariant; recovery-checked)
-> HKDF-SHA256, salt + source-bound (domain-separated info labels)
-> 16 bytes mnemonic entropy -> BIP-39 12-word RAILGUN seed
-> 32 bytes RAILGUN encryptionKey
-> secp256k1 stealth spending scalar
-> secp256k1 stealth viewing scalar
-> ERC-5564 stealth meta-address
-> secp256k1 wallet owner EOAThe RAILGUN mnemonic uses 16 bytes of entropy, producing a 12-word phrase. The
RAILGUN encryptionKey is 32 bytes encoded as 64 lowercase hex characters
without a 0x prefix. Stealth private keys are derived by reducing a 64-byte
HKDF expansion into the secp256k1 scalar field with (value % (n - 1)) + 1.
Security Model
The root of trust is the embedded wallet and the user's authentication to that
wallet. A backend using this package stores no mnemonic, encryption key, stealth
private key, unlock signature, or HKDF material. The package is provider
agnostic: any signer that can produce a byte-stable EIP-191 personal_sign
signature can be wrapped as an UnlockSecretSource.
Two caveats are inherent. First, derived keys necessarily live in client memory
while they are being used for client-side privacy workflows, so a compromised
frontend can steal them. Second, continuity depends on the signer returning a
stable ECDSA (r, s) for the same message across sessions and devices — the
signature encoding is normalized, so only (r, s) must be stable. Mitigate that
with verifyDeterminism during provisioning and by requiring users to back up
the 12-word phrase before receiving funds.
See THREAT_MODEL.md for the full threat model.
Works with Kohaku
derivault works alongside the Ethereum Foundation's Kohaku privacy SDK (the
@kohaku-eth/* npm packages) and sits upstream of it. Kohaku is a wallet-level
privacy SDK for protocols such as RAILGUN and Privacy Pools. derivault
provides the non-custodial key-derivation layer for teams that already build on
an embedded wallet (Privy, passkeys) and do not want to adopt a separate wallet
stack.
Kohaku's RAILGUN plugin does not take a RAILGUN mnemonic directly; it derives
its keys from a Host keystore. The integration is therefore to use the
derivault mnemonic — derived non-custodially from the embedded-wallet
signature — as the seed for Kohaku's MnemonicKeystore.
Illustrative only, and written against @kohaku-eth/railgun and
@kohaku-eth/plugins at 0.0.1-alpha — this integration is not covered by this
repo's test suite. Those packages are unaudited and may introduce breaking
changes, so re-check the API against the versions you install.
import { openKeyvault } from "derivault";
import { MnemonicKeystore, MemoryStorage } from "@kohaku-eth/plugins";
import { createRailgunPlugin } from "@kohaku-eth/railgun";
const keyvault = await openKeyvault(source, { verifyDeterminism: true });
// derivault derives the mnemonic from the embedded wallet, non-custodially.
// Kohaku's keystore consumes it and derives its own RAILGUN spending/viewing
// keys via HD paths, so that key material never has to be generated or stored
// by your backend.
const host = {
keystore: new MnemonicKeystore(keyvault.railgunSeed.mnemonic),
provider, // your @kohaku-eth/provider / EIP-1193 provider
storage: new MemoryStorage(), // or a persistent Storage implementation
// ...any other fields your Kohaku version's Host requires
};
const railgun = await createRailgunPlugin(host, { keyIndex: 0 });In this model derivault supplies the non-custodial keystore seed and Kohaku
derives the protocol keys from it. derivault's own encryptionKey and stealth
keys are not used on the Kohaku path.
For stealth addresses, keyvault.stealthMetaAddress is a standard ERC-5564
meta-address usable with any ERC-5564 tooling. Kohaku does not currently ship a
stealth-address module, so derivault complements it there.
Determinism And Domain Separation
The unlock message is part of the derivation domain. Its text, appName, chain
ID, address, and version determine the signature, and therefore determine every
derived key. Once an app has users, changing its unlock message or appName
re-derives all keys.
The HKDF salt and INFO labels exported by this package are also permanent
commitments. Different app names intentionally derive different keys, giving
apps separate key domains even when the same wallet address is used. The
chainId is likewise part of the domain: RAILGUN mnemonics and ERC-5564
meta-addresses are chain-agnostic, so a multi-chain app should choose one
canonical chainId and never vary it — otherwise each chain derives a separate,
unfunded identity for the same user.
The signature is canonicalized before derivation — reduced to its 64-byte
r || low-s form — so encoding differences between signer stacks (v=27/28 vs
yParity=0/1, 65-byte vs EIP-2098 compact, non-low-s) do not change the derived
keys. What must still hold is that the signer returns a stable (r, s) for the
same message; typical wallets use RFC-6979 deterministic ECDSA. The source also
rejects non-ECDSA (smart-account / WebAuthn) signatures and verifies the
signature recovers to the expected signer.
verifyDeterminism re-derives twice within one session, so it only catches a
randomized signer on the current device — it cannot detect drift across devices
or across a provider changing its signer. The durable safeguard is the exported
recovery phrase; require users to back it up before receiving funds.
Stability
Two version numbers matter here, and they are independent:
- Package version (npm semver). Pre-1.0, minor releases may make breaking API changes, documented in CHANGELOG.md.
- Derivation-domain version — the unlock message,
KEYVAULT_UNLOCK_VERSION, the HKDF salt, and theINFOlabels. These are permanent: a user's keys are a pure function of them, so changing any one silently re-derives every user's keys and presents as total fund loss.
If the derivation ever needs to change, it will ship as a new versioned
domain (e.g. :v2 labels and a bumped message version) alongside the old one,
never as an in-place edit, so existing users keep deriving their existing keys.
Frozen-domain tests in this repo enforce that.
API Reference
buildUnlockMessage(appName, address, chainId): builds the stable EIP-191 message to sign.KEYVAULT_UNLOCK_VERSION: current unlock message version.createSignatureUnlockSource(options): wraps an EIP-191 signer as anUnlockSecretSource.createWebAuthnPrfUnlockSource(options): reads a deterministic secret from a synced passkey using the WebAuthn PRF extension.registerPasskeyWithPrf(options): creates a discoverable passkey and reports PRF support.openKeyvault(source, opts?): derives the RAILGUN seed, stealth keypair, stealth meta-address, and owner EOA (keyvault.walletEoa).KeyvaultDeterminismError: thrown whenverifyDeterminismdetects changing derivation output.INFO: permanent HKDFinfolabels.KEYVAULT_HKDF_SALT: permanent HKDF salt.parseStealthMetaAddress(metaAddress): splits an ERC-5564 meta-address into spending and viewing public keys.generateEphemeralPrivateKey(): creates a random secp256k1 private key.getPublicKey(privateKey): returns the compressed secp256k1 public key.computeSharedSecret(privateKey, publicKey): computes the ERC-5564 shared secret (the compressed ECDH point).generateStealthAddress(metaAddress, ephemeralPrivateKey?): creates an ERC-5564 stealth address announcement ({ stealthAddress, ephemeralPublicKey, viewTag }) — the publishable data only; it does not return the ephemeral private key.createAnnouncementMetadata(viewTag, metadataHash): prefixes metadata with a view tag byte.createStealthMetaAddress(spendingPublicKey, viewingPublicKey): concatenates compressed spending and viewing public keys.generateStealthKeyPair(): creates a random private/public keypair.deriveStealthPrivateKey(args): derives the receiver's private key for a stealth address announcement.checkStealthAddress(args): receiver-side check of whether an announced stealth address belongs to you (optional view-tag fast-reject).SECP256K1_SCHEME_ID: ERC-5564 secp256k1 scheme identifier.getKeyvaultMeta(address, storage?): reads optional app metadata from injected storage or browserlocalStorage.setKeyvaultMeta(address, meta, storage?): writes optional app metadata.clearKeyvaultMeta(address, storage?): removes optional app metadata.- Types:
Address,Hex,UnlockSourceKind,UnlockSecret,UnlockSecretSource,RailgunSeed,StealthKeypair,WalletEoa,Keyvault,WebAuthnPrfRegistration,StealthMetaAddress,StealthAddress,KeyvaultMeta, andKeyvaultMetaStorage.
Extensibility
UnlockSecretSource is intentionally small:
interface UnlockSecretSource {
readonly accountId: string;
getUnlockSecret(): Promise<UnlockSecret>;
}The included sources are EIP-191 signatures and WebAuthn PRF passkeys. Further sources can be added without changing the derivation pipeline; every source kind is bound into the HKDF input domain.
Non-Goals
derivault is not a wallet, not a RAILGUN SDK wrapper, not a relayer, and not a
proof or POI implementation. It does not store, sync, transmit, or back up keys.
It only derives deterministic non-custodial privacy keys from an unlock secret
and provides ERC-5564 helper functions.
License
MIT
