Skip to main content

Key Management and Derivation

To preserve the protocol user's privacy, we decorrelate user "control" accounts while keeping the relationship between root keys and derived keys provable in zero knowledge.

Using Privy for Root Keys

Privy is a wallet provisioning service that combines the best of all worlds. It can manage user owned embedded wallets in TEEs, works across many chains, comes with a battle-tested UI, runs on arbitrary environments and devices, has proven to be secure at scale and it integrates like a charm with Wagmi and React libraries. Also, it doesn't stand in the way when users want to bring their own wallets - the usage difference is really only the UI. That's why we decided trusting Privy to provide key material for users who absolutely don't want to bother with any crypto terms.

Why crypto keys are really bad for privacy, even when you control them

In his opinion piece from April, Vitalik underlines the importance of privacy and that today's understanding of zkSNARKs is perfectly sufficient to operate privacy preserving protocols. Vitalik argues that while cryptographic systems provide security guarantees, they create a fundamental privacy problem through key correlation. Once someone's cryptographic identity (key/address) is known or linked to their real identity, all their past and future cryptographic activities using that key become traceable and correlatable.

His thesis is that cryptographic systems, while secure, inherently compromise privacy through persistent key-based identity correlation, but this problem can now be solved through zero-knowledge proofs and other modern privacy technologies that maintain security while breaking the link between identity and transaction history.

Using HKDF to derive keys for purpose specific usage

There's no shortage of potential options of how keys could be derived from basic entropy. The most obvious choice is to go with well known BIP-32 HD key derivation that lets you derive an arbitrary amount of accounts from random secret.

Our precursor is slightly different, however: we want to deterministically derive keys using a replayable piece of information that users can only create with their control key and secrets that either are known to the user or parties the user trusts. A key derivation mechanism that we choose must also work for smart contract accounts (e.g. Safe or EIP-7702 contracts) and support signatures conforming to EIP-1271, EIP-7913 and eventually EIP-7739.

RFC 5869 (HMAC-based Extract-and-Expand Key Derivation Function (HKDF)) provides a cryptographically robust alternative to BIP-32 for general-purpose key derivation. The extract-then-expand paradigm first concentrates entropy through HMAC-based extraction, then generates multiple derived keys through controlled expansion.

Our specific implementation is rooted in a 2025 paper from IBM, ETH Zürich, and TU Darmstadt that explores how to add several inputs into the key derivation function.

For secp256k1 applications, HKDF excels in ECDH key agreement scenarios where parties derive symmetric keys from shared secrets. The system ensures derived keys meet secp256k1's range requirements (0 < key < n) through iterative generation with incrementing info parameters.

HKDF's strength lies in its formal security analysis and general applicability. It serves broader cryptographic protocols while maintaining provable security under the pseudorandom function assumption for HMAC.

This is actual code that runs when users derive storage or application keys by signing EIP-712 derivation messages with their root control wallets (injected or privy):

sessionKeys.ts
import { Address, Hex, hexToBytes, PublicClient, WalletClient } from "viem";
import { deriveKey } from "./key-derivation";

interface SessionKeyAuthMessage {
keyId: string;
context: string;
}

interface AuthorizedSessionProof {
message: SessionKeyAuthMessage;
signature: Hex;
signer: Address;
timestamp: string;
}

interface SessionKeyData {
sessionKeyPair: Nillion.Keypair;
authorizationProof: AuthorizedSessionProof;
authorizedBy: Hex; // Main wallet address
signature: Hex; // Main wallet's signature authorizing this session key
}

/**
* Generate a new purpose key pair and get authorization signature from main wallet
*/
export async function deriveAuthorizedKeypair(
walletClient: WalletClient,
authMessage: SessionKeyAuthMessage,
userSecret: string,
options?: {
domainName: string,
primaryType: string ,
extendedTypes: Array<{name: string, type: string}> | undefined
}
): Promise<SessionKeyData> {
if (!walletClient?.account?.address) {
throw new Error("Wallet not connected");
}

const primaryType = options?.primaryType || "SessionKeyAuthorization";
const domainName = options?.domainName || "Welshare Health Wallet";

//note the alphabetical prop ordering is important to recreate the signature payload
const typedData = {
domain: {
name: domainName,
version: "1.0"
},
message: authMessage,
primaryType,
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
],
[primaryType]: options?.extendedTypes || [
{ name: "context", type: "string" },
{ name: "keyId", type: "string" },
],
},
};

