From be271de7fb61c8494eb90f34d4ddde61f23340f5 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 12:18:34 -0800 Subject: [PATCH] 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 --- bun.lock | 2 + modules/rwallet/mod.ts | 179 +++++++++++++++++ package.json | 2 + scripts/test-eoa-derivation.ts | 111 +++++++++++ scripts/test-key-manager-eoa.ts | 117 +++++++++++ scripts/test-passkey-signer.ts | 93 +++++++++ scripts/test-session-permissions.ts | 70 +++++++ scripts/test-wallet-store.ts | 181 +++++++++++++++++ shared/x402/passkey-signer.ts | 142 ++++++++++++++ src/encryptid/eoa-derivation.ts | 109 +++++++++++ src/encryptid/key-derivation.ts | 45 +++++ src/encryptid/server.ts | 96 ++++++++- src/encryptid/session.ts | 5 + src/encryptid/wallet-store.ts | 292 ++++++++++++++++++++++++++++ 14 files changed, 1438 insertions(+), 6 deletions(-) create mode 100644 scripts/test-eoa-derivation.ts create mode 100644 scripts/test-key-manager-eoa.ts create mode 100644 scripts/test-passkey-signer.ts create mode 100644 scripts/test-session-permissions.ts create mode 100644 scripts/test-wallet-store.ts create mode 100644 shared/x402/passkey-signer.ts create mode 100644 src/encryptid/eoa-derivation.ts create mode 100644 src/encryptid/wallet-store.ts diff --git a/bun.lock b/bun.lock index ea55549..1fa8752 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index 8e36db8..7f38807 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -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"; diff --git a/package.json b/package.json index cedbe7c..af933e0 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/test-eoa-derivation.ts b/scripts/test-eoa-derivation.ts new file mode 100644 index 0000000..76eb42b --- /dev/null +++ b/scripts/test-eoa-derivation.ts @@ -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(); diff --git a/scripts/test-key-manager-eoa.ts b/scripts/test-key-manager-eoa.ts new file mode 100644 index 0000000..e84e784 --- /dev/null +++ b/scripts/test-key-manager-eoa.ts @@ -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); +}); diff --git a/scripts/test-passkey-signer.ts b/scripts/test-passkey-signer.ts new file mode 100644 index 0000000..6640955 --- /dev/null +++ b/scripts/test-passkey-signer.ts @@ -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); +}); diff --git a/scripts/test-session-permissions.ts b/scripts/test-session-permissions.ts new file mode 100644 index 0000000..29690a0 --- /dev/null +++ b/scripts/test-session-permissions.ts @@ -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(); diff --git a/scripts/test-wallet-store.ts b/scripts/test-wallet-store.ts new file mode 100644 index 0000000..e3bfa8e --- /dev/null +++ b/scripts/test-wallet-store.ts @@ -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(); +(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); +}); diff --git a/shared/x402/passkey-signer.ts b/shared/x402/passkey-signer.ts new file mode 100644 index 0000000..7a43092 --- /dev/null +++ b/shared/x402/passkey-signer.ts @@ -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 { + 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 { + 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); + } + }, + }; +} diff --git a/src/encryptid/eoa-derivation.ts b/src/encryptid/eoa-derivation.ts new file mode 100644 index 0000000..c6a3924 --- /dev/null +++ b/src/encryptid/eoa-derivation.ts @@ -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); +} diff --git a/src/encryptid/key-derivation.ts b/src/encryptid/key-derivation.ts index 108ebee..47fc505 100644 --- a/src/encryptid/key-derivation.ts +++ b/src/encryptid/key-derivation.ts @@ -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 { + // 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; diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 0ea967e..2c27e84 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -1249,6 +1249,12 @@ app.post('/api/recovery/email/verify', async (c) => { async function generateSessionToken(userId: string, username: string): Promise { 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 { }); // ============================================================================ -// 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 = { + 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(); diff --git a/src/encryptid/session.ts b/src/encryptid/session.ts index 75901f5..8a8dcf4 100644 --- a/src/encryptid/session.ts +++ b/src/encryptid/session.ts @@ -116,6 +116,11 @@ export const OPERATION_PERMISSIONS: Record = { '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 }, diff --git a/src/encryptid/wallet-store.ts b/src/encryptid/wallet-store.ts new file mode 100644 index 0000000..e7646d5 --- /dev/null +++ b/src/encryptid/wallet-store.ts @@ -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 { + 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 { + 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 { + 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 & { + isDefault?: boolean; + }): Promise { + 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>): Promise { + 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 { + 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 { + this.cache = []; + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Storage not available + } + } + + // ========================================================================== + // PRIVATE: Encrypt/Decrypt localStorage + // ========================================================================== + + private async load(): Promise { + 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 { + 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; +}