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:
2025-12-27 14:17:51 -08:00
parent c98d565859
commit 351c0d253e
13 changed files with 931 additions and 285 deletions
+2 -1
View File
@@ -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
View File
@@ -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>
+2 -1
View File
@@ -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')
+147 -72
View File
@@ -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>
)
+49 -26
View File
@@ -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>
+10 -3
View File
@@ -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"
+12
View File
@@ -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 {
+2
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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 {
+69 -24
View File
@@ -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
View File
@@ -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
}
+208
View File
@@ -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))
}
}