// Sign the authorization message with root wallet
const bindingSignature = await walletClient.signTypedData(typedData);

const _userSecret = new TextEncoder().encode(userSecret);
const derivedKey = await deriveKey(
_userSecret,
hexToBytes(bindingSignature),
authMessage
);

const authorizationProof: AuthorizedSessionProof = {
message: authMessage,
signature: bindingSignature,
signer: walletClient.account.address,
timestamp: new Date().toISOString(),
};

return {
sessionKeyPair: derivedKey,
authorizedBy: walletClient.account.address,
authorizationProof,
signature: bindingSignature,
};
}

And the key derivation code, implementing RFC 5869:

/lib/key-derivation.ts
import { secp256k1 } from "@noble/curves/secp256k1";
import { hmac } from "@noble/hashes/hmac";
import { sha256 } from "@noble/hashes/sha2";
import { concatBytes, utf8ToBytes } from "@noble/hashes/utils";

/**
* User secret examples:
* - Password-derived key: PBKDF2 output from user password
* - Hardware wallet entropy: Internal randomness from secure element
* - Mnemonic-derived: BIP32 master seed from recovery phrase
* - App-specific secret: User's secret data for this application
*/
type UserSecret = Uint8Array; // 32 bytes of entropy

/**
* Key ID examples:
* - Sequential: 0, 1, 2, 3... for multiple keys
* - Purpose-based: "signing", "encryption", "authentication"
* - Account-based: "account-0", "account-1" for different accounts
* - Feature-based: "email-key", "document-key", "chat-key"
*/
type KeyId = string;

/**
* Context examples:
* - Application: "myapp.com", "wallet.ethereum.org"
* - Version: "v1.0", "beta", "production"
* - Environment: "development", "staging", "production"
* - Domain: "user@company.com", "tenant-123"
*/
type Context = string;

const COMMON_KDF_SALT = "SIGNATURE_INTEGRATED_KDF_v1";

/**
* HKDF implementation using HMAC-SHA256
*/
export function hkdf(
inputKeyMaterial: Uint8Array,
contextInformation: Uint8Array,
salt: Uint8Array = utf8ToBytes(COMMON_KDF_SALT),
privateKeyLength: number = 32
): Uint8Array {
// Extract phase (master key for expand phase)
const pseudoRandomKey = hmac(sha256, salt, inputKeyMaterial);

// Expand phase
const output = new Uint8Array(privateKeyLength);
const hashLen = 32; // SHA256 output length
const n = Math.ceil(privateKeyLength / hashLen);

let t = new Uint8Array(0);
let outputPos = 0;

//expand phase
for (let i = 1; i <= n; i++) {
const input = concatBytes(t, contextInformation, new Uint8Array([i]));
t = hmac(sha256, pseudoRandomKey, input);

const copyLen = Math.min(hashLen, privateKeyLength - outputPos);
output.set(t.subarray(0, copyLen), outputPos);
outputPos += copyLen;
}

return output;
}

/**
* Ensure the derived key material is valid for secp256k1
*/
function ensureValidSecp256k1Key(
keyMaterial: Uint8Array,
derivationData: Uint8Array
): Uint8Array {
const n = secp256k1.CURVE.n; // secp256k1 curve order
let candidate = keyMaterial;
let counter = 0;

while (true) {
const keyValue = bytesToBigInt(candidate);
if (keyValue > 0n && keyValue < n) {
return candidate;
}

// If invalid, derive a new candidate
counter++;
const counterBytes = new Uint8Array(4);
new DataView(counterBytes.buffer).setUint32(0, counter, false);

candidate = hkdf(
concatBytes(keyMaterial, counterBytes),
utf8ToBytes("SECP256K1_RETRY"),
derivationData,
32
);

if (counter > 1000) {
throw new Error(
"Failed to generate valid secp256k1 key after 1000 attempts"
);
}
}
}

/**
* Create deterministic derivation data for a specific key
*/
function createDerivationData(authMessage: SessionKeyAuthMessage): Uint8Array {
return utf8ToBytes(JSON.stringify(authMessage));
}

/**
* Derive a new key with cryptographic binding to root key
*/
export async function deriveKey(
userSecret: UserSecret,
bindingSignature: Uint8Array,
authMessage: SessionKeyAuthMessage
): Promise<Nillion.Keypair> {
// Create derivation commitment
const derivationData = createDerivationData(authMessage);

// Use signature as additional entropy in multi-input KDF
const derivedKeyMaterial = hkdf(
concatBytes(userSecret, bindingSignature),
derivationData
);

// Step 4: Ensure the derived key is valid for secp256k1
const privateKey = ensureValidSecp256k1Key(
derivedKeyMaterial,
derivationData
);

return Nillion.Keypair.from(privateKey);
}

