From 3c26e5449edd6a3ebd602c8b1c6233089ab516d1 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 26 Sep 2026 11:11:22 -0500 Subject: [PATCH 1/9] wip(web): limited-key sign-in, vault and in-browser identity creation Vault: AES-256-GCM under a passkey PRF (HKDF) or Argon2id (64 MiB, t=3) in IndexedDB, AAD-bound to network+identity, unlocked only in module memory with a 12 h auto-lock; the plaintext localStorage keystore is removed and legacy entries purged. Sign-in sheet: unlock, App Connect (verified on chain), create identity (mnemonic, 3-word quiz, deposit QR, asset lock ported from mint-identity and byte-identical to it, IdentityCreate carrying the limited key), import file or phrase (master key signs one IdentityUpdate registering a HIGH key bound to the dash-forge group with budget and expiry), Advanced raw key for this tab only. Settings shows the key's budget and expiry, renew, lock and forget. Co-Authored-By: Claude Opus 5.5 (1M context) --- forge-web/app/settings/page.tsx | 16 +- forge-web/components/app-header.tsx | 6 +- .../components/auth/create-identity-flow.tsx | 234 +++++++++ .../components/auth/protection-fields.tsx | 83 ++++ .../components/auth/wallet-connect-flow.tsx | 100 ++++ forge-web/components/keys-panel.tsx | 79 +++ forge-web/components/login-modal.tsx | 455 ++++++++++++++---- forge-web/components/ui/qr.tsx | 21 + forge-web/contexts/auth-context.tsx | 78 ++- forge-web/lib/auth/app-connect.ts | 191 ++++++++ forge-web/lib/auth/asset-lock.ts | 338 +++++++++++++ forge-web/lib/auth/auth-v2.test.ts | 197 ++++++++ forge-web/lib/auth/controller.ts | 306 +++++++----- forge-web/lib/auth/create-identity.ts | 213 ++++++++ forge-web/lib/auth/hd.ts | 112 +++++ forge-web/lib/auth/identity-file.ts | 49 ++ forge-web/lib/auth/index.ts | 29 +- forge-web/lib/auth/keystore.ts | 101 ---- forge-web/lib/auth/limited-key.ts | 193 ++++++++ forge-web/lib/auth/vault.ts | 347 +++++++++++++ forge-web/package.json | 2 + forge-web/pnpm-lock.yaml | 27 +- 22 files changed, 2812 insertions(+), 365 deletions(-) create mode 100644 forge-web/components/auth/create-identity-flow.tsx create mode 100644 forge-web/components/auth/protection-fields.tsx create mode 100644 forge-web/components/auth/wallet-connect-flow.tsx create mode 100644 forge-web/components/keys-panel.tsx create mode 100644 forge-web/components/ui/qr.tsx create mode 100644 forge-web/lib/auth/app-connect.ts create mode 100644 forge-web/lib/auth/asset-lock.ts create mode 100644 forge-web/lib/auth/auth-v2.test.ts create mode 100644 forge-web/lib/auth/create-identity.ts create mode 100644 forge-web/lib/auth/hd.ts delete mode 100644 forge-web/lib/auth/keystore.ts create mode 100644 forge-web/lib/auth/limited-key.ts create mode 100644 forge-web/lib/auth/vault.ts diff --git a/forge-web/app/settings/page.tsx b/forge-web/app/settings/page.tsx index 5b172af52..1bc51e426 100644 --- a/forge-web/app/settings/page.tsx +++ b/forge-web/app/settings/page.tsx @@ -3,7 +3,7 @@ /** `/settings` — account settings: identity, network, balance, the local spend ledger, sign out. */ import Link from 'next/link' -import { LogOut, Wallet } from 'lucide-react' +import { Wallet } from 'lucide-react' import { AppShell } from '@/components/app-shell' import { Button } from '@/components/ui/button' import { EmptyState } from '@/components/ui/states' @@ -13,11 +13,12 @@ import { useAuth } from '@/contexts/auth-context' import { useUiStore } from '@/hooks/use-ui-store' import { NetworkBadge } from '@/components/ui/network-badge' import { SpendPanel } from '@/components/spend-panel' +import { KeysPanel } from '@/components/keys-panel' import { creditsToDash } from '@/lib/sdk' import { balanceToDash, dashToUsd } from '@/lib/view/format' export default function SettingsPage(): JSX.Element { - const { identity, balance, logout } = useAuth() + const { identity, balance } = useAuth() const openLogin = useUiStore((s) => s.openLogin) const openTopUp = useUiStore((s) => s.openTopUp) @@ -70,15 +71,8 @@ export default function SettingsPage(): JSX.Element {
-
-
-
Sign out
-

Wipes your signing key from the browser keystore on this device.

-
- -
+

This browser's key

+

