Device Keys
Deep dive into ECDH-based device key management for non-custodial authentication
Device Keys
For Email and Phone signers, the SDK uses an ECDH (Elliptic Curve Diffie-Hellman) key exchange mechanism with P-256 curve for secure key derivation. This is the cryptographic foundation that enables non-custodial email/phone authentication.
The Challenge We're Solving
Problem Statement: How do we let users authenticate with email/phone (which they're familiar with) while maintaining non-custodial security (where the platform never sees private keys)?
Traditional Approaches (and their problems):
- ❌ Platform stores private keys: Custodial, platform can steal funds
- ❌ User stores seed phrases: Terrible UX, users lose them
- ❌ Deterministic from password: Weak passwords = weak security
Cilantro's Solution: Device Key ECDH Encryption
- ✅ Platform never sees device private key
- ✅ Platform never sees signing private key
- ✅ User never sees complex cryptographic material
- ✅ Works across multiple devices
- ✅ Strong cryptographic security (P-256 + Ed25519)
How Device Keys Work - Deep Dive
Phase 1: Device Key Generation (Client-Side)
// Using Node.js crypto (P-256 ECDH)
const crypto = require('crypto');
const ecdh = crypto.createECDH('prime256v1'); // NIST P-256 curve
ecdh.generateKeys();
const devicePublicKey = ecdh.getPublicKey(null, 'uncompressed'); // 65 bytes
const devicePrivateKey = ecdh.getPrivateKey(); // 32 bytes
Why P-256?
- NIST standard, widely supported
- Fast ECDH operations
- 128-bit security (equivalent to 3072-bit RSA)
- Uncompressed format: 0x04 + 32 bytes X + 32 bytes Y = 65 bytes
What Happens:
- Client generates an elliptic curve keypair using P-256 (secp256r1)
- Public key is 65 bytes:
[0x04][32-byte X coord][32-byte Y coord] - Private key is 32 bytes: random scalar value
- Private key NEVER leaves the device
- Public key is base64 encoded and sent to server
Phase 2: Signer Creation (Server-Side)
On Cilantro servers:
// 1. Server receives device public key from client
const clientDevicePublicKey = Buffer.from(base64DeviceKey, 'base64');
// 2. Server generates its own ECDH keypair
const serverECDH = crypto.createECDH('prime256v1');
serverECDH.generateKeys();
// 3. Server computes shared secret using client's public key
const sharedSecret = serverECDH.computeSecret(clientDevicePublicKey);
// sharedSecret = ECDH(serverPrivate, clientPublic)
// 4. Server generates master secret (32 random bytes)
const masterSecret = crypto.randomBytes(32);
// 5. Server encrypts master secret with shared secret
const encryptedMasterSecret = encrypt(masterSecret, sharedSecret);
// 6. Server generates Ed25519 keypair from master secret
const ed25519Keypair = deriveEd25519FromMaster(masterSecret);
// 7. Server stores:
// - serverECDH.getPublicKey() (server's public key)
// - encryptedMasterSecret
// - ed25519Keypair.publicKey (for on-chain registration)
// - clientDevicePublicKey (to identify which device)
// 8. Server registers ed25519 public key on-chain as signer
await registerSignerOnChain(walletId, ed25519Keypair.publicKey);
Server Stores:
- ✅ Server's ECDH public key
- ✅ Encrypted master secret
- ✅ Client's device public key
- ✅ Ed25519 public key (registered on-chain)
- ❌ Server's ECDH private key (discarded after encryption!)
- ❌ Master secret plaintext (only encrypted version)
- ❌ Ed25519 private key (can't be derived without client's device key)
Phase 3: Transaction Signing (Client-Side)
When user wants to sign a transaction:
// 1. Client requests encrypted secret from server
const response = await getDeviceEncryptedSecret(walletId, signerId);
const {
encryptedMasterSecret, // Encrypted master secret
serverPublicKey, // Server's ECDH public key
devicePublicKey // Client's device public key (to verify)
} = response.data;
// 2. Client loads their device private key from storage
const devicePrivateKey = await storage.getDeviceKey(devicePublicKey);
// 3. Client computes same shared secret
const clientECDH = crypto.createECDH('prime256v1');
clientECDH.setPrivateKey(devicePrivateKey);
const sharedSecret = clientECDH.computeSecret(
Buffer.from(serverPublicKey, 'base64')
);
// sharedSecret = ECDH(clientPrivate, serverPublic)
// This is THE SAME as server computed: ECDH is commutative!
// 4. Client decrypts master secret
const masterSecret = decrypt(encryptedMasterSecret, sharedSecret);
// 5. Client derives Ed25519 signing key using HKDF
const signingKey = await deriveEd25519(masterSecret);
// 6. Client signs transaction with Ed25519 key
const signature = await ed25519.sign(transactionMessage, signingKey);
// 7. Master secret and signing key immediately discarded (not stored)
Security Properties:
- 🔒 Perfect Forward Secrecy: Server discards ECDH private key after encryption
- 🔒 Zero Knowledge: Server never learns client's device private key
- 🔒 Non-Repudiation: Only client with device key can sign
- 🔒 Ephemeral Keys: Signing keys derived on-demand, never persisted
- 🔒 Double Encryption: Master secret → shared secret → device key
The Complete Cryptographic Flow
┌─────────────────┐ ┌─────────────────┐
│ Client Device │ │ Cilantro Server │
└────────┬────────┘ └────────┬────────┘
│ │
│ 1. Generate P-256 ECDH keypair │
│ devicePublic, devicePrivate │
│ │
│ 2. Send devicePublic (base64) │
│ ──────────────────────────────────────────> │
│ │
│ │ 3. Generate server P-256 ECDH
│ │ serverPublic, serverPrivate
│ │
│ │ 4. Compute sharedSecret
│ │ = ECDH(serverPrivate, devicePublic)
│ │
│ │ 5. Generate masterSecret (32 bytes)
│ │
│ │ 6. Encrypt masterSecret with sharedSecret
│ │ encryptedMaster = AES(masterSecret, sharedSecret)
│ │
│ │ 7. Derive Ed25519 from masterSecret
│ │ ed25519Key = HKDF(masterSecret)
│ │
│ │ 8. Register ed25519Public on-chain
│ │
│ │ 9. Store: serverPublic, encryptedMaster
│ │ Discard: serverPrivate, masterSecret
│ │
│ 10. Return success │
│ <────────────────────────────────────────── │
│ │
│ 11. Store devicePrivate securely │
│ (localStorage/keychain) │
│ │
[Time passes - user wants to sign transaction]
│ │
│ 12. Request encrypted secret │
│ ──────────────────────────────────────────> │
│ │
│ 13. Return: encryptedMaster, serverPublic │
│ <────────────────────────────────────────── │
│ │
│ 14. Load devicePrivate from storage │
│ │
│ 15. Compute sharedSecret │
│ = ECDH(devicePrivate, serverPublic) │
│ (SAME as server computed!) │
│ │
│ 16. Decrypt masterSecret │
│ = AES_decrypt(encryptedMaster, shared) │
│ │
│ 17. Derive Ed25519 signing key │
│ = HKDF(masterSecret) │
│ │
│ 18. Sign transaction │
│ signature = Ed25519_sign(tx, ed25519) │
│ │
│ 19. Discard masterSecret & ed25519 │
│ (ephemeral, not stored) │
│ │
│ 20. Submit signed transaction │
│ ──────────────────────────────────────────> │
│ │
Key Derivation Details
From Master Secret to Ed25519 Signing Key:
import { hkdf } from '@noble/hashes/hkdf';
import { sha256 } from '@noble/hashes/sha256';
import { getPublicKey } from '@noble/ed25519';
async function deriveEd25519Keypair(masterSecret: Uint8Array): Promise<Keypair> {
// HKDF-SHA256 derives Ed25519 private key from master secret
const ed25519PrivateKey = hkdf(
sha256, // Hash function
masterSecret, // Input key material (32 bytes)
undefined, // Salt (optional)
'ed25519-seed', // Info/context string
32 // Output length (Ed25519 needs 32 bytes)
);
// Derive public key from private key
const ed25519PublicKey = await getPublicKey(ed25519PrivateKey);
// Solana format: secretKey = privateKey || publicKey (64 bytes)
const secretKey = new Uint8Array(64);
secretKey.set(ed25519PrivateKey, 0);
secretKey.set(ed25519PublicKey, 32);
return {
publicKey: ed25519PublicKey,
secretKey: secretKey
};
}
Why HKDF?
- Key Stretching: Derives cryptographically strong keys from input material
- Domain Separation: Different contexts produce different keys
- Standard: RFC 5869, widely reviewed and trusted
- Deterministic: Same master secret always produces same Ed25519 key
Storage Options - Where Device Keys Live
The SDK provides three storage adapters, each optimized for different environments:
Browser (localStorage) - Web Applications
import { createLocalStorageAdapter } from 'cilantro-sdk/helpers';
const storage = createLocalStorageAdapter();
// Under the hood:
// - Keys stored in browser's localStorage
// - Format: `deviceKey:${keyId}` → JSON.stringify(deviceKey)
// - Persists across sessions
// - Cleared if user clears browser data
// - Same-origin policy protected
What Actually Gets Stored:
{
keyId: "550e8400-e29b-41d4-a716-446655440000",
publicKey: "BGr8...base64...", // 65 bytes P-256 public key
privateKey: "encrypted_private_key_data", // Encrypted device private key
createdAt: "2025-12-15T10:30:00Z",
lastUsed: "2025-12-15T14:22:00Z",
metadata: {
deviceName: "Chrome on MacBook Pro",
userAgent: "Mozilla/5.0..."
}
}
Security Considerations:
- ⚠️ localStorage is not encrypted by default
- ⚠️ Accessible to any script on same origin (XSS risk)
- ✅ Better than no storage (requires device access)
- ✅ Can implement additional encryption layer
- 💡 Best Practice: Encrypt keys before storing in localStorage
For production applications, encrypt device keys before storing in localStorage to protect against XSS attacks.
Node.js (File System) - Server/Desktop Applications
import { createFileSystemAdapter } from 'cilantro-sdk/helpers';
const storage = createFileSystemAdapter('./device-keys');
// Under the hood:
// - Keys stored as JSON files in specified directory
// - File structure: ./device-keys/{keyId}.json
// - File permissions: 0600 (owner read/write only)
// - Works in Node.js and Electron apps
File Structure:
./device-keys/
├── 550e8400-e29b-41d4-a716-446655440000.json
├── 7a3f9c2d-8b4e-42a1-9d6c-e5f8a1b2c3d4.json
└── metadata.json
Security Features:
- Creates directory with 0700 permissions (owner only)
- Writes files with 0600 permissions (owner read/write)
- Validates file integrity on read
- Atomic writes (write to temp, then rename)
Best for:
- Desktop Electron apps
- Node.js CLI tools
- Server-side key management
- Local development
In-Memory (Testing/Temporary)
import { createMemoryAdapter } from 'cilantro-sdk/helpers';
const storage = createMemoryAdapter();
// Under the hood:
// - Keys stored in JavaScript Map object
// - Cleared when process exits
// - No persistence
// - Perfect for testing
Use Cases:
- ✅ Unit tests
- ✅ Integration tests
- ✅ Temporary sessions
- ✅ Development/debugging
- ❌ Production (keys lost on restart!)
Using Device Keys - Quick Start
Email Signer with Device Keys
import {
createEmailSignerHelper,
getEmailSignerKeypair,
createLocalStorageAdapter
} from 'cilantro-sdk/helpers';
// Step 1: Create storage adapter
const storage = createLocalStorageAdapter();
// Step 2: Create email signer (device key generated automatically)
const signer = await createEmailSignerHelper('wallet-id', {
email: 'user@example.com',
deviceKeyManager: storage
});
console.log('Email signer created:', signer.signerId);
// Step 3: Get keypair for signing (with caching)
const keypair = await getEmailSignerKeypair(
'wallet-id',
signer.signerId,
{ deviceKeyManager: storage }
);
console.log('Keypair ready for signing');
Phone Signer with Device Keys
import {
createPhoneSignerHelper,
getPhoneSignerKeypair
} from 'cilantro-sdk/helpers';
const storage = createLocalStorageAdapter();
// Create phone signer
const signer = await createPhoneSignerHelper('wallet-id', {
phone: '+1234567890',
deviceKeyManager: storage
});
// Get keypair
const keypair = await getPhoneSignerKeypair(
'wallet-id',
signer.signerId,
{ deviceKeyManager: storage }
);
Device Key Management Operations
Generate Device Key Pair
import { generateDeviceKeyPair } from 'cilantro-sdk/helpers';
const deviceKey = await generateDeviceKeyPair();
console.log('Device Key ID:', deviceKey.keyId);
console.log('Public Key:', deviceKey.publicKey); // base64 encoded
// Private key should be stored securely
Get or Create Device Key
import { getDevicePublicKey } from 'cilantro-sdk/helpers';
// Gets existing key or creates new one
const publicKey = await getDevicePublicKey(storage);
Find Device Key by Public Key
import { findDeviceKeyByPublicKey } from 'cilantro-sdk/helpers';
const deviceKey = await findDeviceKeyByPublicKey(
'base64-public-key',
storage
);
List All Device Keys
import { listAllDeviceKeys } from 'cilantro-sdk/helpers';
const keys = await listAllDeviceKeys(storage);
console.log(`Found ${keys.length} device keys`);
Clear Device Key Cache
import { clearDeviceKeyCache } from 'cilantro-sdk/helpers';
// Clears cached keypairs (forces re-derivation)
clearDeviceKeyCache();
Multi-Device Support
Allow users to access their wallet from multiple devices:
// Device 1: Primary device
const device1Storage = createLocalStorageAdapter();
const device1Key = await generateDeviceKeyPair();
await device1Storage.saveDeviceKey(device1Key);
// Create signer with device 1
const signer = await createEmailSignerHelper(walletId, {
email: '[email protected]',
deviceKeyManager: device1Storage
});
// Device 2: Add new device
const device2Storage = createLocalStorageAdapter();
const device2Key = await generateDeviceKeyPair();
await device2Storage.saveDeviceKey(device2Key);
// Add device 2 to existing signer
await addNewDeviceToSigner(walletId, signer.signerId, device2Key.publicKey);
// Now both devices can sign!
Production-Grade Storage Solutions
For production applications, consider these enhanced storage options:
Security Best Practices
Always encrypt device keys before storage using user-derived keys (scrypt/PBKDF2)
Rotate device keys periodically (every 90 days recommended) for enhanced security
Allow users to access wallet from multiple devices for better UX
Implement secure backup mechanisms (encrypted cloud backup or recovery codes)
Monitor and alert on suspicious activity (unusual usage patterns, device fingerprint changes)
Implement CSP headers and sanitize all inputs to prevent XSS attacks
Key Features
Automatically fetches signer, extracts device info, and resolves device keys
Keypair derivation is cached to avoid redundant operations
Prevents mismatches between storage and API
Handle multiple devices per signer seamlessly