Proving Control of Derived Keys

It's not trivial for a user to prove to a third party that they control a key they derived from their root key as the required signature and the salt values needed to recover the key would allow verifiers to recreate the actual key material.

We could let them cross sign a two sided "I am account x and I control account y" message - this would prove that the key holders both verifiably claim that they control both keys at the same time. This approach would fully disclose the relationship between purpose driven keys and root keys, thereby breaking privacy guarantees - particularly if the verification happens on a public blockchain.

As it turns out, it's very much feasible to generically prove the control over both keys in zero knowledge by using the root signature as a secret input, use a well defined key derivation function as a circuit and demonstrate signing a random public message.

If that message is chosen as some uncorrelated new account created by the prover, a user could e.g. claim rewards for certain actions they used a derived key pair for. They can claim the actual rewards using the fully uncorrelated account and create a nullifier that would refuse executing the claim twice.

Historically Proving EIP-1271 Signature Validity

EIP-1271 relies on chain state and contract functions to verify signature validity. Hence a contract signature's validity can change from one block to another, depending on the implementation (e.g. a signature that increases the signer threshold of a Safe that's signed by the previous amount of signers would be immediately not considered valid after it passed).

Proving in zero knowledge that some signature was valid at a certain point in time requires to prove EVM execution and transaction ordering at that time. We ran a larger anaylsis on that topic earlier and we're convinced that it will be possible to safely use EIP-1271 signers as root entities for Welshare profiles: https://welshare.notion.site/Proving-Historical-EIP-1271-Signature-Validity-with-Ethereum-State-Proofs-22b5be1dc95d80be8949f1bd7fc80f1e?source=copy_link

Key Derivation Implementation Notes

While Claude Code helped us translating the key derivation process into Python code, we stumbled upon some issue related to different interpretations of coding primitives. The following is the compaction of our learnings. The key derivation process creates Nillion did:nil keypairs from Ethereum EOA private keys using EIP-712 signature-based entropy and HKDF.