diff --git a/forge-web/components/app-header.tsx b/forge-web/components/app-header.tsx index 4fae7fdd1..c62c12064 100644 --- a/forge-web/components/app-header.tsx +++ b/forge-web/components/app-header.tsx @@ -90,7 +90,7 @@ function AccountMenu({ }: { identity: string balance: string | null - onLogout: () => void + onLogout: (forget?: boolean) => void }): JSX.Element { const [open, setOpen] = useState(false) const ref = useRef(null) @@ -151,11 +151,11 @@ function AccountMenu({ role="menuitem" onClick={() => { setOpen(false) - onLogout() + onLogout(false) }} className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-dense text-danger hover:bg-danger/5" > - Sign out + Lock & sign out ) : null} diff --git a/forge-web/components/auth/create-identity-flow.tsx b/forge-web/components/auth/create-identity-flow.tsx new file mode 100644 index 000000000..805c3ef4a --- /dev/null +++ b/forge-web/components/auth/create-identity-flow.tsx @@ -0,0 +1,234 @@ +'use client' + +/** + * "Create a new identity" (`ux-dx-spec.md` §2.2 tile 2, §2.5): + * 1. twelve words, shown once, with the honest backup warning; + * 2. a quiz on three of them (the flow does not continue until they match); + * 3. how to protect this browser's key (passkey / passphrase); + * 4. the deposit QR + address (any Dash wallet; the faucet on dev networks), watched through + * the block explorer; then the asset lock, its proof, and one IdentityCreate that also + * registers this browser's limited key. + * A closed tab resumes from step 4 once the same words are typed in again. + */ + +import { useEffect, useMemo, useState } from 'react' +import { AlertTriangle, Check, Loader2 } from 'lucide-react' +import { useAuth } from '@/contexts/auth-context' +import { Button } from '@/components/ui/button' +import { Field, Input, Textarea } from '@/components/ui/input' +import { Qr } from '@/components/ui/qr' +import { useProtection } from '@/components/auth/protection-fields' +import { faucetUrl } from '@/components/top-up-sheet' +import { ACTIVE_NETWORK, DEFAULT_NETWORK } from '@/lib/constants' +import { errorMessage } from '@/lib/utils' + +type Step = 'words' | 'quiz' | 'protect' | 'fund' | 'resume' + +const STAGE_TEXT: Readonly> = { + 'waiting-deposit': 'Watching for your deposit…', + locking: 'Locking the deposit for Platform…', + proving: 'Waiting for the lock to be provable…', + registering: 'Registering your identity…', + verifying: 'Checking your browser key on Platform…', +} + +export function CreateIdentityFlow({ onDone }: { onDone: () => void }): JSX.Element { + const { adoptLimitedKey } = useAuth() + const [step, setStep] = useState('words') + const [mnemonic, setMnemonic] = useState(null) + const [positions, setPositions] = useState([]) + const [answers, setAnswers] = useState(['', '', '']) + const [address, setAddress] = useState(null) + const [stage, setStage] = useState(null) + const [seen, setSeen] = useState(0) + const [error, setError] = useState(null) + const [resumeWords, setResumeWords] = useState('') + const { fields, protection, problem } = useProtection('new identity') + const words = useMemo(() => mnemonic?.split(' ') ?? [], [mnemonic]) + + useEffect(() => { + let cancelled = false + void (async () => { + const { readCreationJournal } = await import('@/lib/auth/create-identity') + const j = await readCreationJournal(DEFAULT_NETWORK) + if (cancelled) return + if (j) { + setAddress(j.depositAddress) + setStep('resume') + return + } + const { newMnemonic, quizPositions } = await import('@/lib/auth/hd') + const m = await newMnemonic() + if (cancelled) return + setMnemonic(m) + setPositions(quizPositions(12, 3)) + })() + return () => { + cancelled = true + } + }, []) + + // Forget the words when the flow closes. + useEffect(() => () => setMnemonic(null), []) + + const quizOk = positions.length === 3 && positions.every((p, i) => (answers[i] ?? '').trim().toLowerCase() === words[p]) + + const run = async (m: string): Promise => { + if (!protection) return + setError(null) + const controller = new AbortController() + try { + const { evoSdkService } = await import('@/lib/sdk') + const { createIdentityFromMnemonic, depositAddressOf, MIN_DEPOSIT_DUFFS } = await import('@/lib/auth/create-identity') + const v2 = ACTIVE_NETWORK.v2 + if (!v2) throw new Error('forge-v2 is not deployed here') + setAddress(await depositAddressOf(m, DEFAULT_NETWORK)) + setStep('fund') + const { identityId, key } = await createIdentityFromMnemonic(evoSdkService.getSdk(), { + network: DEFAULT_NETWORK, + mnemonic: m, + group: v2.group, + minDepositDuffs: MIN_DEPOSIT_DUFFS, + signal: controller.signal, + onStage: (s, detail) => setStage(detail ?? STAGE_TEXT[s] ?? s), + onDeposit: setSeen, + }) + await adoptLimitedKey(identityId, key, protection) + setMnemonic(null) + onDone() + } catch (e) { + setError(errorMessage(e)) + } + } + + if (step === 'resume') { + return ( +

+

+ An identity creation is in progress on this device (deposit address {address}). Type + your 12 words to finish it. +

+ +