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:

  1. Client generates an elliptic curve keypair using P-256 (secp256r1)
  2. Public key is 65 bytes: [0x04][32-byte X coord][32-byte Y coord]
  3. Private key is 32 bytes: random scalar value
  4. Private key NEVER leaves the device
  5. 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

Encryption at Rest

Always encrypt device keys before storage using user-derived keys (scrypt/PBKDF2)

Key Rotation

Rotate device keys periodically (every 90 days recommended) for enhanced security

Multi-Device Support

Allow users to access wallet from multiple devices for better UX

Backup and Recovery

Implement secure backup mechanisms (encrypted cloud backup or recovery codes)

Security Monitoring

Monitor and alert on suspicious activity (unusual usage patterns, device fingerprint changes)

XSS Protection

Implement CSP headers and sanitize all inputs to prevent XSS attacks

Key Features

Fully Automatic Resolution

Automatically fetches signer, extracts device info, and resolves device keys

Automatic Caching

Keypair derivation is cached to avoid redundant operations

Device Key Validation

Prevents mismatches between storage and API

Multi-Device Support

Handle multiple devices per signer seamlessly

Next Steps

Device Keys | Cilantro Smart Wallet Docs | Cilantro Smart Wallet