Process Flow:

  1. Derive Ethereum EOA from BIP44 HD wallet (m/44'/60'/0'/0/{index})
  2. Sign EIP-712 structured message with Ethereum private key
  3. Combine signature + user secret as HKDF input
  4. Derive 32-byte key material using HKDF
  5. Ensure key is valid for secp256k1 curve
  6. Generate Nillion did:nil keypair from derived private key

Critical Implementation Details

1. JSON Field Ordering (MOST CRITICAL)

Problem: JSON field ordering differs between TypeScript and Python.

TypeScript behavior:

const authMessage = { keyId: "1", context: "nillion" };
JSON.stringify(authMessage);
// Output: {"context":"nillion","keyId":"1"}
// Alphabetical ordering: context before keyId

Python solution:

def to_dict(self) -> Dict[str, str]:
# MUST preserve alphabetical order to match TypeScript
d = {}
d["context"] = self.context # context FIRST
d["keyId"] = self.key_id # keyId SECOND
return d

# Use separators to match TypeScript compact format
json.dumps(auth_dict, separators=(',', ':'))

Why this matters:

  • This JSON is used as the HKDF context information
  • Even one byte difference changes the entire derived keypair
  • TypeScript's JSON.stringify outputs fields alphabetically by default
  • Python's json.dumps with sort_keys=True also alphabetizes, but the insertion order matters when sort_keys=False

Test case result:

  • Wrong order: did:nil:0324fb5d4a3c983a4ef2bd5b7eee31fe01ad97aaeff96470c9f2eafd1730ba61c0
  • Correct order: did:nil:03ecd47816bb8f475734b77aa9a3f4cc19a6075f3f603de0eebe6e11a784bb2e2d

2. EIP-712 Signature Format

Format: 65 bytes (r + s + v)

  • r: 32 bytes (signature component)
  • s: 32 bytes (signature component)
  • v: 1 byte (recovery id)

Python implementation:

from eth_account.messages import encode_typed_data

encoded_message = encode_typed_data(full_message=typed_data)
signed_message = account.sign_message(encoded_message)
signature_bytes = signed_message.signature # 65 bytes

TypeScript equivalent:

const bindingSignature = await walletClient.signTypedData(typedData);
// Returns hex string like "0x43f8f2f0..."
const signatureBytes = hexToBytes(bindingSignature); // 65 bytes

Note: Both implementations produce identical 65-byte signatures.

3. HKDF Implementation

Salt: SIGNATURE_INTEGRATED_KDF_v1 (hardcoded constant)

Python implementation:

def hkdf(input_key_material: bytes, context_information: bytes,
salt: bytes = COMMON_KDF_SALT, output_length: int = 32) -> bytes:
# Extract phase
prk = hmac.new(salt, input_key_material, hashlib.sha256).digest()

# Expand phase
t = b''
for i in range(1, n + 1):
t = hmac.new(prk, t + context_information + bytes([i]), hashlib.sha256).digest()
output.extend(t)

return bytes(output[:output_length])

Critical parameters:

  • Input: user_secret (UTF-8 bytes) + signature (65 bytes) = 80 bytes
  • Context: JSON.stringify(authMessage) as UTF-8 bytes
  • Salt: b"SIGNATURE_INTEGRATED_KDF_v1"
  • Output: 32 bytes

4. secp256k1 Key Validation

Problem: Not all 32-byte values are valid secp256k1 private keys.

Valid range: 0 < key < n where n = secp256k1.CURVE.n

Python implementation:

SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

def ensure_valid_secp256k1_key(key_material: bytes, derivation_data: bytes) -> bytes:
candidate = key_material
counter = 0

while counter < 1000:
key_value = int.from_bytes(candidate, byteorder='big')
if 0 < key_value < SECP256K1_ORDER:
return candidate

# Re-derive with counter if invalid
counter += 1
counter_bytes = counter.to_bytes(4, byteorder='big')
candidate = hkdf(
candidate + counter_bytes,
b"SECP256K1_RETRY",
derivation_data,
32
)

5. Public Key Compression

Format: 33 bytes (1-byte prefix + 32-byte x-coordinate)

Prefix determination:

  • 0x02 if y-coordinate is even
  • 0x03 if y-coordinate is odd

Python implementation:

from ecdsa import SigningKey, SECP256k1

signing_key = SigningKey.from_string(private_key, curve=SECP256k1)
verifying_key = signing_key.get_verifying_key()
public_key_uncompressed = verifying_key.to_string() # 64 bytes

x_coord = public_key_uncompressed[:32]
y_coord = public_key_uncompressed[32:]
y_is_odd = y_coord[-1] & 1
prefix = b'\x03' if y_is_odd else b'\x02'
compressed_public_key = prefix + x_coord # 33 bytes

DID format: did:nil:{compressed_public_key_hex}

6. Byte Concatenation

Critical order:

# Input key material: user_secret + signature
user_secret_bytes = "user@secret.com".encode('utf-8') # 15 bytes
binding_signature = ... # 65 bytes
input_key_material = user_secret_bytes + binding_signature # 80 bytes total

Must match TypeScript:

const _userSecret = new TextEncoder().encode(userSecret);
const input = concatBytes(_userSecret, hexToBytes(bindingSignature));

Common Pitfalls

❌ Using sort_keys=True in json.dumps

  • Creates different JSON than TypeScript
  • Results in completely different derived keypair

❌ Wrong field insertion order

  • Even with alphabetical fields, must insert context before keyId

❌ Including 0x prefix in signature

  • Signature should be raw bytes, not hex string with prefix

❌ Wrong public key format for DID

  • Must use compressed (33 bytes), not uncompressed (64 bytes)

❌ Not validating secp256k1 key range

  • Can result in invalid private keys that can't be used

Test Vectors

Known safe test key (Hardhat/Ganache default account #0):

Private Key: ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
Ethereum Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Auth Message: { keyId: "1", context: "nillion" }
User Secret: "user@secret.com"

Expected Results:
- EIP-712 Signature: 43f8f2f081a113628a5ab4ab232ca74707a455346b338905b7eb3041961e46ef74a1eeb95a1e9e878665afe68db14900ae7686641bcd07760e46d784312e1aee1c
- Derivation Data: 7b22636f6e74657874223a226e696c6c696f6e222c226b65794964223a2231227d
- HKDF Output: fc7d9e63f27d06c1d69c090f86a7f15a91464f8c5de6ee14be7c3dff6f70f9f1
- Final DID: did:nil:03ecd47816bb8f475734b77aa9a3f4cc19a6075f3f603de0eebe6e11a784bb2e2d

References