feat: add Gnosis Safe + EncryptID passkey wallet abstraction

Derive a deterministic secp256k1 EOA from the passkey's PRF output via
HKDF-SHA256, enabling hardware-backed signing for x402 micropayments and
Safe treasury proposals without storing private keys.

Key changes:
- EOA key derivation with domain-separated HKDF (eoa-derivation.ts)
- Key manager integration with PRF-only EOA path (key-derivation.ts)
- Encrypted client-side wallet store for Safe associations (wallet-store.ts)
- Passkey-backed x402 signer replacing EVM_PRIVATE_KEY (passkey-signer.ts)
- Safe propose/confirm/execute proxy routes in rwallet (mod.ts)
- Wallet capability flag in JWT via users.wallet_address (server.ts)
- Payment operation permissions: x402, safe-propose, safe-execute (session.ts)

Privacy: Safe wallet associations stored client-side only (AES-256-GCM
encrypted localStorage). Server only knows user has wallet capability.

108 tests passing across 5 test suites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-02 12:18:34 -08:00
parent c8e63b5c9f
commit be271de7fb
14 changed files with 1438 additions and 6 deletions

View File

@ -9,6 +9,8 @@
"@aws-sdk/client-s3": "^3.700.0",
"@encryptid/sdk": "file:../encryptid-sdk",
"@lit/reactive-element": "^2.0.4",
"@noble/curves": "^1.8.0",
"@noble/hashes": "^1.7.0",
"@x402/core": "^2.3.1",
"@x402/evm": "^2.5.0",
"hono": "^4.11.7",

View File

@ -92,6 +92,185 @@ function getSafePrefix(chainId: string): string | null {
return CHAIN_MAP[chainId]?.prefix || null;
}
// ── Safe Proposal / Confirm / Execute (EncryptID-authenticated) ──
// Helper: extract and verify JWT from request
async function verifyWalletAuth(c: any): Promise<{ sub: string; did?: string; username?: string; eid?: any } | null> {
const authorization = c.req.header("Authorization");
if (!authorization?.startsWith("Bearer ")) return null;
try {
// Import verify dynamically to avoid adding hono/jwt as a module-level dep
const { verify } = await import("hono/jwt");
const secret = process.env.JWT_SECRET;
if (!secret) return null;
const payload = await verify(authorization.slice(7), secret, "HS256");
return payload as any;
} catch {
return null;
}
}
// POST /api/safe/:chainId/:address/propose — Create a Safe transaction proposal
routes.post("/api/safe/:chainId/:address/propose", async (c) => {
const claims = await verifyWalletAuth(c);
if (!claims) return c.json({ error: "Authentication required" }, 401);
// Require ELEVATED auth level (3+)
if (!claims.eid || claims.eid.authLevel < 3) {
return c.json({ error: "Elevated authentication required for proposals" }, 403);
}
if (!claims.eid.capabilities?.wallet) {
return c.json({ error: "Wallet capability required" }, 403);
}
const chainId = c.req.param("chainId");
const address = c.req.param("address");
const chainPrefix = getSafePrefix(chainId);
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
const body = await c.req.json();
const { to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce, signature, sender } = body;
if (!to || !signature || !sender) {
return c.json({ error: "Missing required fields: to, signature, sender" }, 400);
}
// Submit proposal to Safe Transaction Service
const res = await fetch(
`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/multisig-transactions/`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to,
value: value || "0",
data: data || "0x",
operation: operation || 0,
safeTxGas: safeTxGas || "0",
baseGas: baseGas || "0",
gasPrice: gasPrice || "0",
gasToken: gasToken || "0x0000000000000000000000000000000000000000",
refundReceiver: refundReceiver || "0x0000000000000000000000000000000000000000",
nonce,
signature,
sender,
}),
},
);
if (!res.ok) {
const err = await res.text();
return c.json({ error: "Safe Transaction Service error", details: err }, res.status as any);
}
return c.json(await res.json(), 201);
});
// POST /api/safe/:chainId/:address/confirm — Confirm a pending transaction
routes.post("/api/safe/:chainId/:address/confirm", async (c) => {
const claims = await verifyWalletAuth(c);
if (!claims) return c.json({ error: "Authentication required" }, 401);
if (!claims.eid || claims.eid.authLevel < 3) {
return c.json({ error: "Elevated authentication required" }, 403);
}
if (!claims.eid.capabilities?.wallet) {
return c.json({ error: "Wallet capability required" }, 403);
}
const chainId = c.req.param("chainId");
const chainPrefix = getSafePrefix(chainId);
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
const { safeTxHash, signature } = await c.req.json();
if (!safeTxHash || !signature) {
return c.json({ error: "Missing required fields: safeTxHash, signature" }, 400);
}
const res = await fetch(
`https://safe-transaction-${chainPrefix}.safe.global/api/v1/multisig-transactions/${safeTxHash}/confirmations/`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ signature }),
},
);
if (!res.ok) {
const err = await res.text();
return c.json({ error: "Confirmation failed", details: err }, res.status as any);
}
return c.json(await res.json());
});
// POST /api/safe/:chainId/:address/execute — Execute a ready transaction (CRITICAL auth)
routes.post("/api/safe/:chainId/:address/execute", async (c) => {
const claims = await verifyWalletAuth(c);
if (!claims) return c.json({ error: "Authentication required" }, 401);
// Require CRITICAL auth level (4) for execution
if (!claims.eid || claims.eid.authLevel < 4) {
return c.json({ error: "Critical authentication required for execution" }, 403);
}
if (!claims.eid.capabilities?.wallet) {
return c.json({ error: "Wallet capability required" }, 403);
}
// Check auth freshness (must be within 60 seconds)
const now = Math.floor(Date.now() / 1000);
if (claims.eid.authTime && (now - claims.eid.authTime) > 60) {
return c.json({ error: "Authentication too old for execution — re-authenticate" }, 403);
}
const chainId = c.req.param("chainId");
const address = c.req.param("address");
const chainPrefix = getSafePrefix(chainId);
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
// Get pending transactions and find the one to execute
const { safeTxHash } = await c.req.json();
if (!safeTxHash) {
return c.json({ error: "Missing required field: safeTxHash" }, 400);
}
// Fetch the transaction details from Safe Transaction Service
const txRes = await fetch(
`https://safe-transaction-${chainPrefix}.safe.global/api/v1/multisig-transactions/${safeTxHash}/`,
);
if (!txRes.ok) {
return c.json({ error: "Transaction not found" }, 404);
}
const txData = await txRes.json() as { confirmations?: any[]; confirmationsRequired?: number; isExecuted?: boolean };
if (txData.isExecuted) {
return c.json({ error: "Transaction already executed" }, 400);
}
const confirmationsNeeded = txData.confirmationsRequired || 1;
const confirmationsReceived = txData.confirmations?.length || 0;
if (confirmationsReceived < confirmationsNeeded) {
return c.json({
error: "Not enough confirmations",
required: confirmationsNeeded,
received: confirmationsReceived,
}, 400);
}
// Transaction is ready — return execution data for client-side submission
// (Actual on-chain execution must happen client-side with the user's signer)
return c.json({
ready: true,
safeTxHash,
confirmations: confirmationsReceived,
required: confirmationsNeeded,
transaction: txData,
});
});
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";

