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:
parent
c8e63b5c9f
commit
be271de7fb
2
bun.lock
2
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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue