Context
The desktop app (Electron) has no vault-based auth. Users can only use local daemon accounts. We want to let users sign up/log in via the vault from the desktop app, with passkey/password auth happening in the system browser.
Why browser-based? Passkeys (WebAuthn + PRF) require the vault's registered origin. Electron runs on file:///localhost — can't do WebAuthn for the vault's RP ID. Opening the system browser is the standard native app auth pattern (VS Code, Slack, Spotify, GitHub CLI).
Key insight: hmauth.ts already allows HTTP localhost for both validateClientId (line 202) and validateRedirectUri (line 228). The delegation callback data is entirely self-contained in URL params (no cookies needed). Zero protocol changes required.
Approach: Localhost HTTP Callback (RFC 8252)
Desktop App System Browser Vault
───────────── ────────────── ─────
1. User clicks "Sign in"
2. Generate Ed25519 session key
3. Build delegation URL
4. shell.openExternal(url) ──→ Opens vault page ──→ Receives request
5. User authenticates Validates proof
(passkey/password)
6. User consents Signs capability
7. Vault redirects ←──────────── Redirects to
to localhost localhost:{port}
8. HTTP server receives ←── Browser hits
callback at /auth/callback localhost:{port}/auth/callback
9. Validate state + capability
10. Persist session (safeStorage)
11. BrowserWindow.focus()
12. IPC → renderer updates 13. Browser shows
"Success! Return to app"
Why not alternatives?
Deep link (hm://): Requires hmauth changes, URL length limits, unreliable protocol registration
Polling: New vault API endpoints, latency, more complex
UX Design (Nielsen Heuristics)
Implementation
Decisions (from user input)
Vault URL: User-configurable setting, same pattern as gateway URL
Session persistence: Yes, persist across restarts via safeStorage
Daemon integration: Deferred — will investigate separately
Files to Modify
New Files
Reuse Existing Code
Vault URL Setting (mirrors gateway URL pattern)
Follow the exact architecture from app-gateway-settings.ts:
settings.tsx (VaultSettings component)
↓
vault-auth.ts (useVaultUrl, useSetVaultUrl hooks — React Query)
↓
tRPC Client → IPC → Main Process
↓
app-vault-auth.ts (getVaultUrl/setVaultUrl procedures)
↓
electron-store (key: 'VaultUrl')
Store key: 'VaultUrl' in AppStore
Default: DEFAULT_VAULT_URL from @shm/shared/constants.ts (currently WEB_IDENTITY_ORIGIN)
Query key: add VAULT_URL to @shm/shared/models/query-keys
Session Key Management
Generation (main process):
import { ed25519 } from '@noble/curves/ed25519'
import { randomBytes } from 'node:crypto'
const seed = randomBytes(32)
const publicKey = ed25519.getPublicKey(seed)
const principal = hmauth.principalEncode(publicKey)
Proof signing (same Ed25519 as web app, noble instead of Web Crypto):
const unsignedUrl = buildDelegationUrl(/* without proof */)
const signature = ed25519.sign(new TextEncoder().encode(unsignedUrl), seed)
const proof = base64.encode(signature)
// Append &proof={proof} to URL
Persistence (survives restarts):
import { safeStorage } from 'electron'
// Save
const encrypted = safeStorage.encryptString(Buffer.from(seed).toString('hex'))
fs.writeFileSync(sessionPath, encrypted)
// Load
const encrypted = fs.readFileSync(sessionPath)
const hex = safeStorage.decryptString(encrypted)
const seed = Buffer.from(hex, 'hex')
Callback Processing (/auth/callback route)
Add to app-http-server.ts before the /api/ path check:
if (pathname === '/auth/callback') {
const result = handleAuthCallback(url.searchParams)
// Returns { status, html } or { status, error }
res.writeHead(result.status, {'Content-Type': 'text/html'})
res.end(result.html)
return
}
In app-vault-auth.ts, handleAuthCallback():
Extract data and state from query params
Validate state matches stored pending auth state
Base64-decode → gunzip → CBOR-decode the data param
Verify capability: CID integrity, signature, delegate matches our session key
Persist: session key (safeStorage) + capability blob + account principal
Notify renderer via IPC: { type: 'vaultAuthComplete', accountPrincipal }
BrowserWindow.getAllWindows()[0]?.focus()
Return success HTML page
Success Page (returned to browser)
<!DOCTYPE html>
<html><head><title>Signed in</title>
<style>body{font-family:system-ui;display:flex;justify-content:center;
align-items:center;height:100vh;margin:0;color:#333}
.box{text-align:center}</style></head>
<body><div class="box">
<h1>Signed in successfully</h1>
<p>You can close this tab and return to the desktop app.</p>
</div>
<script>setTimeout(()=>window.close(),2000)</script>
</body></html>
tRPC API (vaultAuthApi)
export const vaultAuthApi = t.router({
// Vault URL setting
getVaultUrl: t.procedure.query(() => vaultUrl),
setVaultUrl: t.procedure.input(z.string().url()).mutation(({ input }) => {
writeVaultUrl(input)
}),
// Auth flow
startAuth: t.procedure.mutation(async () => {
// 1. Generate session key
// 2. Build delegation URL with redirect_uri=http://localhost:{port}/auth/callback
// 3. Store pending auth state (state nonce, seed)
// 4. shell.openExternal(delegationUrl)
// 5. Return { started: true }
}),
cancelAuth: t.procedure.mutation(() => {
// Clear pending auth state
}),
// Session state
getAuthState: t.procedure.query(() => {
// Returns: { status: 'none' | 'pending' | 'authenticated', account?: {...} }
}),
logout: t.procedure.mutation(() => {
// Clear stored session key + capability
}),
})
Deferred (Not In Scope)
Daemon integration: How to register the delegation with the daemon for P2P operations. Will investigate the daemon's gRPC API separately.
Account switching: Supporting multiple vault-delegated accounts.
Session refresh: Auto-refreshing expired sessions (24h vault session TTL).
Blob publishing: Publishing capability blobs to identity origin after auth.
Verification
Manual test: Click "Sign in" → browser opens vault → authenticate with passkey/password → consent → desktop receives delegation → account shows in UI
Cancel test: Start auth → click Cancel → state resets cleanly
Persistence test: Sign in → restart app → session still active
Error cases: Invalid vault URL, vault unreachable, callback with wrong state, timeout
Settings test: Change vault URL in settings → next auth uses new URL