mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
feat: implement MFA TOTP, real KMS encryption, smart wallet DB ops, fix dashboard stubs
Backend: - MFA TOTP via pquerna/otp: generate secret, verify code, enforce at login - KMS client: real AES-256-GCM encryption with Argon2id key derivation - Smart wallet handlers: propose/execute Safe tx, submit UserOp via DB - 9 new KMS tests, all 27 API tests passing Frontend: - Dashboard home fetches real vault/wallet counts, transactions, cluster status - User menu shows actual logged-in email instead of hardcoded placeholder - Reshare page connected to real API - Smart wallet detail connected to real API (propose, execute, user-op) - Loading/error states throughout
This commit is contained in:
@@ -4,7 +4,7 @@ import { useState, type FormEvent } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { api, APIError } from '@/lib/api'
|
||||
import { setTokens } from '@/lib/auth'
|
||||
import { setTokens, setUserEmail } from '@/lib/auth'
|
||||
import type { AuthResponse } from '@/lib/types'
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -32,6 +32,7 @@ export default function LoginPage() {
|
||||
|
||||
const auth = data as AuthResponse
|
||||
setTokens(auth.access_token, auth.refresh_token)
|
||||
setUserEmail(email)
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof APIError ? err.message : 'Login failed')
|
||||
|
||||
+134
-55
@@ -1,31 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { StatusBadge } from '@/components/common/status-badge'
|
||||
import { ChainIcon } from '@/components/common/chain-icon'
|
||||
import { api, APIError } from '@/lib/api'
|
||||
import type { Vault, Transaction, ClusterStatus } from '@/lib/types'
|
||||
|
||||
const stats = [
|
||||
{ label: 'Vaults', value: '--', description: 'Total vaults' },
|
||||
{ label: 'Wallets', value: '--', description: 'Total wallets' },
|
||||
{ label: 'Pending TX', value: '--', description: 'Awaiting approval' },
|
||||
]
|
||||
|
||||
const recentTx: {
|
||||
id: string
|
||||
type: string
|
||||
chain: string
|
||||
amount: string
|
||||
status: string
|
||||
created_at: string
|
||||
}[] = []
|
||||
|
||||
const clusterNodes = [
|
||||
{ id: 'node0', status: 'running', uptime: '99.9%' },
|
||||
{ id: 'node1', status: 'running', uptime: '99.8%' },
|
||||
{ id: 'node2', status: 'running', uptime: '99.7%' },
|
||||
]
|
||||
interface DashboardStats {
|
||||
vaults: number
|
||||
wallets: number
|
||||
pendingTx: number
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats>({ vaults: 0, wallets: 0, pendingTx: 0 })
|
||||
const [vaults, setVaults] = useState<Vault[]>([])
|
||||
const [recentTx, setRecentTx] = useState<Transaction[]>([])
|
||||
const [cluster, setCluster] = useState<ClusterStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDashboard() {
|
||||
try {
|
||||
const [vaultList, txList, clusterStatus] = await Promise.all([
|
||||
api.listVaults().catch(() => [] as Vault[]),
|
||||
api.listTransactions().catch(() => [] as Transaction[]),
|
||||
api.getStatus().catch(() => null),
|
||||
])
|
||||
|
||||
setVaults(vaultList)
|
||||
|
||||
// Count wallets across all vaults
|
||||
let walletCount = 0
|
||||
try {
|
||||
const walletLists = await Promise.all(
|
||||
vaultList.map((v) => api.listWallets(v.id).catch(() => []))
|
||||
)
|
||||
walletCount = walletLists.reduce((sum, wl) => sum + wl.length, 0)
|
||||
} catch {
|
||||
// wallet count stays 0
|
||||
}
|
||||
|
||||
const pendingCount = txList.filter(
|
||||
(tx) => tx.status === 'pending' || tx.status === 'pending_approval'
|
||||
).length
|
||||
|
||||
setStats({ vaults: vaultList.length, wallets: walletCount, pendingTx: pendingCount })
|
||||
setRecentTx(txList.slice(0, 10))
|
||||
setCluster(clusterStatus)
|
||||
} catch (err) {
|
||||
setError(err instanceof APIError ? err.message : 'Failed to load dashboard')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchDashboard()
|
||||
}, [])
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Vaults', value: stats.vaults, description: 'Total vaults' },
|
||||
{ label: 'Wallets', value: stats.wallets, description: 'Total wallets' },
|
||||
{ label: 'Pending TX', value: stats.pendingTx, description: 'Awaiting approval' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
@@ -38,18 +77,20 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Total value */}
|
||||
<div className="mb-8 rounded-lg border border-border bg-card p-6">
|
||||
<p className="text-sm text-muted-foreground">Total Portfolio Value</p>
|
||||
<p className="mt-1 text-3xl font-semibold tracking-tight font-mono">$--,---.--</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mb-8 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
{stats.map((stat) => (
|
||||
{statCards.map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg border border-border bg-card p-6">
|
||||
<p className="text-sm text-muted-foreground">{stat.label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold tracking-tight font-mono">{stat.value}</p>
|
||||
<p className="mt-1 text-2xl font-semibold tracking-tight font-mono">
|
||||
{loading ? '--' : stat.value}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{stat.description}</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -70,7 +111,13 @@ export default function DashboardPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentTx.length === 0 ? (
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : recentTx.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No recent transactions.
|
||||
@@ -79,15 +126,17 @@ export default function DashboardPage() {
|
||||
) : (
|
||||
recentTx.map((tx) => (
|
||||
<tr key={tx.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3 capitalize">{tx.type}</td>
|
||||
<td className="px-4 py-3 capitalize">{tx.tx_type}</td>
|
||||
<td className="px-4 py-3">
|
||||
<ChainIcon chain={tx.chain} />
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono">{tx.amount}</td>
|
||||
<td className="px-4 py-3 font-mono">{tx.amount ?? '--'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={tx.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{tx.created_at}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{new Date(tx.created_at).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -100,9 +149,27 @@ export default function DashboardPage() {
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold">Vault Summary</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-center text-muted-foreground">
|
||||
No vaults created yet.
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-center text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : vaults.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-center text-muted-foreground">
|
||||
No vaults created yet.
|
||||
</div>
|
||||
) : (
|
||||
vaults.map((vault) => (
|
||||
<div key={vault.id} className="rounded-lg border border-border bg-card p-6">
|
||||
<p className="font-medium">{vault.name}</p>
|
||||
{vault.description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{vault.description}</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Created {new Date(vault.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -110,30 +177,42 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-semibold">MPC Cluster Status</h2>
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Threshold</p>
|
||||
<p className="text-lg font-semibold font-mono">2 of 3</p>
|
||||
</div>
|
||||
<StatusBadge status="running" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{clusterNodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="flex items-center justify-between rounded-md bg-muted/50 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
<span className="text-sm font-medium font-mono">{node.id}</span>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading cluster status...</p>
|
||||
) : cluster ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Threshold</p>
|
||||
<p className="text-lg font-semibold font-mono">
|
||||
{cluster.threshold} of {cluster.expected_peers}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs text-muted-foreground">Uptime: {node.uptime}</span>
|
||||
<StatusBadge status={node.status} />
|
||||
<StatusBadge status={cluster.ready ? 'running' : 'degraded'} />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-md bg-muted/50 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`h-2 w-2 rounded-full ${cluster.ready ? 'bg-emerald-400' : 'bg-yellow-400'}`} />
|
||||
<span className="text-sm font-medium font-mono">{cluster.node_id}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Peers: {cluster.connected_peers}/{cluster.expected_peers}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{cluster.version}
|
||||
</span>
|
||||
<StatusBadge status={cluster.ready ? 'running' : 'degraded'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cluster status unavailable.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, type FormEvent } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { api, APIError } from '@/lib/api'
|
||||
import { setTokens } from '@/lib/auth'
|
||||
import { setTokens, setUserEmail } from '@/lib/auth'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
@@ -22,6 +22,7 @@ export default function RegisterPage() {
|
||||
try {
|
||||
const data = await api.register({ org_name: orgName, email, password })
|
||||
setTokens(data.access_token, data.refresh_token)
|
||||
setUserEmail(email)
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof APIError ? err.message : 'Registration failed')
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { api, APIError } from '@/lib/api'
|
||||
import { SafeTxQueue } from '@/components/smart-wallets/safe-tx-queue'
|
||||
import { TxTable } from '@/components/transactions/tx-table'
|
||||
|
||||
interface SmartWalletDetail {
|
||||
id: string
|
||||
type: 'safe' | 'erc4337'
|
||||
chain: string
|
||||
chainId: number
|
||||
contractAddress: string
|
||||
threshold: number
|
||||
owners: string[]
|
||||
mpcWalletId: string
|
||||
}
|
||||
import type { SmartWallet, Transaction } from '@/lib/types'
|
||||
|
||||
interface PendingSafeTx {
|
||||
id: string
|
||||
@@ -31,64 +22,148 @@ interface PendingSafeTx {
|
||||
export default function SmartWalletDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
|
||||
// TODO: fetch from API
|
||||
const [wallet] = useState<SmartWalletDetail>({
|
||||
id: params.id,
|
||||
type: 'safe',
|
||||
chain: 'Ethereum',
|
||||
chainId: 1,
|
||||
contractAddress: '0x1234567890abcdef1234567890abcdef12345678',
|
||||
threshold: 2,
|
||||
owners: [
|
||||
'0xaaa1111111111111111111111111111111111111',
|
||||
'0xbbb2222222222222222222222222222222222222',
|
||||
'0xccc3333333333333333333333333333333333333',
|
||||
],
|
||||
mpcWalletId: 'wallet-abc',
|
||||
})
|
||||
const [wallet, setWallet] = useState<SmartWallet | null>(null)
|
||||
const [txHistory, setTxHistory] = useState<Transaction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fetchError, setFetchError] = useState('')
|
||||
|
||||
const [pendingTxs] = useState<PendingSafeTx[]>([
|
||||
{
|
||||
id: 'stx-1',
|
||||
to: '0xddd4444444444444444444444444444444444444',
|
||||
value: '1.5 ETH',
|
||||
data: '0x',
|
||||
nonce: 0,
|
||||
confirmations: 1,
|
||||
required: 2,
|
||||
submittedBy: '0xaaa...111',
|
||||
createdAt: '2026-02-28T10:00:00Z',
|
||||
},
|
||||
])
|
||||
// Pending Safe transactions (from wallet history with pending status)
|
||||
const [pendingTxs, setPendingTxs] = useState<PendingSafeTx[]>([])
|
||||
|
||||
// Safe: propose new transaction
|
||||
const [proposeTo, setProposeTo] = useState('')
|
||||
const [proposeValue, setProposeValue] = useState('')
|
||||
const [proposeData, setProposeData] = useState('')
|
||||
const [proposeLoading, setProposeLoading] = useState(false)
|
||||
const [proposeError, setProposeError] = useState('')
|
||||
|
||||
// ERC-4337: submit UserOperation
|
||||
const [userOpTarget, setUserOpTarget] = useState('')
|
||||
const [userOpValue, setUserOpValue] = useState('')
|
||||
const [userOpCalldata, setUserOpCalldata] = useState('')
|
||||
const [userOpLoading, setUserOpLoading] = useState(false)
|
||||
const [userOpError, setUserOpError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const sw = await api.getSmartWallet(params.id)
|
||||
setWallet(sw)
|
||||
|
||||
// Fetch transaction history for the parent MPC wallet
|
||||
try {
|
||||
const history = await api.getWalletHistory(sw.wallet_id)
|
||||
setTxHistory(history)
|
||||
|
||||
// Derive pending Safe txs from pending transactions
|
||||
const pending: PendingSafeTx[] = history
|
||||
.filter((tx) => tx.status === 'pending' || tx.status === 'pending_approval')
|
||||
.map((tx, i) => ({
|
||||
id: tx.id,
|
||||
to: tx.to_address ?? '',
|
||||
value: tx.amount ?? '0',
|
||||
data: '0x',
|
||||
nonce: i,
|
||||
confirmations: tx.approved_by?.length ?? 0,
|
||||
required: sw.threshold,
|
||||
submittedBy: tx.initiated_by ?? 'unknown',
|
||||
createdAt: tx.created_at,
|
||||
}))
|
||||
setPendingTxs(pending)
|
||||
} catch {
|
||||
// tx history fetch failed, non-fatal
|
||||
}
|
||||
} catch (err) {
|
||||
setFetchError(err instanceof APIError ? err.message : 'Failed to load smart wallet')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [params.id])
|
||||
|
||||
async function handleProposeSafeTx() {
|
||||
// TODO: call API to propose Safe transaction
|
||||
alert(`Propose tx to ${proposeTo} for ${proposeValue}`)
|
||||
if (!wallet) return
|
||||
setProposeLoading(true)
|
||||
setProposeError('')
|
||||
try {
|
||||
await api.proposeSafeTx(params.id, {
|
||||
to: proposeTo,
|
||||
value: proposeValue,
|
||||
data: proposeData || undefined,
|
||||
})
|
||||
setProposeTo('')
|
||||
setProposeValue('')
|
||||
setProposeData('')
|
||||
// Refresh wallet data
|
||||
const history = await api.getWalletHistory(wallet.wallet_id).catch(() => [])
|
||||
setTxHistory(history)
|
||||
} catch (err) {
|
||||
setProposeError(err instanceof APIError ? err.message : 'Failed to propose transaction')
|
||||
} finally {
|
||||
setProposeLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitUserOp() {
|
||||
// TODO: call API to submit UserOperation
|
||||
alert(`Submit UserOp to ${userOpTarget} for ${userOpValue}`)
|
||||
if (!wallet) return
|
||||
setUserOpLoading(true)
|
||||
setUserOpError('')
|
||||
try {
|
||||
await api.userOperation(params.id, {
|
||||
call_data: userOpCalldata,
|
||||
value: userOpValue || undefined,
|
||||
})
|
||||
setUserOpTarget('')
|
||||
setUserOpValue('')
|
||||
setUserOpCalldata('')
|
||||
} catch (err) {
|
||||
setUserOpError(err instanceof APIError ? err.message : 'Failed to submit UserOperation')
|
||||
} finally {
|
||||
setUserOpLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||
<p className="text-sm text-muted-foreground">Loading smart wallet...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (fetchError || !wallet) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{fetchError || 'Smart wallet not found'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Map API transactions to TxTable format
|
||||
const tableTransactions = txHistory.map((tx) => ({
|
||||
id: tx.id,
|
||||
type: tx.tx_type,
|
||||
status: (tx.status === 'pending_approval' ? 'awaiting_approval' : tx.status) as
|
||||
'pending' | 'confirmed' | 'failed' | 'awaiting_approval',
|
||||
chain: tx.chain,
|
||||
from: wallet.contract_address,
|
||||
to: tx.to_address ?? '',
|
||||
value: tx.amount ?? '0',
|
||||
hash: tx.tx_hash ?? undefined,
|
||||
timestamp: tx.created_at,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-8 px-4 py-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{wallet.type === 'safe' ? 'Safe' : 'ERC-4337'} Wallet
|
||||
{wallet.wallet_type === 'safe' ? 'Safe' : 'ERC-4337'} Wallet
|
||||
</h1>
|
||||
<p className="mt-1 truncate font-mono text-sm text-muted-foreground">
|
||||
{wallet.contractAddress}
|
||||
{wallet.contract_address}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -101,14 +176,12 @@ export default function SmartWalletDetailPage() {
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Type</dt>
|
||||
<dd className="mt-1">
|
||||
{wallet.type === 'safe' ? 'Gnosis Safe' : 'ERC-4337 Account'}
|
||||
{wallet.wallet_type === 'safe' ? 'Gnosis Safe' : 'ERC-4337 Account'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Chain</dt>
|
||||
<dd className="mt-1">
|
||||
{wallet.chain} ({wallet.chainId})
|
||||
</dd>
|
||||
<dd className="mt-1">{wallet.chain}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Threshold</dt>
|
||||
@@ -118,8 +191,18 @@ export default function SmartWalletDetailPage() {
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">MPC Wallet</dt>
|
||||
<dd className="mt-1 font-mono text-xs">{wallet.mpcWalletId}</dd>
|
||||
<dd className="mt-1 font-mono text-xs">{wallet.wallet_id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Status</dt>
|
||||
<dd className="mt-1 capitalize">{wallet.status}</dd>
|
||||
</div>
|
||||
{wallet.deployed_at && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Deployed</dt>
|
||||
<dd className="mt-1 text-xs">{new Date(wallet.deployed_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">Owners</dt>
|
||||
<dd className="mt-1 space-y-1">
|
||||
@@ -134,7 +217,7 @@ export default function SmartWalletDetailPage() {
|
||||
</section>
|
||||
|
||||
{/* Safe: pending transactions */}
|
||||
{wallet.type === 'safe' && (
|
||||
{wallet.wallet_type === 'safe' && (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Pending Transactions</h2>
|
||||
@@ -145,6 +228,9 @@ export default function SmartWalletDetailPage() {
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Propose Transaction
|
||||
</h2>
|
||||
{proposeError && (
|
||||
<p className="text-sm text-destructive">{proposeError}</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
@@ -169,10 +255,10 @@ export default function SmartWalletDetailPage() {
|
||||
/>
|
||||
<button
|
||||
onClick={handleProposeSafeTx}
|
||||
disabled={!proposeTo}
|
||||
disabled={!proposeTo || proposeLoading}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Propose Transaction
|
||||
{proposeLoading ? 'Proposing...' : 'Propose Transaction'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -180,11 +266,14 @@ export default function SmartWalletDetailPage() {
|
||||
)}
|
||||
|
||||
{/* ERC-4337: UserOperation */}
|
||||
{wallet.type === 'erc4337' && (
|
||||
{wallet.wallet_type === 'erc4337' && (
|
||||
<section className="rounded-lg border border-border bg-card p-6 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Submit UserOperation
|
||||
</h2>
|
||||
{userOpError && (
|
||||
<p className="text-sm text-destructive">{userOpError}</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
@@ -209,10 +298,10 @@ export default function SmartWalletDetailPage() {
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmitUserOp}
|
||||
disabled={!userOpTarget}
|
||||
disabled={!userOpTarget || userOpLoading}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Submit UserOperation
|
||||
{userOpLoading ? 'Submitting...' : 'Submit UserOperation'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -221,21 +310,7 @@ export default function SmartWalletDetailPage() {
|
||||
{/* Transaction history */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Transaction History</h2>
|
||||
<TxTable
|
||||
transactions={[
|
||||
{
|
||||
id: 'tx-1',
|
||||
type: 'send',
|
||||
status: 'confirmed',
|
||||
chain: wallet.chain,
|
||||
from: wallet.contractAddress,
|
||||
to: '0xddd...444',
|
||||
value: '0.5 ETH',
|
||||
hash: '0xabc...123',
|
||||
timestamp: '2026-02-27T15:30:00Z',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<TxTable transactions={tableTransactions} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { api, APIError } from '@/lib/api'
|
||||
import { ReshareWizard } from '@/components/wallets/reshare-wizard'
|
||||
|
||||
interface WalletInfo {
|
||||
id: string
|
||||
name: string
|
||||
threshold: number
|
||||
participants: string[]
|
||||
ecdsaPubkey: string
|
||||
}
|
||||
import type { Wallet } from '@/lib/types'
|
||||
|
||||
type ReshareStatus = 'idle' | 'confirming' | 'running' | 'success' | 'error'
|
||||
|
||||
@@ -18,22 +12,31 @@ export default function ResharePage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
// TODO: fetch wallet info from API
|
||||
const [wallet] = useState<WalletInfo>({
|
||||
id: params.id,
|
||||
name: 'Vault Wallet 1',
|
||||
threshold: 2,
|
||||
participants: ['node0', 'node1', 'node2'],
|
||||
ecdsaPubkey: '0x04abc...def',
|
||||
})
|
||||
const [wallet, setWallet] = useState<Wallet | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fetchError, setFetchError] = useState('')
|
||||
|
||||
const [newThreshold, setNewThreshold] = useState(wallet.threshold)
|
||||
const [newParticipants, setNewParticipants] = useState(
|
||||
wallet.participants.join('\n')
|
||||
)
|
||||
const [newThreshold, setNewThreshold] = useState(2)
|
||||
const [newParticipants, setNewParticipants] = useState('')
|
||||
const [status, setStatus] = useState<ReshareStatus>('idle')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchWallet() {
|
||||
try {
|
||||
const w = await api.getWallet(params.id)
|
||||
setWallet(w)
|
||||
setNewThreshold(w.threshold)
|
||||
setNewParticipants(w.participants.join('\n'))
|
||||
} catch (err) {
|
||||
setFetchError(err instanceof APIError ? err.message : 'Failed to load wallet')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchWallet()
|
||||
}, [params.id])
|
||||
|
||||
const participantList = newParticipants
|
||||
.split('\n')
|
||||
.map((p) => p.trim())
|
||||
@@ -47,15 +50,35 @@ export default function ResharePage() {
|
||||
setStatus('running')
|
||||
setError('')
|
||||
try {
|
||||
// TODO: call MPC reshare API
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
await api.reshareWallet(params.id, {
|
||||
new_threshold: newThreshold,
|
||||
new_participants: participantList,
|
||||
})
|
||||
setStatus('success')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Reshare failed')
|
||||
setError(err instanceof APIError ? err.message : 'Reshare failed')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-8">
|
||||
<p className="text-sm text-muted-foreground">Loading wallet...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (fetchError || !wallet) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-8">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{fetchError || 'Wallet not found'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8 px-4 py-8">
|
||||
<div>
|
||||
@@ -64,7 +87,7 @@ export default function ResharePage() {
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Rotate key shares for wallet{' '}
|
||||
<span className="font-mono">{wallet.name}</span> without changing the
|
||||
<span className="font-mono">{wallet.name ?? wallet.id}</span> without changing the
|
||||
public key or on-chain addresses.
|
||||
</p>
|
||||
</div>
|
||||
@@ -90,7 +113,7 @@ export default function ResharePage() {
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">ECDSA Public Key</dt>
|
||||
<dd className="mt-1 truncate font-mono text-xs">
|
||||
{wallet.ecdsaPubkey}
|
||||
{wallet.ecdsa_pubkey ?? 'N/A'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { clearTokens } from '@/lib/auth'
|
||||
import { clearTokens, getUserEmail } from '@/lib/auth'
|
||||
|
||||
export function UserMenu() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [email, setEmail] = useState<string | null>(null)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEmail(getUserEmail())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
@@ -23,6 +28,8 @@ export function UserMenu() {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
const initial = email ? email.charAt(0).toUpperCase() : 'U'
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
@@ -30,9 +37,9 @@ export function UserMenu() {
|
||||
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
U
|
||||
{initial}
|
||||
</div>
|
||||
<span className="hidden sm:inline">user@example.com</span>
|
||||
<span className="hidden sm:inline">{email ?? 'Account'}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'lux_mpc_access_token'
|
||||
const REFRESH_TOKEN_KEY = 'lux_mpc_refresh_token'
|
||||
const USER_EMAIL_KEY = 'lux_mpc_user_email'
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
@@ -19,10 +20,21 @@ export function setTokens(accessToken: string, refreshToken: string): void {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
|
||||
}
|
||||
|
||||
export function setUserEmail(email: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(USER_EMAIL_KEY, email)
|
||||
}
|
||||
|
||||
export function getUserEmail(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(USER_EMAIL_KEY)
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
localStorage.removeItem(USER_EMAIL_KEY)
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
|
||||
@@ -37,6 +37,7 @@ require (
|
||||
github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cronokirby/saferith v0.33.0 // indirect
|
||||
@@ -82,6 +83,7 @@ require (
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
|
||||
@@ -23,6 +23,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
@@ -252,6 +254,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
|
||||
|
||||
+84
-14
@@ -1,12 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -116,14 +116,18 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if mfaSecret != nil && *mfaSecret != "" {
|
||||
// Only enforce MFA if the secret is active (not pending setup verification)
|
||||
if mfaSecret != nil && *mfaSecret != "" && !strings.HasPrefix(*mfaSecret, "pending:") {
|
||||
if req.MFACode == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mfa_required": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
// TODO: validate TOTP code against mfaSecret
|
||||
if !totp.Validate(req.MFACode, *mfaSecret) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid MFA code")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
accessToken, err := s.generateJWT(userID, orgID, role)
|
||||
@@ -181,27 +185,52 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleMFASetup(w http.ResponseWriter, r *http.Request) {
|
||||
userID := getUserID(r.Context())
|
||||
|
||||
secret := make([]byte, 20)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate secret")
|
||||
// Look up user email for the TOTP account name
|
||||
var email string
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT email FROM users WHERE id = $1`, userID).Scan(&email)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to look up user")
|
||||
return
|
||||
}
|
||||
encoded := base32.StdEncoding.EncodeToString(secret)
|
||||
|
||||
_, err := s.db.Pool.Exec(r.Context(),
|
||||
`UPDATE users SET mfa_secret = $1 WHERE id = $2`, encoded, userID)
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "LuxMPC",
|
||||
AccountName: email,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate TOTP secret")
|
||||
return
|
||||
}
|
||||
|
||||
// Store the base32-encoded secret (not yet verified -- MFA not active until handleMFAVerify)
|
||||
_, err = s.db.Pool.Exec(r.Context(),
|
||||
`UPDATE users SET mfa_secret = NULL WHERE id = $1`, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
// Store pending secret in a separate column or use a convention:
|
||||
// We store it but MFA is only active after verification confirms the user can produce codes.
|
||||
// For simplicity, store with a "pending:" prefix; handleMFAVerify strips it on confirmation.
|
||||
pending := "pending:" + key.Secret()
|
||||
_, err = s.db.Pool.Exec(r.Context(),
|
||||
`UPDATE users SET mfa_secret = $1 WHERE id = $2`, pending, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"secret": encoded,
|
||||
"otpauth": "otpauth://totp/LuxMPC?secret=" + encoded + "&issuer=LuxMPC",
|
||||
"secret": key.Secret(),
|
||||
"otpauth": key.URL(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMFAVerify(w http.ResponseWriter, r *http.Request) {
|
||||
userID := getUserID(r.Context())
|
||||
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
@@ -209,8 +238,49 @@ func (s *Server) handleMFAVerify(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
// TODO: Implement TOTP verification
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "verified"})
|
||||
if req.Code == "" {
|
||||
writeError(w, http.StatusBadRequest, "code is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the pending MFA secret
|
||||
var mfaSecret *string
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT mfa_secret FROM users WHERE id = $1`, userID).Scan(&mfaSecret)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
if mfaSecret == nil || *mfaSecret == "" {
|
||||
writeError(w, http.StatusBadRequest, "MFA setup not initiated")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract secret -- may have "pending:" prefix from setup
|
||||
secret := *mfaSecret
|
||||
if strings.HasPrefix(secret, "pending:") {
|
||||
secret = strings.TrimPrefix(secret, "pending:")
|
||||
} else {
|
||||
// MFA is already active, nothing to verify
|
||||
writeError(w, http.StatusBadRequest, "MFA is already enabled")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the TOTP code
|
||||
if !totp.Validate(req.Code, secret) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid TOTP code")
|
||||
return
|
||||
}
|
||||
|
||||
// Activate MFA by storing the secret without the "pending:" prefix
|
||||
_, err = s.db.Pool.Exec(r.Context(),
|
||||
`UPDATE users SET mfa_secret = $1 WHERE id = $2`, secret, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "mfa_enabled"})
|
||||
}
|
||||
|
||||
func slugify(name string) string {
|
||||
|
||||
@@ -118,6 +118,7 @@ func (s *Server) handleGetSmartWallet(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleProposeSafeTx(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
userID := getUserID(r.Context())
|
||||
swID := urlParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
@@ -129,8 +130,12 @@ func (s *Server) handleProposeSafeTx(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.To == "" {
|
||||
writeError(w, http.StatusBadRequest, "to address is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify smart wallet
|
||||
// Verify smart wallet and get associated wallet ID + chain
|
||||
var walletID, chain string
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT wallet_id, chain FROM smart_wallets WHERE id = $1 AND org_id = $2`,
|
||||
@@ -140,13 +145,21 @@ func (s *Server) handleProposeSafeTx(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Create Safe transaction proposal, sign with MPC
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "proposed",
|
||||
"smart_wallet_id": swID,
|
||||
"to": req.To,
|
||||
"value": req.Value,
|
||||
})
|
||||
// Create a transaction record for the Safe proposal
|
||||
var tx db.Transaction
|
||||
err = s.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO transactions (org_id, wallet_id, tx_type, chain, to_address, amount, raw_tx, status, initiated_by)
|
||||
VALUES ($1, $2, 'safe_proposal', $3, $4, $5, $6, 'pending', $7)
|
||||
RETURNING id, org_id, wallet_id, tx_type, chain, to_address, amount, status, initiated_by, created_at`,
|
||||
orgID, walletID, chain, req.To, req.Value, []byte(req.Data), nilIfEmpty(userID)).
|
||||
Scan(&tx.ID, &tx.OrgID, &tx.WalletID, &tx.TxType, &tx.Chain,
|
||||
&tx.ToAddress, &tx.Amount, &tx.Status, &tx.InitiatedBy, &tx.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create transaction: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, tx)
|
||||
}
|
||||
|
||||
func (s *Server) handleExecuteSafeTx(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -160,25 +173,43 @@ func (s *Server) handleExecuteSafeTx(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.SafeTxHash == "" {
|
||||
writeError(w, http.StatusBadRequest, "safe_tx_hash is required")
|
||||
return
|
||||
}
|
||||
|
||||
var contractAddress string
|
||||
// Verify smart wallet exists
|
||||
var walletID string
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT contract_address FROM smart_wallets WHERE id = $1 AND org_id = $2`,
|
||||
swID, orgID).Scan(&contractAddress)
|
||||
`SELECT wallet_id FROM smart_wallets WHERE id = $1 AND org_id = $2`,
|
||||
swID, orgID).Scan(&walletID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "smart wallet not found")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Execute Safe transaction
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "executing",
|
||||
"safe_tx_hash": req.SafeTxHash,
|
||||
})
|
||||
// Update the matching pending transaction to "executing" status
|
||||
var tx db.Transaction
|
||||
err = s.db.Pool.QueryRow(r.Context(),
|
||||
`UPDATE transactions SET status = 'executing', tx_hash = $1
|
||||
WHERE wallet_id = $2 AND org_id = $3 AND tx_type = 'safe_proposal' AND status = 'pending'
|
||||
AND id = (SELECT id FROM transactions WHERE wallet_id = $2 AND org_id = $3
|
||||
AND tx_type = 'safe_proposal' AND status = 'pending' ORDER BY created_at DESC LIMIT 1)
|
||||
RETURNING id, org_id, wallet_id, tx_type, chain, to_address, amount, tx_hash, status, created_at`,
|
||||
req.SafeTxHash, walletID, orgID).
|
||||
Scan(&tx.ID, &tx.OrgID, &tx.WalletID, &tx.TxType, &tx.Chain,
|
||||
&tx.ToAddress, &tx.Amount, &tx.TxHash, &tx.Status, &tx.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "no pending safe proposal found for this wallet")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, tx)
|
||||
}
|
||||
|
||||
func (s *Server) handleUserOperation(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
userID := getUserID(r.Context())
|
||||
swID := urlParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
@@ -189,11 +220,15 @@ func (s *Server) handleUserOperation(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.CallData == "" {
|
||||
writeError(w, http.StatusBadRequest, "call_data is required")
|
||||
return
|
||||
}
|
||||
|
||||
var walletType string
|
||||
var walletType, walletID, chain string
|
||||
err := s.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT wallet_type FROM smart_wallets WHERE id = $1 AND org_id = $2`,
|
||||
swID, orgID).Scan(&walletType)
|
||||
`SELECT wallet_type, wallet_id, chain FROM smart_wallets WHERE id = $1 AND org_id = $2`,
|
||||
swID, orgID).Scan(&walletType, &walletID, &chain)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "smart wallet not found")
|
||||
return
|
||||
@@ -203,9 +238,19 @@ func (s *Server) handleUserOperation(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Build and submit UserOperation
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "submitted",
|
||||
"smart_wallet_id": swID,
|
||||
})
|
||||
// Create a transaction record for the UserOperation
|
||||
var tx db.Transaction
|
||||
err = s.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO transactions (org_id, wallet_id, tx_type, chain, amount, raw_tx, status, initiated_by)
|
||||
VALUES ($1, $2, 'user_operation', $3, $4, $5, 'submitted', $6)
|
||||
RETURNING id, org_id, wallet_id, tx_type, chain, amount, status, initiated_by, created_at`,
|
||||
orgID, walletID, chain, req.Value, []byte(req.CallData), nilIfEmpty(userID)).
|
||||
Scan(&tx.ID, &tx.OrgID, &tx.WalletID, &tx.TxType, &tx.Chain,
|
||||
&tx.Amount, &tx.Status, &tx.InitiatedBy, &tx.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create user operation: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, tx)
|
||||
}
|
||||
|
||||
+208
-89
@@ -2,19 +2,32 @@ package kms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/logger"
|
||||
)
|
||||
|
||||
// KMSClient wraps the Lux KMS SDK for MPC key management
|
||||
// KMSClient wraps the Lux KMS SDK for MPC key management.
|
||||
// All secrets are encrypted at rest with AES-256-GCM using a master key
|
||||
// derived from client credentials via Argon2id.
|
||||
type KMSClient struct {
|
||||
mu sync.RWMutex
|
||||
projectID string
|
||||
environment string
|
||||
secretPath string
|
||||
siteURL string
|
||||
// Using map to store secrets locally for now
|
||||
masterKey []byte // 32-byte AES-256 key derived from credentials
|
||||
// Encrypted secrets stored in memory (ciphertext, not plaintext)
|
||||
secrets map[string][]byte
|
||||
}
|
||||
|
||||
@@ -37,14 +50,14 @@ type SecretMetadata struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// NewKMSClient creates a new Lux KMS client for secure key operations
|
||||
// NewKMSClient creates a new Lux KMS client for secure key operations.
|
||||
// A 32-byte master key is derived from ClientID + ClientSecret via Argon2id.
|
||||
// If no credentials are provided, a deterministic fallback key is used (development only).
|
||||
func NewKMSClient(config KMSConfig) (*KMSClient, error) {
|
||||
// Validate config
|
||||
if config.ProjectID == "" {
|
||||
return nil, fmt.Errorf("project ID is required")
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if config.Environment == "" {
|
||||
config.Environment = "prod"
|
||||
}
|
||||
@@ -52,10 +65,23 @@ func NewKMSClient(config KMSConfig) (*KMSClient, error) {
|
||||
config.SecretPath = "/mpc"
|
||||
}
|
||||
if config.SiteURL == "" {
|
||||
config.SiteURL = "http://localhost:8080" // Default local instance
|
||||
config.SiteURL = "http://localhost:8080"
|
||||
}
|
||||
|
||||
logger.Info("Initializing KMS client (stub implementation)",
|
||||
// Derive master key from client credentials
|
||||
var masterKey []byte
|
||||
if config.ClientID != "" && config.ClientSecret != "" {
|
||||
// Use client credentials to derive a strong encryption key
|
||||
salt := sha256.Sum256([]byte("luxkms:" + config.ProjectID + ":" + config.ClientID))
|
||||
masterKey = argon2.IDKey([]byte(config.ClientSecret), salt[:], 1, 64*1024, 4, 32)
|
||||
} else {
|
||||
// Development fallback -- deterministic but not secure for production
|
||||
logger.Warn("No KMS client credentials provided, using deterministic key (NOT FOR PRODUCTION)")
|
||||
salt := sha256.Sum256([]byte("luxkms:dev:" + config.ProjectID))
|
||||
masterKey = argon2.IDKey([]byte("dev-fallback-"+config.ProjectID), salt[:], 1, 64*1024, 4, 32)
|
||||
}
|
||||
|
||||
logger.Info("Initializing KMS client with AES-256-GCM encryption",
|
||||
"projectID", config.ProjectID,
|
||||
"environment", config.Environment,
|
||||
"secretPath", config.SecretPath,
|
||||
@@ -66,106 +92,166 @@ func NewKMSClient(config KMSConfig) (*KMSClient, error) {
|
||||
environment: config.Environment,
|
||||
secretPath: config.SecretPath,
|
||||
siteURL: config.SiteURL,
|
||||
masterKey: masterKey,
|
||||
secrets: make(map[string][]byte),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StoreKeyShare stores an MPC key share in Lux KMS
|
||||
func (c *KMSClient) StoreKeyShare(ctx context.Context, walletID string, keyShare []byte) error {
|
||||
secretName := fmt.Sprintf("%s/wallets/%s/keyshare", c.secretPath, walletID)
|
||||
// encrypt encrypts plaintext using AES-256-GCM with the master key.
|
||||
// The nonce is prepended to the ciphertext.
|
||||
func (c *KMSClient) encrypt(plaintext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(c.masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
// Store in local map for stub implementation
|
||||
c.secrets[secretName] = keyShare
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("Stored key share (stub)",
|
||||
"walletID", walletID,
|
||||
"size", len(keyShare),
|
||||
)
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// nonce || ciphertext
|
||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// decrypt decrypts ciphertext (nonce || encrypted) using AES-256-GCM with the master key.
|
||||
func (c *KMSClient) decrypt(ciphertext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(c.masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ct := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// storeEncrypted encrypts and stores a secret under the given name.
|
||||
func (c *KMSClient) storeEncrypted(name string, data []byte) error {
|
||||
encrypted, err := c.encrypt(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encryption failed for %s: %w", name, err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.secrets[name] = encrypted
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetrieveKeyShare retrieves an MPC key share from Lux KMS
|
||||
func (c *KMSClient) RetrieveKeyShare(ctx context.Context, walletID string) ([]byte, error) {
|
||||
secretName := fmt.Sprintf("%s/wallets/%s/keyshare", c.secretPath, walletID)
|
||||
|
||||
// Retrieve from local map for stub implementation
|
||||
keyShare, ok := c.secrets[secretName]
|
||||
// retrieveEncrypted retrieves and decrypts a secret by name.
|
||||
func (c *KMSClient) retrieveEncrypted(name string) ([]byte, error) {
|
||||
c.mu.RLock()
|
||||
encrypted, ok := c.secrets[name]
|
||||
c.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("key share not found for wallet %s", walletID)
|
||||
return nil, fmt.Errorf("secret not found: %s", name)
|
||||
}
|
||||
return c.decrypt(encrypted)
|
||||
}
|
||||
|
||||
logger.Info("Retrieved key share (stub)",
|
||||
// StoreKeyShare stores an MPC key share encrypted with AES-256-GCM
|
||||
func (c *KMSClient) StoreKeyShare(ctx context.Context, walletID string, keyShare []byte) error {
|
||||
secretName := fmt.Sprintf("%s/wallets/%s/keyshare", c.secretPath, walletID)
|
||||
if err := c.storeEncrypted(secretName, keyShare); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("Stored key share (encrypted)",
|
||||
"walletID", walletID,
|
||||
"size", len(keyShare),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetrieveKeyShare retrieves and decrypts an MPC key share
|
||||
func (c *KMSClient) RetrieveKeyShare(ctx context.Context, walletID string) ([]byte, error) {
|
||||
secretName := fmt.Sprintf("%s/wallets/%s/keyshare", c.secretPath, walletID)
|
||||
keyShare, err := c.retrieveEncrypted(secretName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("key share not found for wallet %s: %w", walletID, err)
|
||||
}
|
||||
logger.Info("Retrieved key share (encrypted)",
|
||||
"walletID", walletID,
|
||||
"size", len(keyShare),
|
||||
)
|
||||
return keyShare, nil
|
||||
}
|
||||
|
||||
// RotateKeyShare rotates the key share for a wallet
|
||||
// RotateKeyShare rotates the key share for a wallet (re-encrypts with current key)
|
||||
func (c *KMSClient) RotateKeyShare(ctx context.Context, walletID string, newKeyShare []byte) error {
|
||||
// For stub implementation, just update the existing key
|
||||
return c.StoreKeyShare(ctx, walletID, newKeyShare)
|
||||
}
|
||||
|
||||
// StorePresignature stores a presignature in Lux KMS
|
||||
// StorePresignature stores a presignature encrypted with AES-256-GCM
|
||||
func (c *KMSClient) StorePresignature(ctx context.Context, walletID, sigID string, presigData []byte) error {
|
||||
secretName := fmt.Sprintf("%s/wallets/%s/presigs/%s", c.secretPath, walletID, sigID)
|
||||
|
||||
// Store in local map for stub implementation
|
||||
c.secrets[secretName] = presigData
|
||||
|
||||
logger.Info("Stored presignature (stub)",
|
||||
if err := c.storeEncrypted(secretName, presigData); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("Stored presignature (encrypted)",
|
||||
"walletID", walletID,
|
||||
"sigID", sigID,
|
||||
"size", len(presigData),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetrievePresignature retrieves a presignature from Lux KMS
|
||||
// RetrievePresignature retrieves and decrypts a presignature
|
||||
func (c *KMSClient) RetrievePresignature(ctx context.Context, walletID, sigID string) ([]byte, error) {
|
||||
secretName := fmt.Sprintf("%s/wallets/%s/presigs/%s", c.secretPath, walletID, sigID)
|
||||
|
||||
// Retrieve from local map for stub implementation
|
||||
presigData, ok := c.secrets[secretName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("presignature not found for wallet %s, sig %s", walletID, sigID)
|
||||
presigData, err := c.retrieveEncrypted(secretName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("presignature not found for wallet %s, sig %s: %w", walletID, sigID, err)
|
||||
}
|
||||
|
||||
logger.Info("Retrieved presignature (stub)",
|
||||
logger.Info("Retrieved presignature (encrypted)",
|
||||
"walletID", walletID,
|
||||
"sigID", sigID,
|
||||
"size", len(presigData),
|
||||
)
|
||||
|
||||
return presigData, nil
|
||||
}
|
||||
|
||||
// DeletePresignature removes a used presignature
|
||||
func (c *KMSClient) DeletePresignature(ctx context.Context, walletID, sigID string) error {
|
||||
secretName := fmt.Sprintf("%s/wallets/%s/presigs/%s", c.secretPath, walletID, sigID)
|
||||
|
||||
// Delete from local map for stub implementation
|
||||
c.mu.Lock()
|
||||
delete(c.secrets, secretName)
|
||||
|
||||
logger.Info("Deleted presignature (stub)",
|
||||
c.mu.Unlock()
|
||||
logger.Info("Deleted presignature",
|
||||
"walletID", walletID,
|
||||
"sigID", sigID,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSecrets lists all secrets in a given path
|
||||
// ListSecrets lists all secrets in a given path (metadata only, no decryption)
|
||||
func (c *KMSClient) ListSecrets(ctx context.Context, path string) ([]SecretMetadata, error) {
|
||||
fullPath := fmt.Sprintf("%s%s", c.secretPath, path)
|
||||
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
var secrets []SecretMetadata
|
||||
for key := range c.secrets {
|
||||
if len(key) >= len(fullPath) && key[:len(fullPath)] == fullPath {
|
||||
if strings.HasPrefix(key, fullPath) {
|
||||
secrets = append(secrets, SecretMetadata{
|
||||
Name: key,
|
||||
Path: fullPath,
|
||||
@@ -179,26 +265,46 @@ func (c *KMSClient) ListSecrets(ctx context.Context, path string) ([]SecretMetad
|
||||
return secrets, nil
|
||||
}
|
||||
|
||||
// Healthcheck verifies KMS connectivity
|
||||
// Healthcheck verifies KMS connectivity and encryption capability
|
||||
func (c *KMSClient) Healthcheck(ctx context.Context) error {
|
||||
// Stub implementation always returns healthy
|
||||
logger.Debug("KMS health check passed (stub)")
|
||||
// Verify encryption round-trip works
|
||||
testData := []byte("healthcheck")
|
||||
encrypted, err := c.encrypt(testData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encryption healthcheck failed: %w", err)
|
||||
}
|
||||
decrypted, err := c.decrypt(encrypted)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decryption healthcheck failed: %w", err)
|
||||
}
|
||||
if string(decrypted) != string(testData) {
|
||||
return fmt.Errorf("encryption round-trip mismatch")
|
||||
}
|
||||
logger.Debug("KMS health check passed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the KMS client connection
|
||||
// Close closes the KMS client and zeroes the master key
|
||||
func (c *KMSClient) Close() error {
|
||||
// Clear local secrets map
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Zero master key material
|
||||
for i := range c.masterKey {
|
||||
c.masterKey[i] = 0
|
||||
}
|
||||
c.secrets = nil
|
||||
logger.Info("KMS client closed (stub)")
|
||||
logger.Info("KMS client closed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// StoreMPCKeyShare stores an MPC key share with specific node and wallet IDs
|
||||
// StoreMPCKeyShare stores an MPC key share with specific node and wallet IDs (encrypted)
|
||||
func (c *KMSClient) StoreMPCKeyShare(nodeID, walletID, keyType string, keyData []byte) error {
|
||||
secretName := fmt.Sprintf("%s/nodes/%s/wallets/%s/%s", c.secretPath, nodeID, walletID, keyType)
|
||||
c.secrets[secretName] = keyData
|
||||
logger.Info("Stored MPC key share",
|
||||
if err := c.storeEncrypted(secretName, keyData); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("Stored MPC key share (encrypted)",
|
||||
"nodeID", nodeID,
|
||||
"walletID", walletID,
|
||||
"keyType", keyType,
|
||||
@@ -207,14 +313,14 @@ func (c *KMSClient) StoreMPCKeyShare(nodeID, walletID, keyType string, keyData [
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetrieveMPCKeyShare retrieves an MPC key share with specific node and wallet IDs
|
||||
// RetrieveMPCKeyShare retrieves and decrypts an MPC key share
|
||||
func (c *KMSClient) RetrieveMPCKeyShare(nodeID, walletID, keyType string) ([]byte, error) {
|
||||
secretName := fmt.Sprintf("%s/nodes/%s/wallets/%s/%s", c.secretPath, nodeID, walletID, keyType)
|
||||
keyData, ok := c.secrets[secretName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("key share not found for node %s, wallet %s, type %s", nodeID, walletID, keyType)
|
||||
keyData, err := c.retrieveEncrypted(secretName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("key share not found for node %s, wallet %s, type %s: %w", nodeID, walletID, keyType, err)
|
||||
}
|
||||
logger.Info("Retrieved MPC key share",
|
||||
logger.Info("Retrieved MPC key share (encrypted)",
|
||||
"nodeID", nodeID,
|
||||
"walletID", walletID,
|
||||
"keyType", keyType,
|
||||
@@ -223,9 +329,12 @@ func (c *KMSClient) RetrieveMPCKeyShare(nodeID, walletID, keyType string) ([]byt
|
||||
return keyData, nil
|
||||
}
|
||||
|
||||
// ListKeys lists all keys (without the actual key data)
|
||||
// ListKeys lists all secret names (without decrypting values)
|
||||
func (c *KMSClient) ListKeys() ([]string, error) {
|
||||
var keys []string
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
keys := make([]string, 0, len(c.secrets))
|
||||
for key := range c.secrets {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
@@ -235,6 +344,9 @@ func (c *KMSClient) ListKeys() ([]string, error) {
|
||||
|
||||
// DeleteKey removes a key from storage
|
||||
func (c *KMSClient) DeleteKey(key string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if _, ok := c.secrets[key]; !ok {
|
||||
return fmt.Errorf("key not found: %s", key)
|
||||
}
|
||||
@@ -243,60 +355,67 @@ func (c *KMSClient) DeleteKey(key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchStore stores multiple secrets in a single operation
|
||||
// BatchStore encrypts and stores multiple secrets
|
||||
func (c *KMSClient) BatchStore(ctx context.Context, secrets map[string][]byte) error {
|
||||
for name, data := range secrets {
|
||||
fullName := fmt.Sprintf("%s/%s", c.secretPath, name)
|
||||
c.secrets[fullName] = data
|
||||
if err := c.storeEncrypted(fullName, data); err != nil {
|
||||
return fmt.Errorf("failed to store %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Batch stored secrets (stub)", "count", len(secrets))
|
||||
logger.Info("Batch stored secrets (encrypted)", "count", len(secrets))
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchRetrieve retrieves multiple secrets in a single operation
|
||||
// BatchRetrieve decrypts and retrieves multiple secrets
|
||||
func (c *KMSClient) BatchRetrieve(ctx context.Context, names []string) (map[string][]byte, error) {
|
||||
results := make(map[string][]byte)
|
||||
|
||||
for _, name := range names {
|
||||
fullName := fmt.Sprintf("%s/%s", c.secretPath, name)
|
||||
if data, ok := c.secrets[fullName]; ok {
|
||||
results[name] = data
|
||||
data, err := c.retrieveEncrypted(fullName)
|
||||
if err != nil {
|
||||
continue // skip missing secrets
|
||||
}
|
||||
results[name] = data
|
||||
}
|
||||
|
||||
logger.Info("Batch retrieved secrets (stub)", "requested", len(names), "found", len(results))
|
||||
logger.Info("Batch retrieved secrets (encrypted)", "requested", len(names), "found", len(results))
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// StoreConfig stores MPC node configuration
|
||||
// StoreConfig encrypts and stores MPC node configuration
|
||||
func (c *KMSClient) StoreConfig(ctx context.Context, nodeID string, config interface{}) error {
|
||||
configName := fmt.Sprintf("%s/nodes/%s/config", c.secretPath, nodeID)
|
||||
|
||||
data, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
c.secrets[configName] = data
|
||||
|
||||
logger.Info("Stored node config (stub)", "nodeID", nodeID)
|
||||
if err := c.storeEncrypted(configName, data); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("Stored node config (encrypted)", "nodeID", nodeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetrieveConfig retrieves MPC node configuration
|
||||
// RetrieveConfig decrypts and retrieves MPC node configuration
|
||||
func (c *KMSClient) RetrieveConfig(ctx context.Context, nodeID string, config interface{}) error {
|
||||
configName := fmt.Sprintf("%s/nodes/%s/config", c.secretPath, nodeID)
|
||||
|
||||
data, ok := c.secrets[configName]
|
||||
if !ok {
|
||||
return fmt.Errorf("config not found for node %s", nodeID)
|
||||
data, err := c.retrieveEncrypted(configName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config not found for node %s: %w", nodeID, err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, config); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("Retrieved node config (stub)", "nodeID", nodeID)
|
||||
logger.Info("Retrieved node config (encrypted)", "nodeID", nodeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateEncryptionKey generates a cryptographically secure 32-byte key
|
||||
// suitable for use as a master key or data encryption key.
|
||||
func GenerateEncryptionKey() ([]byte, error) {
|
||||
key := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, key); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate encryption key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package kms
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestClient(t *testing.T) *KMSClient {
|
||||
t.Helper()
|
||||
c, err := NewKMSClient(KMSConfig{
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret-32-chars-long-enough",
|
||||
ProjectID: "test-project",
|
||||
Environment: "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
plaintext := []byte("this is a secret key share")
|
||||
|
||||
encrypted, err := c.encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Encrypted must differ from plaintext
|
||||
if bytes.Equal(encrypted, plaintext) {
|
||||
t.Fatal("encrypted data must differ from plaintext")
|
||||
}
|
||||
|
||||
decrypted, err := c.decrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(decrypted, plaintext) {
|
||||
t.Fatalf("round-trip failed: got %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRetrieveKeyShare(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
ctx := context.Background()
|
||||
walletID := "wallet-abc123"
|
||||
keyShare := []byte("ecdsa-key-share-bytes-here")
|
||||
|
||||
if err := c.StoreKeyShare(ctx, walletID, keyShare); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
retrieved, err := c.RetrieveKeyShare(ctx, walletID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(retrieved, keyShare) {
|
||||
t.Fatalf("got %q, want %q", retrieved, keyShare)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMPCKeyShareEncrypted(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
nodeID := "node0"
|
||||
walletID := "wallet-xyz"
|
||||
keyType := "ecdsa"
|
||||
keyData := []byte("mpc-share-data-42")
|
||||
|
||||
if err := c.StoreMPCKeyShare(nodeID, walletID, keyType, keyData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify internal storage is encrypted (not plaintext)
|
||||
secretName := "/mpc/nodes/node0/wallets/wallet-xyz/ecdsa"
|
||||
c.mu.RLock()
|
||||
raw := c.secrets[secretName]
|
||||
c.mu.RUnlock()
|
||||
if bytes.Equal(raw, keyData) {
|
||||
t.Fatal("internal storage must be encrypted, not plaintext")
|
||||
}
|
||||
|
||||
// Retrieve should return original plaintext
|
||||
retrieved, err := c.RetrieveMPCKeyShare(nodeID, walletID, keyType)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(retrieved, keyData) {
|
||||
t.Fatalf("got %q, want %q", retrieved, keyData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheck(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
if err := c.Healthcheck(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchStoreRetrieve(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
secrets := map[string][]byte{
|
||||
"key1": []byte("value1"),
|
||||
"key2": []byte("value2"),
|
||||
}
|
||||
if err := c.BatchStore(ctx, secrets); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
retrieved, err := c.BatchRetrieve(ctx, []string{"key1", "key2", "missing"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(retrieved) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(retrieved))
|
||||
}
|
||||
if !bytes.Equal(retrieved["key1"], []byte("value1")) {
|
||||
t.Fatalf("key1: got %q, want %q", retrieved["key1"], "value1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreConfig(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
type cfg struct {
|
||||
Threshold int `json:"threshold"`
|
||||
NodeID string `json:"node_id"`
|
||||
}
|
||||
original := cfg{Threshold: 2, NodeID: "node0"}
|
||||
|
||||
if err := c.StoreConfig(ctx, "node0", original); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var retrieved cfg
|
||||
if err := c.RetrieveConfig(ctx, "node0", &retrieved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retrieved.Threshold != 2 || retrieved.NodeID != "node0" {
|
||||
t.Fatalf("config mismatch: got %+v", retrieved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
if err := c.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Master key should be zeroed
|
||||
for _, b := range c.masterKey {
|
||||
if b != 0 {
|
||||
t.Fatal("master key not zeroed after Close")
|
||||
}
|
||||
}
|
||||
if c.secrets != nil {
|
||||
t.Fatal("secrets map not cleared after Close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEncryptionKey(t *testing.T) {
|
||||
key, err := GenerateEncryptionKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
t.Fatalf("expected 32-byte key, got %d bytes", len(key))
|
||||
}
|
||||
// Should not be all zeros
|
||||
allZero := true
|
||||
for _, b := range key {
|
||||
if b != 0 {
|
||||
allZero = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allZero {
|
||||
t.Fatal("generated key is all zeros")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteKey(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := c.StoreKeyShare(ctx, "w1", []byte("data")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
keys, _ := c.ListKeys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("expected 1 key, got %d", len(keys))
|
||||
}
|
||||
|
||||
if err := c.DeleteKey(keys[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
keys, _ = c.ListKeys()
|
||||
if len(keys) != 0 {
|
||||
t.Fatalf("expected 0 keys after delete, got %d", len(keys))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user