View File

@ -25,6 +25,8 @@
"@aws-sdk/client-s3": "^3.700.0",
"imapflow": "^1.0.170",
"mailparser": "^3.7.2",
"@noble/curves": "^1.8.0",
"@noble/hashes": "^1.7.0",
"@x402/core": "^2.3.1",
"@x402/evm": "^2.5.0"
},

View File

@ -0,0 +1,111 @@
/**
* test-eoa-derivation.ts Verify EOA key derivation from PRF output.
*
* Tests:
* 1. Determinism: same PRF input same EOA address every time
* 2. Domain separation: different from what encryption/signing keys would produce
* 3. Valid Ethereum address format (EIP-55 checksummed)
* 4. Different PRF inputs different addresses
* 5. Private key zeroing works
*
* Usage:
* bun run scripts/test-eoa-derivation.ts
*/
import { deriveEOAFromPRF, zeroPrivateKey } from '../src/encryptid/eoa-derivation';
let passed = 0;
let failed = 0;
function assert(condition: boolean, msg: string) {
if (condition) {
console.log(`${msg}`);
passed++;
} else {
console.error(`${msg}`);
failed++;
}
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function main() {
console.log('=== EOA Key Derivation Tests ===\n');
// Fixed PRF output (simulates 32 bytes from WebAuthn PRF extension)
const prfOutput = new Uint8Array(32);
for (let i = 0; i < 32; i++) prfOutput[i] = i + 1;
// Test 1: Basic derivation works
console.log('[1] Basic derivation');
const result1 = deriveEOAFromPRF(prfOutput);
assert(result1.privateKey.length === 32, 'Private key is 32 bytes');
assert(result1.publicKey.length === 65, 'Public key is 65 bytes (uncompressed)');
assert(result1.publicKey[0] === 0x04, 'Public key starts with 0x04');
assert(result1.address.startsWith('0x'), 'Address starts with 0x');
assert(result1.address.length === 42, 'Address is 42 chars (0x + 40 hex)');
console.log(` Address: ${result1.address}`);
console.log(` PubKey: 0x${toHex(result1.publicKey).slice(0, 20)}...`);
// Test 2: Determinism — same input always gives same output
console.log('\n[2] Determinism');
const result2 = deriveEOAFromPRF(prfOutput);
assert(result2.address === result1.address, 'Same PRF → same address');
assert(toHex(result2.privateKey) === toHex(result1.privateKey), 'Same PRF → same private key');
// Run it 5 more times to be sure
for (let i = 0; i < 5; i++) {
const r = deriveEOAFromPRF(prfOutput);
assert(r.address === result1.address, `Deterministic on iteration ${i + 3}`);
}
// Test 3: Different input → different address
console.log('\n[3] Different inputs produce different addresses');
const prfOutput2 = new Uint8Array(32);
for (let i = 0; i < 32; i++) prfOutput2[i] = 32 - i;
const result3 = deriveEOAFromPRF(prfOutput2);
assert(result3.address !== result1.address, 'Different PRF → different address');
console.log(` Address 1: ${result1.address}`);
console.log(` Address 2: ${result3.address}`);
// Test 4: EIP-55 checksum validity
console.log('\n[4] EIP-55 checksum format');
const hasUppercase = /[A-F]/.test(result1.address.slice(2));
const hasLowercase = /[a-f]/.test(result1.address.slice(2));
const isAllHex = /^0x[0-9a-fA-F]{40}$/.test(result1.address);
assert(isAllHex, 'Address is valid hex');
assert(hasUppercase || hasLowercase, 'Address has mixed case (EIP-55 checksum)');
// Test 5: Private key zeroing
console.log('\n[5] Private key zeroing');
const result4 = deriveEOAFromPRF(prfOutput);
const keyBefore = toHex(result4.privateKey);
assert(keyBefore !== '00'.repeat(32), 'Key is non-zero before wipe');
zeroPrivateKey(result4.privateKey);
const keyAfter = toHex(result4.privateKey);
assert(keyAfter === '00'.repeat(32), 'Key is all zeros after wipe');
// Test 6: Domain separation — verify the address is different from what
// you'd get with different HKDF params (we can't test WebCrypto HKDF here
// but we can verify our specific salt/info combo produces a unique result)
console.log('\n[6] Domain separation');
// Manually derive with same input but different params using @noble/hashes
const { hkdf } = await import('@noble/hashes/hkdf');
const { sha256 } = await import('@noble/hashes/sha256');
const encoder = new TextEncoder();
const encryptionKeyBytes = hkdf(sha256, prfOutput, encoder.encode('encryptid-encryption-key-v1'), encoder.encode('AES-256-GCM'), 32);
const signingKeyBytes = hkdf(sha256, prfOutput, encoder.encode('encryptid-signing-key-v1'), encoder.encode('ECDSA-P256-seed'), 32);
const eoaKeyBytes = hkdf(sha256, prfOutput, encoder.encode('encryptid-eoa-key-v1'), encoder.encode('secp256k1-signing-key'), 32);
assert(toHex(encryptionKeyBytes) !== toHex(eoaKeyBytes), 'EOA key ≠ encryption key material');
assert(toHex(signingKeyBytes) !== toHex(eoaKeyBytes), 'EOA key ≠ signing key material');
assert(toHex(result1.privateKey) === toHex(eoaKeyBytes), 'EOA key matches expected HKDF output');
// Summary
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
process.exit(failed > 0 ? 1 : 0);
}
main();

View File

@ -0,0 +1,117 @@
/**
* test-key-manager-eoa.ts Test EOA integration in EncryptIDKeyManager.
*
* Verifies that the key manager properly derives EOA keys from PRF output
* and includes them in the DerivedKeys interface.
*
* Usage:
* bun run scripts/test-key-manager-eoa.ts
*/
import { EncryptIDKeyManager } from '../src/encryptid/key-derivation';
let passed = 0;
let failed = 0;
function assert(condition: boolean, msg: string) {
if (condition) {
console.log(`${msg}`);
passed++;
} else {
console.error(`${msg}`);
failed++;
}
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function main() {
console.log('=== Key Manager EOA Integration Tests ===\n');
// Simulate PRF output (32 bytes)
const prfOutput = new ArrayBuffer(32);
const view = new Uint8Array(prfOutput);
for (let i = 0; i < 32; i++) view[i] = i + 1;
// Test 1: Initialize from PRF and derive keys
console.log('[1] Derive keys from PRF (includes EOA)');
const km = new EncryptIDKeyManager();
assert(!km.isInitialized(), 'Not initialized before init');
await km.initFromPRF(prfOutput);
assert(km.isInitialized(), 'Initialized after initFromPRF');
const keys = await km.getKeys();
assert(keys.fromPRF === true, 'Keys marked as from PRF');
assert(keys.encryptionKey !== undefined, 'Has encryption key');
assert(keys.signingKeyPair !== undefined, 'Has signing key pair');
assert(keys.didSeed !== undefined, 'Has DID seed');
assert(keys.did !== undefined, 'Has DID');
// EOA fields should be present (PRF path)
assert(keys.eoaPrivateKey !== undefined, 'Has EOA private key');
assert(keys.eoaAddress !== undefined, 'Has EOA address');
assert(keys.eoaPrivateKey!.length === 32, 'EOA private key is 32 bytes');
assert(keys.eoaAddress!.startsWith('0x'), 'EOA address starts with 0x');
assert(keys.eoaAddress!.length === 42, 'EOA address is 42 chars');
console.log(` EOA Address: ${keys.eoaAddress}`);
// Test 2: Keys are cached (same object returned)
console.log('\n[2] Key caching');
const keys2 = await km.getKeys();
assert(keys2.eoaAddress === keys.eoaAddress, 'Cached keys return same EOA address');
// Test 3: Determinism — new manager with same PRF gives same EOA
console.log('\n[3] Determinism across key manager instances');
const km2 = new EncryptIDKeyManager();
await km2.initFromPRF(prfOutput);
const keys3 = await km2.getKeys();
assert(keys3.eoaAddress === keys.eoaAddress, 'Same PRF → same EOA across instances');
assert(toHex(keys3.eoaPrivateKey!) === toHex(keys.eoaPrivateKey!), 'Same private key too');
// Test 4: Clear wipes EOA key
console.log('\n[4] Clear wipes sensitive material');
const eoaKeyRef = keys.eoaPrivateKey!;
const eoaBefore = toHex(eoaKeyRef);
assert(eoaBefore !== '00'.repeat(32), 'EOA key non-zero before clear');
km.clear();
assert(!km.isInitialized(), 'Not initialized after clear');
// The referenced Uint8Array should be zeroed
const eoaAfter = toHex(eoaKeyRef);
assert(eoaAfter === '00'.repeat(32), 'EOA private key zeroed after clear');
// Test 5: Passphrase path — no EOA keys
console.log('\n[5] Passphrase path (no EOA)');
const km3 = new EncryptIDKeyManager();
const salt = new Uint8Array(32);
crypto.getRandomValues(salt);
await km3.initFromPassphrase('test-passphrase-123', salt);
const passKeys = await km3.getKeys();
assert(passKeys.fromPRF === false, 'Keys marked as from passphrase');
assert(passKeys.eoaPrivateKey === undefined, 'No EOA private key from passphrase');
assert(passKeys.eoaAddress === undefined, 'No EOA address from passphrase');
assert(passKeys.encryptionKey !== undefined, 'Still has encryption key');
km3.clear();
// Test 6: Domain separation — EOA address differs from DID-derived values
console.log('\n[6] Domain separation');
const km4 = new EncryptIDKeyManager();
await km4.initFromPRF(prfOutput);
const keys4 = await km4.getKeys();
// EOA address should not appear in the DID
assert(!keys4.did.includes(keys4.eoaAddress!.slice(2)), 'EOA address not in DID');
km4.clear();
// Cleanup km2
km2.clear();
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch(err => {
console.error('Fatal:', err);
process.exit(1);
});

View File

@ -0,0 +1,93 @@
/**
* test-passkey-signer.ts Test the passkey x402 signer module.
*
* Verifies that createPasskeySigner() and createPasskeySignerFromKeys()
* work correctly with simulated PRF output. Does NOT make actual payments
* (that requires a funded wallet + live facilitator).
*
* Usage:
* bun run scripts/test-passkey-signer.ts
*/
import { createPasskeySigner, createPasskeySignerFromKeys } from '../shared/x402/passkey-signer';
import { deriveEOAFromPRF } from '../src/encryptid/eoa-derivation';
import { EncryptIDKeyManager, type DerivedKeys } from '../src/encryptid/key-derivation';
let passed = 0;
let failed = 0;
function assert(condition: boolean, msg: string) {
if (condition) {
console.log(`${msg}`);
passed++;
} else {
console.error(`${msg}`);
failed++;
}
}
async function main() {
console.log('=== Passkey Signer Tests ===\n');
// Fixed PRF output
const prfOutput = new Uint8Array(32);
for (let i = 0; i < 32; i++) prfOutput[i] = i + 1;
// Expected EOA from this PRF
const expectedEOA = deriveEOAFromPRF(prfOutput);
// Test 1: createPasskeySigner from raw PRF
console.log('[1] createPasskeySigner (from raw PRF)');
const signer = await createPasskeySigner({ prfOutput });
assert(signer.eoaAddress === expectedEOA.address, 'EOA address matches derivation');
assert(typeof signer.paidFetch === 'function', 'paidFetch is a function');
assert(typeof signer.cleanup === 'function', 'cleanup is a function');
console.log(` EOA: ${signer.eoaAddress}`);
// Test 2: Cleanup zeros the key
console.log('\n[2] Cleanup');
signer.cleanup();
// Can't directly check the internal state, but we verify it doesn't throw
assert(true, 'Cleanup ran without error');
// Test 3: createPasskeySignerFromKeys (from DerivedKeys)
console.log('\n[3] createPasskeySignerFromKeys');
const km = new EncryptIDKeyManager();
await km.initFromPRF(prfOutput.buffer);
const keys = await km.getKeys();
const signer2 = await createPasskeySignerFromKeys(keys);
assert(signer2 !== null, 'Signer created from DerivedKeys');
assert(signer2!.eoaAddress === expectedEOA.address, 'Same EOA address from keys');
assert(typeof signer2!.paidFetch === 'function', 'paidFetch is a function');
signer2!.cleanup();
// Test 4: createPasskeySignerFromKeys returns null for passphrase keys
console.log('\n[4] Returns null for passphrase-derived keys (no EOA)');
const km2 = new EncryptIDKeyManager();
await km2.initFromPassphrase('test-pass', new Uint8Array(32));
const passKeys = await km2.getKeys();
const signer3 = await createPasskeySignerFromKeys(passKeys);
assert(signer3 === null, 'Returns null when no EOA keys');
km2.clear();
// Test 5: Custom network
console.log('\n[5] Custom network');
const signer4 = await createPasskeySigner({
prfOutput,
network: 'eip155:8453', // Base mainnet
});
assert(signer4.eoaAddress === expectedEOA.address, 'Same EOA regardless of network');
signer4.cleanup();
// Cleanup
km.clear();
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch(err => {
console.error('Fatal:', err);
process.exit(1);
});

View File

@ -0,0 +1,70 @@
/**
* test-session-permissions.ts Verify payment operation permissions in session.ts.
*
* Tests that the new payment:x402, payment:safe-propose, and payment:safe-execute
* operations are properly defined in OPERATION_PERMISSIONS.
*
* Usage:
* bun run scripts/test-session-permissions.ts
*/
import { OPERATION_PERMISSIONS, AuthLevel } from '../src/encryptid/session';
let passed = 0;
let failed = 0;
function assert(condition: boolean, msg: string) {
if (condition) {
console.log(`${msg}`);
passed++;
} else {
console.error(`${msg}`);
failed++;
}
}
function main() {
console.log('=== Session Permission Tests ===\n');
// Test 1: payment:x402 exists with correct settings
console.log('[1] payment:x402');
const x402 = OPERATION_PERMISSIONS['payment:x402'];
assert(x402 !== undefined, 'payment:x402 is defined');
assert(x402.minAuthLevel === AuthLevel.STANDARD, 'Requires STANDARD auth');
assert(x402.requiresCapability === 'wallet', 'Requires wallet capability');
assert(x402.maxAgeSeconds === undefined, 'No max age (not time-sensitive)');
// Test 2: payment:safe-propose exists with correct settings
console.log('\n[2] payment:safe-propose');
const propose = OPERATION_PERMISSIONS['payment:safe-propose'];
assert(propose !== undefined, 'payment:safe-propose is defined');
assert(propose.minAuthLevel === AuthLevel.ELEVATED, 'Requires ELEVATED auth');
assert(propose.requiresCapability === 'wallet', 'Requires wallet capability');
assert(propose.maxAgeSeconds === 60, 'Max age is 60 seconds');
// Test 3: payment:safe-execute exists with correct settings
console.log('\n[3] payment:safe-execute');
const execute = OPERATION_PERMISSIONS['payment:safe-execute'];
assert(execute !== undefined, 'payment:safe-execute is defined');
assert(execute.minAuthLevel === AuthLevel.CRITICAL, 'Requires CRITICAL auth');
assert(execute.requiresCapability === 'wallet', 'Requires wallet capability');
assert(execute.maxAgeSeconds === 60, 'Max age is 60 seconds');
// Test 4: Existing operations still intact
console.log('\n[4] Existing operations unchanged');
assert(OPERATION_PERMISSIONS['rspace:view-public'] !== undefined, 'rspace:view-public still exists');
assert(OPERATION_PERMISSIONS['rwallet:send-small'] !== undefined, 'rwallet:send-small still exists');
assert(OPERATION_PERMISSIONS['account:delete'] !== undefined, 'account:delete still exists');
assert(OPERATION_PERMISSIONS['rspace:view-public'].minAuthLevel === AuthLevel.BASIC, 'rspace:view-public still BASIC');
assert(OPERATION_PERMISSIONS['account:delete'].minAuthLevel === AuthLevel.CRITICAL, 'account:delete still CRITICAL');
// Test 5: Auth level ordering
console.log('\n[5] Auth level escalation (x402 < propose < execute)');
assert(x402.minAuthLevel < propose.minAuthLevel, 'x402 < propose');
assert(propose.minAuthLevel < execute.minAuthLevel, 'propose < execute');
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
process.exit(failed > 0 ? 1 : 0);
}
main();

View File

@ -0,0 +1,181 @@
/**
* test-wallet-store.ts Test encrypted client-side wallet store.
*
* Mocks localStorage since we're running in Bun (not a browser).
*
* Usage:
* bun run scripts/test-wallet-store.ts
*/
// Mock localStorage before importing anything
const storage = new Map<string, string>();
(globalThis as any).localStorage = {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => storage.set(k, v),
removeItem: (k: string) => storage.delete(k),
clear: () => storage.clear(),
};
import { WalletStore } from '../src/encryptid/wallet-store';
import { EncryptIDKeyManager } from '../src/encryptid/key-derivation';
let passed = 0;
let failed = 0;
function assert(condition: boolean, msg: string) {
if (condition) {
console.log(`${msg}`);
passed++;
} else {
console.error(`${msg}`);
failed++;
}
}
async function main() {
console.log('=== Wallet Store Tests ===\n');
// Set up key manager with a test PRF
const prfOutput = new ArrayBuffer(32);
new Uint8Array(prfOutput).set(Array.from({ length: 32 }, (_, i) => i + 1));
const km = new EncryptIDKeyManager();
await km.initFromPRF(prfOutput);
const keys = await km.getKeys();
const store = new WalletStore(keys.encryptionKey);
// Test 1: Empty store
console.log('[1] Empty store');
const empty = await store.list();
assert(empty.length === 0, 'No wallets initially');
const noDefault = await store.getDefault();
assert(noDefault === null, 'No default wallet');
// Test 2: Add first wallet (auto-default)
console.log('\n[2] Add first wallet');
const w1 = await store.add({
safeAddress: '0x1111111111111111111111111111111111111111',
chainId: 84532,
eoaAddress: keys.eoaAddress!,
label: 'Test Treasury',
});
assert(w1.id.length > 0, 'Has UUID');
assert(w1.safeAddress === '0x1111111111111111111111111111111111111111', 'Address correct');
assert(w1.chainId === 84532, 'Chain correct');
assert(w1.isDefault === true, 'First wallet is auto-default');
assert(w1.label === 'Test Treasury', 'Label correct');
assert(w1.addedAt > 0, 'Has timestamp');
// Test 3: Data is encrypted in localStorage
console.log('\n[3] Encrypted at rest');
const raw = storage.get('encryptid_wallets');
assert(raw !== undefined, 'Data exists in localStorage');
const blob = JSON.parse(raw!);
assert(typeof blob.c === 'string', 'Has ciphertext field');
assert(typeof blob.iv === 'string', 'Has IV field');
assert(!raw!.includes('Treasury'), 'Label NOT in plaintext');
assert(!raw!.includes('1111111'), 'Address NOT in plaintext');
// Test 4: Add second wallet
console.log('\n[4] Add second wallet');
const w2 = await store.add({
safeAddress: '0x2222222222222222222222222222222222222222',
chainId: 8453,
eoaAddress: keys.eoaAddress!,
label: 'Mainnet Safe',
});
assert(w2.isDefault === false, 'Second wallet is not default');
const all = await store.list();
assert(all.length === 2, 'Now have 2 wallets');
// Test 5: Get default
console.log('\n[5] Get default');
const def = await store.getDefault();
assert(def !== null, 'Has default');
assert(def!.id === w1.id, 'First wallet is still default');
// Test 6: Get by address + chain
console.log('\n[6] Get by address + chain');
const found = await store.get('0x2222222222222222222222222222222222222222', 8453);
assert(found !== null, 'Found by address+chain');
assert(found!.label === 'Mainnet Safe', 'Correct wallet');
const notFound = await store.get('0x2222222222222222222222222222222222222222', 1);
assert(notFound === null, 'Not found on wrong chain');
// Test 7: Update label and default
console.log('\n[7] Update');
const updated = await store.update(w2.id, { label: 'Base Mainnet', isDefault: true });
assert(updated !== null, 'Update succeeded');
assert(updated!.label === 'Base Mainnet', 'Label updated');
assert(updated!.isDefault === true, 'Now default');
const newDefault = await store.getDefault();
assert(newDefault!.id === w2.id, 'Default switched');
// Old default should be false now
const allAfter = await store.list();
const oldW1 = allAfter.find(w => w.id === w1.id);
assert(oldW1!.isDefault === false, 'Old default cleared');
// Test 8: Duplicate add = upsert
console.log('\n[8] Duplicate add (upsert)');
const w1Updated = await store.add({
safeAddress: '0x1111111111111111111111111111111111111111',
chainId: 84532,
eoaAddress: keys.eoaAddress!,
label: 'Renamed Treasury',
});
assert(w1Updated.label === 'Renamed Treasury', 'Label updated via upsert');
const afterUpsert = await store.list();
assert(afterUpsert.length === 2, 'Still 2 wallets (not 3)');
// Test 9: Persistence — new store instance reads encrypted data
console.log('\n[9] Persistence across instances');
const store2 = new WalletStore(keys.encryptionKey);
const restored = await store2.list();
assert(restored.length === 2, 'Restored 2 wallets');
assert(restored.some(w => w.label === 'Renamed Treasury'), 'Labels preserved');
assert(restored.some(w => w.label === 'Base Mainnet'), 'Both wallets present');
// Test 10: Wrong key can't decrypt
console.log('\n[10] Wrong key fails gracefully');
const km2 = new EncryptIDKeyManager();
const otherPrf = new ArrayBuffer(32);
new Uint8Array(otherPrf).set(Array.from({ length: 32 }, (_, i) => 255 - i));
await km2.initFromPRF(otherPrf);
const otherKeys = await km2.getKeys();
const storeWrongKey = new WalletStore(otherKeys.encryptionKey);
const wrongResult = await storeWrongKey.list();
assert(wrongResult.length === 0, 'Wrong key returns empty (graceful failure)');
km2.clear();
// Test 11: Remove wallet
console.log('\n[11] Remove');
const removed = await store.remove(w2.id);
assert(removed === true, 'Remove succeeded');
const afterRemove = await store.list();
assert(afterRemove.length === 1, 'Down to 1 wallet');
// Removed wallet was default, so remaining should be promoted
assert(afterRemove[0].isDefault === true, 'Remaining wallet promoted to default');
// Test 12: Remove non-existent
const removedAgain = await store.remove('nonexistent-id');
assert(removedAgain === false, 'Remove non-existent returns false');
// Test 13: Clear
console.log('\n[13] Clear');
await store.clear();
const afterClear = await store.list();
assert(afterClear.length === 0, 'Empty after clear');
assert(!storage.has('encryptid_wallets'), 'localStorage key removed');
// Cleanup
km.clear();
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch(err => {
console.error('Fatal:', err);
process.exit(1);
});

View File

@ -0,0 +1,142 @@
/**
* Passkey-backed x402 Signer
*
* Client-side module that uses the EncryptID passkey (WebAuthn PRF) to
* derive an EOA and sign x402 micropayments. Replaces the EVM_PRIVATE_KEY
* env var approach with hardware-backed passkey signing.
*
* Flow:
* 1. Authenticate with passkey (gets PRF output)
* 2. Derive EOA via deriveEOAFromPRF()
* 3. Create x402 client with EOA as signer
* 4. Return wrapped fetch that auto-handles 402 responses
* 5. Zero the private key bytes after signing
*/
import { deriveEOAFromPRF, zeroPrivateKey } from '../../src/encryptid/eoa-derivation';
import type { DerivedKeys } from '../../src/encryptid/key-derivation';
/**
* Options for creating a passkey-backed x402 fetch wrapper.
*/
export interface PasskeySignerOptions {
/** Network identifier (default: 'eip155:84532' for Base Sepolia) */
network?: string;
/** PRF output from passkey authentication */
prfOutput: Uint8Array;
}
/**
* Result from creating a passkey signer, including cleanup.
*/
export interface PasskeySignerResult {
/** The fetch function with x402 payment handling */
paidFetch: typeof fetch;
/** The derived EOA address */
eoaAddress: string;
/** Call this to zero out the private key from memory */
cleanup: () => void;
}
/**
* Create a passkey-backed x402 payment signer.
*
* Derives an EOA from the passkey PRF output and creates a fetch wrapper
* that automatically handles 402 Payment Required responses by signing
* USDC transfers on the specified network.
*
* @example
* ```ts
* const { paidFetch, eoaAddress, cleanup } = await createPasskeySigner({
* prfOutput: prfOutputBytes,
* network: 'eip155:84532',
* });
*
* try {
* const res = await paidFetch('https://example.com/paid-endpoint', {
* method: 'POST',
* body: JSON.stringify({ data: 'test' }),
* });
* console.log('Payment + response:', await res.json());
* } finally {
* cleanup(); // Zero private key
* }
* ```
*/
export async function createPasskeySigner(
options: PasskeySignerOptions,
): Promise<PasskeySignerResult> {
const network = options.network || 'eip155:84532';
// Derive EOA from PRF output
const eoa = deriveEOAFromPRF(options.prfOutput);
// Dynamic imports for x402 client libraries (only loaded when needed)
const [{ x402Client, wrapFetchWithPayment }, { ExactEvmScheme }, { privateKeyToAccount }] =
await Promise.all([
import('@x402/fetch'),
import('@x402/evm/exact/client'),
import('viem/accounts'),
]);
// Convert raw private key bytes to viem account
const hexKey = ('0x' + Array.from(eoa.privateKey).map(b => b.toString(16).padStart(2, '0')).join('')) as `0x${string}`;
const account = privateKeyToAccount(hexKey);
// Set up x402 client with the derived EOA
// Type cast needed: viem's LocalAccount doesn't include readContract,
// but ExactEvmScheme only uses signing methods at runtime
const client = new x402Client();
client.register(network as `${string}:${string}`, new ExactEvmScheme(account as any));
const paidFetch = wrapFetchWithPayment(fetch as any, client) as unknown as typeof fetch;
return {
paidFetch,
eoaAddress: eoa.address,
cleanup: () => {
zeroPrivateKey(eoa.privateKey);
},
};
}
/**
* Create a passkey signer directly from EncryptID DerivedKeys.
*
* Convenience wrapper when keys are already available from the key manager.
*/
export async function createPasskeySignerFromKeys(
keys: DerivedKeys,
network?: string,
): Promise<PasskeySignerResult | null> {
if (!keys.eoaPrivateKey || !keys.eoaAddress) {
return null;
}
const net = network || 'eip155:84532';
const [{ x402Client, wrapFetchWithPayment }, { ExactEvmScheme }, { privateKeyToAccount }] =
await Promise.all([
import('@x402/fetch'),
import('@x402/evm/exact/client'),
import('viem/accounts'),
]);
const hexKey = ('0x' + Array.from(keys.eoaPrivateKey).map(b => b.toString(16).padStart(2, '0')).join('')) as `0x${string}`;
const account = privateKeyToAccount(hexKey);
const client = new x402Client();
client.register(net as `${string}:${string}`, new ExactEvmScheme(account as any));
const paidFetch = wrapFetchWithPayment(fetch as any, client) as unknown as typeof fetch;
return {
paidFetch,
eoaAddress: keys.eoaAddress,
cleanup: () => {
if (keys.eoaPrivateKey) {
zeroPrivateKey(keys.eoaPrivateKey);
}
},
};
}

View File

@ -0,0 +1,109 @@
/**
* EOA Key Derivation from WebAuthn PRF Output
*
* Derives a deterministic secp256k1 EOA keypair from the passkey's PRF output
* using HKDF-SHA256. The derived EOA becomes an owner on the user's Gnosis Safe,
* bridging passkey-based identity to EVM wallet operations.
*
* The key is:
* - Deterministic: same passkey always produces the same EOA address
* - Never stored: derived on-demand in the browser from PRF output
* - Domain-separated: different HKDF salt/info from encryption/signing/DID keys
*/
import { secp256k1 } from '@noble/curves/secp256k1';
import { hkdf } from '@noble/hashes/hkdf';
import { sha256 } from '@noble/hashes/sha256';
import { keccak_256 } from '@noble/hashes/sha3';
/**
* Derive a secp256k1 EOA private key from PRF output using HKDF-SHA256.
*
* Uses domain-separated salt/info to ensure this key is independent from
* the encryption, signing, and DID keys derived from the same PRF output.
*
* @param prfOutput - Raw PRF output from WebAuthn (typically 32 bytes)
* @returns Object with privateKey (Uint8Array), publicKey (Uint8Array), and address (checksummed hex)
*/
export function deriveEOAFromPRF(prfOutput: Uint8Array): {
privateKey: Uint8Array;
publicKey: Uint8Array;
address: string;
} {
const encoder = new TextEncoder();
// HKDF-SHA256 with domain-specific salt and info
// These are deliberately different from the existing derivation salts:
// encryption: 'encryptid-encryption-key-v1' / 'AES-256-GCM'
// signing: 'encryptid-signing-key-v1' / 'ECDSA-P256-seed'
// DID: 'encryptid-did-key-v1' / 'Ed25519-seed'
const privateKey = hkdf(
sha256,
prfOutput,
encoder.encode('encryptid-eoa-key-v1'), // salt
encoder.encode('secp256k1-signing-key'), // info
32 // 32 bytes for secp256k1
);
// Validate the private key is in the valid range for secp256k1
// (between 1 and curve order - 1). HKDF output is uniformly random
// so this is astronomically unlikely to fail, but we check anyway.
if (!isValidPrivateKey(privateKey)) {
throw new Error('Derived EOA private key is outside valid secp256k1 range');
}
// Derive public key (uncompressed, 65 bytes: 04 || x || y)
const publicKeyUncompressed = secp256k1.getPublicKey(privateKey, false);
// Ethereum address = last 20 bytes of keccak256(publicKey[1:])
// Skip the 0x04 prefix byte
const publicKeyBytes = publicKeyUncompressed.slice(1);
const hash = keccak_256(publicKeyBytes);
const addressBytes = hash.slice(12); // last 20 bytes
const address = toChecksumAddress(addressBytes);
return { privateKey, publicKey: publicKeyUncompressed, address };
}
/**
* Check if a 32-byte value is a valid secp256k1 private key.
*/
function isValidPrivateKey(key: Uint8Array): boolean {
try {
secp256k1.getPublicKey(key);
return true;
} catch {
return false;
}
}
/**
* Convert raw address bytes to EIP-55 checksummed hex address.
*/
function toChecksumAddress(addressBytes: Uint8Array): string {
const hex = Array.from(addressBytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const hashHex = Array.from(keccak_256(new TextEncoder().encode(hex)))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
let checksummed = '0x';
for (let i = 0; i < 40; i++) {
checksummed += parseInt(hashHex[i], 16) >= 8
? hex[i].toUpperCase()
: hex[i];
}
return checksummed;
}
/**
* Zero out a private key buffer.
* Call this after signing to minimize exposure window.
*/
export function zeroPrivateKey(key: Uint8Array): void {
key.fill(0);
}

View File

@ -6,6 +6,7 @@
*/
import { bufferToBase64url, base64urlToBuffer } from './webauthn';
import { deriveEOAFromPRF } from './eoa-derivation';
// ============================================================================
// TYPES
@ -26,6 +27,12 @@ export interface DerivedKeys {
/** Whether keys were derived from PRF (true) or passphrase (false) */
fromPRF: boolean;
/** secp256k1 private key for EOA wallet operations (derived from PRF only) */
eoaPrivateKey?: Uint8Array;
/** Ethereum address derived from the EOA private key */
eoaAddress?: string;
}
export interface EncryptedData {
@ -54,6 +61,7 @@ export class EncryptIDKeyManager {
private masterKey: CryptoKey | null = null;
private derivedKeys: DerivedKeys | null = null;
private fromPRF: boolean = false;
private prfOutputRaw: Uint8Array | null = null;
/**
* Initialize from WebAuthn PRF output
@ -62,6 +70,10 @@ export class EncryptIDKeyManager {
* the hardware-backed PRF extension output.
*/
async initFromPRF(prfOutput: ArrayBuffer): Promise<void> {
// Copy raw PRF output for EOA derivation (uses @noble/hashes directly)
// Must copy — not just wrap — so clear() doesn't zero the caller's buffer
this.prfOutputRaw = new Uint8Array(new Uint8Array(prfOutput));
// Import PRF output as HKDF key material
this.masterKey = await crypto.subtle.importKey(
'raw',
@ -163,12 +175,16 @@ export class EncryptIDKeyManager {
// Generate DID from seed
const did = await this.generateDID(didSeed);
// Derive EOA key (PRF-only — passphrase path doesn't get wallet keys)
const eoa = this.deriveEOAKey();
this.derivedKeys = {
encryptionKey,
signingKeyPair,
didSeed,
did,
fromPRF: this.fromPRF,
...(eoa && { eoaPrivateKey: eoa.privateKey, eoaAddress: eoa.address }),
};
return this.derivedKeys;
@ -297,10 +313,39 @@ export class EncryptIDKeyManager {
return `did:key:z${base58Encoded}`;
}
/**
* Derive secp256k1 EOA key from PRF output.
*
* Only available when initialized from PRF (not passphrase).
* Uses @noble/curves + @noble/hashes for deterministic derivation
* with domain-separated HKDF salt/info.
*/
private deriveEOAKey(): { privateKey: Uint8Array; publicKey: Uint8Array; address: string } | null {
if (!this.fromPRF || !this.prfOutputRaw) {
return null;
}
const result = deriveEOAFromPRF(this.prfOutputRaw);
console.log('EncryptID: EOA key derived, address:', result.address);
return result;
}
/**
* Clear all keys from memory
*/
clear(): void {
// Zero out sensitive key material
if (this.derivedKeys?.eoaPrivateKey) {
this.derivedKeys.eoaPrivateKey.fill(0);
}
if (this.derivedKeys?.didSeed) {
this.derivedKeys.didSeed.fill(0);
}
if (this.prfOutputRaw) {
this.prfOutputRaw.fill(0);
this.prfOutputRaw = null;
}
this.masterKey = null;
this.derivedKeys = null;
this.fromPRF = false;

View File

@ -1249,6 +1249,12 @@ app.post('/api/recovery/email/verify', async (c) => {
async function generateSessionToken(userId: string, username: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
// Check if user has set a wallet address → enable wallet capability
// Safe associations are stored client-side (privacy); server only knows
// the user has wallet capability via the wallet_address profile field.
const profile = await getUserProfile(userId);
const hasWallet = !!profile?.walletAddress;
const payload = {
iss: 'https://auth.rspace.online',
sub: userId,
@ -1262,7 +1268,7 @@ async function generateSessionToken(userId: string, username: string): Promise<s
capabilities: {
encrypt: true,
sign: true,
wallet: false,
wallet: hasWallet,
},
},
};
@ -2328,25 +2334,103 @@ app.get('/link', (c) => {
});
// ============================================================================
// SPACE MEMBERSHIP ROUTES
// SHARED AUTH HELPER
// ============================================================================
const VALID_ROLES = ['viewer', 'participant', 'moderator', 'admin'];
// Helper: verify JWT and return claims, or null
async function verifyTokenFromRequest(authorization: string | undefined): Promise<{
sub: string; did?: string; username?: string;
sub: string; did?: string; username?: string; eid?: any;
} | null> {
if (!authorization?.startsWith('Bearer ')) return null;
const token = authorization.slice(7);
try {
const payload = await verify(token, CONFIG.jwtSecret, 'HS256');
return payload as { sub: string; did?: string; username?: string };
return payload as { sub: string; did?: string; username?: string; eid?: any };
} catch {
return null;
}
}
// ============================================================================
// WALLET CAPABILITY ROUTES
// ============================================================================
// POST /encryptid/api/wallet-capability — Enable wallet capability for this user.
// Sets wallet_address on the user profile. Safe associations are stored
// client-side only (encrypted localStorage) to avoid server-side correlation
// between identity and on-chain addresses.
app.post('/encryptid/api/wallet-capability', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
const { walletAddress } = await c.req.json();
if (!walletAddress || !/^0x[0-9a-fA-F]{40}$/.test(walletAddress)) {
return c.json({ error: 'Valid Ethereum address required' }, 400);
}
await updateUserProfile(claims.sub, { walletAddress });
return c.json({ enabled: true, walletAddress });
});
// DELETE /encryptid/api/wallet-capability — Disable wallet capability.
app.delete('/encryptid/api/wallet-capability', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
await updateUserProfile(claims.sub, { walletAddress: null });
return c.json({ enabled: false });
});
// POST /encryptid/api/safe/verify — Check if EOA is owner on a Safe (stateless proxy).
// No server-side state — just proxies Safe Transaction Service API.
app.post('/encryptid/api/safe/verify', async (c) => {
const { safeAddress, chainId, eoaAddress } = await c.req.json();
if (!safeAddress || !eoaAddress) {
return c.json({ error: 'safeAddress and eoaAddress are required' }, 400);
}
const chain = chainId || 84532;
const CHAIN_PREFIXES: Record<number, string> = {
1: 'mainnet', 10: 'optimism', 100: 'gnosis-chain', 137: 'polygon',
8453: 'base', 42161: 'arbitrum', 42220: 'celo', 43114: 'avalanche',
56: 'bsc', 324: 'zksync', 11155111: 'sepolia', 84532: 'base-sepolia',
};
const prefix = CHAIN_PREFIXES[chain];
if (!prefix) {
return c.json({ error: 'Unsupported chain' }, 400);
}
try {
const safeRes = await fetch(
`https://safe-transaction-${prefix}.safe.global/api/v1/safes/${safeAddress}/`,
);
if (!safeRes.ok) {
return c.json({ isOwner: false, error: 'Safe not found' });
}
const safeData = await safeRes.json() as { owners?: string[]; threshold?: number; nonce?: number };
const owners = (safeData.owners || []).map((o: string) => o.toLowerCase());
const isOwner = owners.includes(eoaAddress.toLowerCase());
return c.json({
isOwner,
safeAddress,
chainId: chain,
threshold: safeData.threshold,
ownerCount: owners.length,
nonce: safeData.nonce,
});
} catch {
return c.json({ isOwner: false, error: 'Failed to query Safe' }, 502);
}
});
// ============================================================================
// SPACE MEMBERSHIP ROUTES
// ============================================================================
const VALID_ROLES = ['viewer', 'participant', 'moderator', 'admin'];
// GET /api/spaces/:slug/members — list all members
app.get('/api/spaces/:slug/members', async (c) => {
const { slug } = c.req.param();

View File

@ -116,6 +116,11 @@ export const OPERATION_PERMISSIONS: Record<string, OperationPermission> = {
'rmaps:add-location': { minAuthLevel: AuthLevel.STANDARD },
'rmaps:edit-location': { minAuthLevel: AuthLevel.STANDARD, requiresCapability: 'sign' },
// Payment operations (x402 and Safe treasury)
'payment:x402': { minAuthLevel: AuthLevel.STANDARD, requiresCapability: 'wallet' },
'payment:safe-propose': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet', maxAgeSeconds: 60 },
'payment:safe-execute': { minAuthLevel: AuthLevel.CRITICAL, requiresCapability: 'wallet', maxAgeSeconds: 60 },
// Account operations
'account:view-profile': { minAuthLevel: AuthLevel.STANDARD },
'account:edit-profile': { minAuthLevel: AuthLevel.ELEVATED },

View File

@ -0,0 +1,292 @@
/**
* EncryptID Client-Side Wallet Store
*
* Stores Safe wallet associations in encrypted localStorage using the
* same AES-256-GCM encryption key derived from the passkey PRF.
*
* Privacy model: The server never sees which Safes you own. It only knows
* you have "wallet capability" (a boolean flag). All Safe addresses, chain
* IDs, labels, and preferences are encrypted at rest in the browser.
*
* Data is re-encrypted on every write with a fresh IV.
*/
import { encryptData, decryptDataAsString, type EncryptedData } from './key-derivation';
import { bufferToBase64url, base64urlToBuffer } from './webauthn';
// ============================================================================
// TYPES
// ============================================================================
export interface SafeWalletEntry {
/** Client-generated UUID */
id: string;
/** Checksummed Safe contract address */
safeAddress: string;
/** EVM chain ID (e.g. 84532 for Base Sepolia, 8453 for Base) */
chainId: number;
/** Passkey-derived EOA address that is an owner on this Safe */
eoaAddress: string;
/** User-assigned label */
label: string;
/** Whether this is the user's default wallet */
isDefault: boolean;
/** Timestamp when this association was created */
addedAt: number;
}
interface StoredWalletData {
/** Schema version for future migrations */
version: 1;
/** Encrypted wallet entries */
wallets: SafeWalletEntry[];
}
interface PersistedBlob {
/** Base64url-encoded AES-256-GCM ciphertext */
c: string;
/** Base64url-encoded 12-byte IV */
iv: string;
}
// ============================================================================
// CONSTANTS
// ============================================================================
const STORAGE_KEY = 'encryptid_wallets';
// ============================================================================
// WALLET STORE
// ============================================================================
/**
* Encrypted client-side wallet store.
*
* Usage:
* ```ts
* const keys = await getKeyManager().getKeys();
* const store = new WalletStore(keys.encryptionKey);
*
* // Add a Safe
* await store.add({
* safeAddress: '0x...',
* chainId: 84532,
* eoaAddress: keys.eoaAddress!,
* label: 'Treasury',
* });
*
* // List all
* const wallets = await store.list();
*
* // Get default
* const primary = await store.getDefault();
* ```
*/
export class WalletStore {
private encryptionKey: CryptoKey;
private cache: SafeWalletEntry[] | null = null;
constructor(encryptionKey: CryptoKey) {
this.encryptionKey = encryptionKey;
}
/**
* List all stored Safe wallet associations.
*/
async list(): Promise<SafeWalletEntry[]> {
if (this.cache) return [...this.cache];
const wallets = await this.load();
this.cache = wallets;
return [...wallets];
}
/**
* Get the default wallet, or the first one if none is marked default.
*/
async getDefault(): Promise<SafeWalletEntry | null> {
const wallets = await this.list();
return wallets.find(w => w.isDefault) || wallets[0] || null;
}
/**
* Get a wallet by Safe address and chain.
*/
async get(safeAddress: string, chainId: number): Promise<SafeWalletEntry | null> {
const wallets = await this.list();
return wallets.find(
w => w.safeAddress.toLowerCase() === safeAddress.toLowerCase() && w.chainId === chainId,
) || null;
}
/**
* Add a new Safe wallet association.
*/
async add(entry: Omit<SafeWalletEntry, 'id' | 'isDefault' | 'addedAt'> & {
isDefault?: boolean;
}): Promise<SafeWalletEntry> {
const wallets = await this.list();
// Check for duplicate
const existing = wallets.find(
w => w.safeAddress.toLowerCase() === entry.safeAddress.toLowerCase()
&& w.chainId === entry.chainId,
);
if (existing) {
// Update in place
existing.label = entry.label;
existing.eoaAddress = entry.eoaAddress;
if (entry.isDefault) {
wallets.forEach(w => w.isDefault = false);
existing.isDefault = true;
}
await this.save(wallets);
return { ...existing };
}
const wallet: SafeWalletEntry = {
id: crypto.randomUUID(),
safeAddress: entry.safeAddress,
chainId: entry.chainId,
eoaAddress: entry.eoaAddress,
label: entry.label,
isDefault: entry.isDefault ?? wallets.length === 0, // First wallet is default
addedAt: Date.now(),
};
if (wallet.isDefault) {
wallets.forEach(w => w.isDefault = false);
}
wallets.push(wallet);
await this.save(wallets);
return { ...wallet };
}
/**
* Update an existing wallet entry.
*/
async update(id: string, updates: Partial<Pick<SafeWalletEntry, 'label' | 'isDefault'>>): Promise<SafeWalletEntry | null> {
const wallets = await this.list();
const wallet = wallets.find(w => w.id === id);
if (!wallet) return null;
if (updates.label !== undefined) wallet.label = updates.label;
if (updates.isDefault) {
wallets.forEach(w => w.isDefault = false);
wallet.isDefault = true;
}
await this.save(wallets);
return { ...wallet };
}
/**
* Remove a wallet association.
*/
async remove(id: string): Promise<boolean> {
const wallets = await this.list();
const idx = wallets.findIndex(w => w.id === id);
if (idx === -1) return false;
const wasDefault = wallets[idx].isDefault;
wallets.splice(idx, 1);
// If we removed the default, promote the first remaining
if (wasDefault && wallets.length > 0) {
wallets[0].isDefault = true;
}
await this.save(wallets);
return true;
}
/**
* Remove all wallet associations and clear storage.
*/
async clear(): Promise<void> {
this.cache = [];
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Storage not available
}
}
// ==========================================================================
// PRIVATE: Encrypt/Decrypt localStorage
// ==========================================================================
private async load(): Promise<SafeWalletEntry[]> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const blob: PersistedBlob = JSON.parse(raw);
if (!blob.c || !blob.iv) return [];
const encrypted: EncryptedData = {
ciphertext: base64urlToBuffer(blob.c),
iv: new Uint8Array(base64urlToBuffer(blob.iv)),
};
const json = await decryptDataAsString(this.encryptionKey, encrypted);
const data: StoredWalletData = JSON.parse(json);
if (data.version !== 1) {
console.warn('EncryptID WalletStore: Unknown schema version', data.version);
return [];
}
return data.wallets;
} catch (err) {
// Decryption failure = wrong key (new passkey?) or corrupt data
console.warn('EncryptID WalletStore: Failed to load wallets', err);
return [];
}
}
private async save(wallets: SafeWalletEntry[]): Promise<void> {
this.cache = [...wallets];
const data: StoredWalletData = { version: 1, wallets };
const json = JSON.stringify(data);
const encrypted = await encryptData(this.encryptionKey, json);
const blob: PersistedBlob = {
c: bufferToBase64url(encrypted.ciphertext),
iv: bufferToBase64url(encrypted.iv.buffer),
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(blob));
} catch (err) {
console.error('EncryptID WalletStore: Failed to save wallets', err);
}
}
}
// ============================================================================
// SINGLETON
// ============================================================================
let walletStoreInstance: WalletStore | null = null;
/**
* Get or create the wallet store singleton.
* Requires an encryption key (from the key manager).
*/
export function getWalletStore(encryptionKey: CryptoKey): WalletStore {
if (!walletStoreInstance) {
walletStoreInstance = new WalletStore(encryptionKey);
}
return walletStoreInstance;
}
/**
* Reset the wallet store (e.g. on logout).
*/
export function resetWalletStore(): void {
walletStoreInstance = null;
}