From 802aa169db002d87b7dac65e11d9a6a0d8e54264 Mon Sep 17 00:00:00 2001 From: thephez Date: Mon, 20 Jul 2026 15:35:40 -0400 Subject: [PATCH 1/6] feat(dashnote): harden WIF login against ambiguous key-to-identity resolution Treat a WIF as a credential rather than an identity identifier. When a key resolves to multiple identities, refuse to list or guess among them: fall back to paginated `byNonUniquePublicKeyHash`, and require the user to supply the exact intended identity ID, which is fetched directly and verified. Match ECDSA keys by full public key or ECDSA HASH160 (hex or base64) while rejecting other HASH160 key types. Persist the identity ID and DPNS label only when the remember checkbox is enabled. --- example-apps/dashnote/CLAUDE.md | 2 +- example-apps/dashnote/README.md | 26 +- .../dashnote/src/components/AppShell.tsx | 2 +- .../dashnote/src/components/LoginModal.tsx | 78 +++++- .../dashnote/src/dash/loginWithPrivateKey.ts | 233 ++++++++++++++---- example-apps/dashnote/src/dash/types.ts | 5 + .../dashnote/src/hooks/useWifPreview.ts | 24 +- .../dashnote/src/session/SessionContext.tsx | 13 +- .../dashnote/test/LoginModal.test.tsx | 79 ++++++ .../dashnote/test/SessionContext.test.tsx | 22 ++ .../dashnote/test/loginWithPrivateKey.test.ts | 163 ++++++++++++ .../dashnote/test/useWifPreview.test.tsx | 44 ++++ 12 files changed, 615 insertions(+), 76 deletions(-) diff --git a/example-apps/dashnote/CLAUDE.md b/example-apps/dashnote/CLAUDE.md index ec48bd1d..fb2583a2 100644 --- a/example-apps/dashnote/CLAUDE.md +++ b/example-apps/dashnote/CLAUDE.md @@ -21,7 +21,7 @@ React + TypeScript + Vite app for personal notes on Dash Platform testnet. Notes - **[src/dash/](src/dash/)** — one file per Platform SDK operation: [createNote.ts](src/dash/createNote.ts), [updateNote.ts](src/dash/updateNote.ts), [deleteNote.ts](src/dash/deleteNote.ts), [queries.ts](src/dash/queries.ts), [contract.ts](src/dash/contract.ts), [loginWithPrivateKey.ts](src/dash/loginWithPrivateKey.ts), [resolveDpnsName.ts](src/dash/resolveDpnsName.ts). Each exports an async function with a leading JSDoc block naming the SDK method it wraps. No hooks, no UI wrappers — the SDK call is the function. Files in this folder always reference `@dashevo/evo-sdk` (or shared core re-exports / SDK-shape types). App-shell utilities that don't touch the SDK live in [src/lib/](src/lib/) instead. Two deferred-import loaders live alongside the operations: [sdkModule.ts](src/dash/sdkModule.ts) caches a `import("@dashevo/evo-sdk")` so value imports (`Document`, `DataContract`, `Identifier`) stay out of the entry chunk, and [types.ts](src/dash/types.ts) holds the shared SDK type aliases used everywhere. - **Shared SDK core** — [src/dash/client.ts](src/dash/client.ts) and [src/dash/keyManager.ts](src/dash/keyManager.ts) re-export `createClient` and `IdentityKeyManager` from `../../../../setupDashClient-core.mjs` (the canonical browser-safe core at the host repo root). No vendoring. The `@dashevo/evo-sdk` bare specifier is aliased in [vite.config.ts](vite.config.ts) to this app's locally installed browser bundle so the shared core resolves the SDK from here. -- **[src/session/](src/session/)** — `SessionContext.tsx` provides the context (`status`, `error`, `sdk`, `keyManager`, `identityId`, `contractId`, `rememberedIdentityId`, `dpnsName`, `setContractId`, `log`, `login`, `enterReadOnly`, `viewAsRemembered`, `forgetIdentity`, `logout`) and `useSession.ts` is the consumer hook. `login(secret, options)` auto-detects mnemonic vs WIF via [detectSecretShape](src/lib/detectSecretShape.ts): mnemonics use `IdentityKeyManager` (DIP-13 derivation with optional `identityIndex`); WIF dispatches to `loginWithPrivateKey` (dynamic-imported to keep its SDK dependency off the app shell), which produces a single-key manager via [keyManagerFromKey.ts](src/session/keyManagerFromKey.ts). Mnemonic + WIF live only in the keyManager closure — never in state, never in localStorage. `SessionContext` has its own small loader for the shared core (`createClient`, `IdentityKeyManager`); the `@dashevo/evo-sdk` value-import loader is the separate [sdkModule.ts](src/dash/sdkModule.ts) used by `createNote` / `updateNote` / `deleteNote` / `contract.ts`. Both are dynamically imported on first use so the ~8MB WASM bundle doesn't block initial paint. After a successful login the context also resolves the identity's DPNS name via [resolveDpnsName.ts](src/dash/resolveDpnsName.ts) and persists it alongside the remembered identity ID. +- **[src/session/](src/session/)** — `SessionContext.tsx` provides the context (`status`, `error`, `sdk`, `keyManager`, `identityId`, `contractId`, `rememberedIdentityId`, `dpnsName`, `setContractId`, `log`, `login`, `enterReadOnly`, `viewAsRemembered`, `forgetIdentity`, `logout`) and `useSession.ts` is the consumer hook. `login(secret, options)` auto-detects mnemonic vs WIF via [detectSecretShape](src/lib/detectSecretShape.ts): mnemonics use `IdentityKeyManager` (DIP-13 derivation with optional `identityIndex`); WIF dispatches to `loginWithPrivateKey` (dynamic-imported to keep its SDK dependency off the app shell), which produces a single-key manager via [keyManagerFromKey.ts](src/session/keyManagerFromKey.ts). WIF resolution falls back to paginated `byNonUniquePublicKeyHash`; ambiguous keys require an exact `expectedIdentityId`, which is fetched directly and verified rather than resolved by result order. Mnemonic + WIF live only in the keyManager closure — never in state, never in localStorage. `SessionContext` has its own small loader for the shared core (`createClient`, `IdentityKeyManager`); the `@dashevo/evo-sdk` value-import loader is the separate [sdkModule.ts](src/dash/sdkModule.ts) used by `createNote` / `updateNote` / `deleteNote` / `contract.ts`. Both are dynamically imported on first use so the ~8MB WASM bundle doesn't block initial paint. After a successful login the context also resolves the identity's DPNS name via [resolveDpnsName.ts](src/dash/resolveDpnsName.ts) and persists it alongside the remembered identity ID only when the remember checkbox is enabled. - **[src/components/](src/components/)** — standard React. Modals/panels call `src/dash/` functions directly. Shell: [AppShell.tsx](src/components/AppShell.tsx), [Tabs.tsx](src/components/Tabs.tsx), [NavButton.tsx](src/components/NavButton.tsx), [HowItWorks.tsx](src/components/HowItWorks.tsx). Workspace: [NotesWorkspace.tsx](src/components/NotesWorkspace.tsx) (two-pane list + editor with optimistic cache + background revalidation), [NoteList.tsx](src/components/NoteList.tsx), [NoteEditor.tsx](src/components/NoteEditor.tsx) (fused title/body editor with byte-budget progress bar), [DeleteNoteModal.tsx](src/components/DeleteNoteModal.tsx) (confirmation modal for deletes). Auth + settings: [LoginModal.tsx](src/components/LoginModal.tsx) (mnemonic-or-WIF paste flow with remembered-identity panel), [SettingsPanel.tsx](src/components/SettingsPanel.tsx) (settings tab — contract paste / register, identity card, local-data controls), [IdentityCard.tsx](src/components/IdentityCard.tsx). Shared primitives: [Modal.tsx](src/components/Modal.tsx), [OperationResultNotice.tsx](src/components/OperationResultNotice.tsx). - **[src/hooks/](src/hooks/)** — app-specific hooks: [useTheme.ts](src/hooks/useTheme.ts) (dark mode toggle), [useMediaQuery.ts](src/hooks/useMediaQuery.ts) (`window.matchMedia` via `useSyncExternalStore`), [useContractRegistration.ts](src/hooks/useContractRegistration.ts) (Settings flow for paste-or-register), [useWifPreview.ts](src/hooks/useWifPreview.ts) (eager identity + key-fitness preview on WIF paste in the login modal). - **[src/lib/](src/lib/)** — pure utilities, no SDK references: [logger.ts](src/lib/logger.ts) (`Logger` type + `errorMessage(err)`), [notesCache.ts](src/lib/notesCache.ts) (localStorage-backed note list keyed by identity + contract + network), [rememberedIdentity.ts](src/lib/rememberedIdentity.ts) (last-logged-in identity ID + DPNS name for read-only browse), [fieldLimits.ts](src/lib/fieldLimits.ts) (UTF-8 byte counters for title/message), [format.ts](src/lib/format.ts), [detectSecretShape.ts](src/lib/detectSecretShape.ts) (mnemonic-vs-WIF classifier used by `login` and the login modal preview). diff --git a/example-apps/dashnote/README.md b/example-apps/dashnote/README.md index 6f858c97..83a228a3 100644 --- a/example-apps/dashnote/README.md +++ b/example-apps/dashnote/README.md @@ -63,18 +63,18 @@ npm run preview # Serve the production build Every SDK call lives in its own file under [`src/dash/`](./src/dash/). Open the file to see the full implementation with a JSDoc header naming the SDK method it wraps. -| Operation | File | SDK method | -| ---------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------- | -| Connect to testnet | [`src/dash/client.ts`](./src/dash/client.ts) | `EvoSDK.testnetTrusted()` + `sdk.connect()` | -| Derive identity keys | [`src/dash/keyManager.ts`](./src/dash/keyManager.ts) | wallet/key derivation helpers (mnemonic path) | -| Login by private key | [`src/dash/loginWithPrivateKey.ts`](./src/dash/loginWithPrivateKey.ts) | `PrivateKey.fromWIF` + `sdk.identities.byPublicKeyHash` | -| Resolve DPNS name | [`src/dash/resolveDpnsName.ts`](./src/dash/resolveDpnsName.ts) | `sdk.dpns.username` | -| Register note contract | [`src/dash/contract.ts`](./src/dash/contract.ts) | `sdk.contracts.publish` | -| Create a note | [`src/dash/createNote.ts`](./src/dash/createNote.ts) | `sdk.documents.create` | -| Update a note | [`src/dash/updateNote.ts`](./src/dash/updateNote.ts) | `sdk.documents.get` + `sdk.documents.replace` | -| Delete a note | [`src/dash/deleteNote.ts`](./src/dash/deleteNote.ts) | `sdk.documents.delete` | -| List my notes | [`src/dash/queries.ts`](./src/dash/queries.ts) | `sdk.documents.query` | -| Get one note | [`src/dash/queries.ts`](./src/dash/queries.ts) | `sdk.documents.get` | +| Operation | File | SDK method | +| ---------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Connect to testnet | [`src/dash/client.ts`](./src/dash/client.ts) | `EvoSDK.testnetTrusted()` + `sdk.connect()` | +| Derive identity keys | [`src/dash/keyManager.ts`](./src/dash/keyManager.ts) | wallet/key derivation helpers (mnemonic path) | +| Login by private key | [`src/dash/loginWithPrivateKey.ts`](./src/dash/loginWithPrivateKey.ts) | `PrivateKey.fromWIF` + `sdk.identities.fetch` / `byPublicKeyHash` / `byNonUniquePublicKeyHash` | +| Resolve DPNS name | [`src/dash/resolveDpnsName.ts`](./src/dash/resolveDpnsName.ts) | `sdk.dpns.username` | +| Register note contract | [`src/dash/contract.ts`](./src/dash/contract.ts) | `sdk.contracts.publish` | +| Create a note | [`src/dash/createNote.ts`](./src/dash/createNote.ts) | `sdk.documents.create` | +| Update a note | [`src/dash/updateNote.ts`](./src/dash/updateNote.ts) | `sdk.documents.get` + `sdk.documents.replace` | +| Delete a note | [`src/dash/deleteNote.ts`](./src/dash/deleteNote.ts) | `sdk.documents.delete` | +| List my notes | [`src/dash/queries.ts`](./src/dash/queries.ts) | `sdk.documents.query` | +| Get one note | [`src/dash/queries.ts`](./src/dash/queries.ts) | `sdk.documents.get` | Update flow always fetches the document first to read its current revision, then submits a replace with `revision = BigInt(existing.revision ?? 0) + 1n`. Replays without bumping the revision are rejected by the state transition. @@ -87,6 +87,8 @@ Supporting files: - **[`src/lib/rememberedIdentity.ts`](./src/lib/rememberedIdentity.ts)** — last-logged-in identity ID plus its DPNS name (when one was resolved) for read-only browse. Never stores the mnemonic or WIF. - **[`src/lib/detectSecretShape.ts`](./src/lib/detectSecretShape.ts)** — cheap "mnemonic vs WIF" classifier used by `SessionContext.login` and the login modal's eager WIF preview. +WIF login treats the private key as a credential, not an identity identifier. The resolver accepts ECDSA key data encoded as either a full public key or ECDSA HASH160 value (hex or base64), while rejecting other HASH160 key types. If one key is associated with multiple identities, Dashnote does not list or guess among them: the login modal requires the full intended identity ID, fetches that identity directly, and verifies that the WIF is an enabled HIGH/CRITICAL authentication key for it. The identity ID and optional DPNS label are persisted only when **Remember this identity** is checked; private keys are never persisted. + ## Reading the codebase Recommended order for understanding how the app works: diff --git a/example-apps/dashnote/src/components/AppShell.tsx b/example-apps/dashnote/src/components/AppShell.tsx index d438d547..36b9f3d1 100644 --- a/example-apps/dashnote/src/components/AppShell.tsx +++ b/example-apps/dashnote/src/components/AppShell.tsx @@ -267,7 +267,7 @@ export function AppShell({ }`} > (null); const showRememberedPanel = Boolean( session.rememberedIdentityId && !useDifferentIdentity, ); @@ -31,10 +33,29 @@ export function LoginModal({ open, onClose }: LoginModalProps) { [secret], ); const isWifInput = secretShape === "wif"; - const wifPreview = useWifPreview(session.sdk, secret, isWifInput); + const rememberedExpectedIdentityId = showRememberedPanel + ? (session.rememberedIdentityId ?? undefined) + : undefined; + const hasIdentityPrompt = + isWifInput && + (ambiguousWif === secret.trim() || expectedIdentityId.length > 0); + const effectiveExpectedIdentityId = + rememberedExpectedIdentityId ?? + (hasIdentityPrompt ? expectedIdentityId.trim() || undefined : undefined); + const wifPreview = useWifPreview( + session.sdk, + secret, + isWifInput, + effectiveExpectedIdentityId, + ); + const needsIdentityId = + hasIdentityPrompt || wifPreview.status === "ambiguous"; const previewBlocksLogin = wifPreview.status === "wrong-purpose" || - wifPreview.status === "key-disabled"; + wifPreview.status === "key-disabled" || + wifPreview.status === "identity-mismatch" || + wifPreview.status === "ambiguous" || + (needsIdentityId && wifPreview.status !== "resolved"); useEffect(() => { if (open) { @@ -42,6 +63,8 @@ export function LoginModal({ open, onClose }: LoginModalProps) { setUseDifferentIdentity(false); setError(null); setSecret(""); + setExpectedIdentityId(""); + setAmbiguousWif(null); } }, [open]); @@ -54,11 +77,17 @@ export function LoginModal({ open, onClose }: LoginModalProps) { await session.login(secret, { identityIndex: Number.isNaN(index) ? 0 : index, rememberMe, + ...(isWifInput && effectiveExpectedIdentityId + ? { expectedIdentityId: effectiveExpectedIdentityId } + : {}), }); setSecret(""); onClose(); } catch (err) { setError(errorMessage(err)); + if (err instanceof Error && err.name === "AmbiguousIdentityError") { + setAmbiguousWif(secret.trim()); + } } finally { setSubmitting(false); } @@ -143,7 +172,11 @@ export function LoginModal({ open, onClose }: LoginModalProps) { autoComplete="off" required value={secret} - onChange={(event) => setSecret(event.target.value)} + onChange={(event) => { + setSecret(event.target.value); + setExpectedIdentityId(""); + setAmbiguousWif(null); + }} placeholder="Mnemonic phrase or WIF private key (high/critical)" className="rounded-md border border-line bg-bg px-3 py-2 text-[13px] text-ink outline-none transition focus:border-accent-dim" /> @@ -166,6 +199,18 @@ export function LoginModal({ open, onClose }: LoginModalProps) { )} + {wifPreview.status === "ambiguous" && ( + + This key is associated with multiple identities. Enter the + full identity ID you intend to use. + + )} + {wifPreview.status === "identity-mismatch" && ( + + This key could not be verified as an eligible authentication + key for that identity ID. + + )} {wifPreview.status === "wrong-purpose" && ( Found identity{" "} @@ -196,6 +241,27 @@ export function LoginModal({ open, onClose }: LoginModalProps) { )} + {needsIdentityId && !rememberedExpectedIdentityId && ( + + )} +

@@ -133,6 +148,29 @@ export function LoginModal({ open, onClose }: LoginModalProps) {

+ {needsIdentityId && ( +
+ + { + setExpectedIdentityId(event.target.value); + setError(null); + }} + placeholder="Full Dash Platform identity ID" + /> +

+ This key is associated with multiple identities. Token + operations will be performed as this exact identity; TokenOps + does not list identities associated with the key. +

+
+ )} + {!isWifInput && ( <>