From d64c316240e5f5876c22b25b64a0be3eb8ed6cce Mon Sep 17 00:00:00 2001 From: ssmithx Date: Thu, 6 Aug 2026 18:37:24 +0000 Subject: [PATCH] Replace 'Login as Guest' with 'Create New Account' + nsec backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The throwaway guest identity was disposable by design — lost the session, lost the account, no way to recover it, not even shown the key to write down. Replaced with a real onboarding flow: - generateNewAccount() (lib/nsec.ts) creates a fresh keypair client-side and returns both nsec and npub for display, reusing the exact same login mechanism as the existing 'paste an nsec' path (loginWithNsec) — no new backend surface. - LoginView.vue now has a backup step between 'Create New Account' and actually being signed in: shows the npub (safe to share) and nsec (never share), a copy-to-clipboard button, and an explicit 'this will not be shown again' warning before the player confirms and logs in. - Removed lib/guest.ts and the loginAsGuest store action entirely — fully superseded, not kept alongside. Verified the nip19 encode/decode round-trip (nsec -> decode -> same pubkey) and the full generate-then-login flow work correctly before deploying. --- frontend/src/lib/guest.ts | 30 ------ frontend/src/lib/nsec.ts | 20 +++- frontend/src/stores/auth.ts | 29 ++---- frontend/src/views/LoginView.vue | 168 ++++++++++++++++++++++--------- 4 files changed, 143 insertions(+), 104 deletions(-) delete mode 100644 frontend/src/lib/guest.ts diff --git a/frontend/src/lib/guest.ts b/frontend/src/lib/guest.ts deleted file mode 100644 index 6da154b..0000000 --- a/frontend/src/lib/guest.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Guest login: a freshly generated, throwaway nostr identity, signed -// client-side — no NIP-07 extension needed, no nsec to paste. Same -// never-persisted-key posture as lib/nsec.ts (the key exists in page memory -// only long enough to sign this one login event), except here nothing was -// ever typed by a human, so there's no "don't paste your real key" risk to -// warn about — it's random and disposable by construction. -// -// Tradeoff worth knowing: since the key is never saved anywhere (not even -// as an nsec the player could copy down), losing the session cookie means -// losing this identity for good — a fresh "Login as Guest" click makes a -// brand new one, it can't recover the old pubkey's claims. -import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; - -/** Generates a random identity and returns a signed NIP-98 Authorization header for it. */ -export function buildGuestNip98Header(url: string, method: string): string { - const sk = generateSecretKey(); - const event = finalizeEvent( - { - kind: 27235, - created_at: Math.floor(Date.now() / 1000), - content: '', - tags: [ - ['u', url], - ['method', method], - ], - }, - sk, - ); - return `Nostr ${btoa(JSON.stringify(event))}`; -} diff --git a/frontend/src/lib/nsec.ts b/frontend/src/lib/nsec.ts index d8136b8..c918148 100644 --- a/frontend/src/lib/nsec.ts +++ b/frontend/src/lib/nsec.ts @@ -5,9 +5,27 @@ // the NIP-07 path also never retains key material — only the resulting // session cookie survives. import { nip19 } from 'nostr-tools'; -import { finalizeEvent, getPublicKey } from 'nostr-tools/pure'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import type { SignedEvent, UnsignedEvent } from './nip07'; +export interface NewAccount { + nsec: string; + npub: string; +} + +/** + * Generates a brand-new identity entirely client-side. Returned so the + * caller can show the player their nsec to back up *before* logging in with + * it (see views/LoginView.vue) — unlike the raw-nsec paste flow above, this + * key was never at risk of being someone's real identity, but it's still + * the player's only copy, so it's worth the same "here it is, save it" + * treatment real wallet software gives a freshly generated seed. + */ +export function generateNewAccount(): NewAccount { + const sk = generateSecretKey(); + return { nsec: nip19.nsecEncode(sk), npub: nip19.npubEncode(getPublicKey(sk)) }; +} + export function isValidNsec(nsec: string): boolean { try { return nip19.decode(nsec.trim()).type === 'nsec'; diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 9993093..2bc773d 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -2,7 +2,6 @@ import { defineStore } from 'pinia'; import { api, apiUrl, ApiError } from '../lib/api'; import { buildNip98Header, hasNip07 } from '../lib/nip07'; import { buildNip98HeaderWithNsec, isValidNsec } from '../lib/nsec'; -import { buildGuestNip98Header } from '../lib/guest'; export type Team = 'orange' | 'green'; @@ -46,11 +45,12 @@ export const useAuthStore = defineStore('auth', { this.team = res.team; }, /** - * Unsafe fallback login: signs with a raw pasted nsec instead of a NIP-07 - * extension. The key is used once, in memory, to sign this request and - * is never persisted — but it does pass through this page's JS, which a - * real identity's key never should. Explicit user-requested convenience - * feature, not a recommended default. + * Signs in with a raw nsec — either pasted by the player (the "unsafe" + * fallback for testing/kiosk use, no extension needed) or one just + * generated for them by "Create New Account" (lib/nsec.ts#generateNewAccount) + * after they've had a chance to back it up. Same code path either way: + * the key is used once, in memory, to sign this request and is never + * persisted here — only the resulting session cookie survives. */ async loginWithNsec(nsec: string) { if (!isValidNsec(nsec)) throw new Error('That doesn\'t look like a valid nsec.'); @@ -64,23 +64,6 @@ export const useAuthStore = defineStore('auth', { this.pubkey = res.pubkey; this.team = res.team; }, - /** - * A freshly generated, throwaway identity — no extension, no key to - * paste. The key only exists in page memory for this one signing call; - * losing the session means losing this identity for good, since nothing - * is saved for the player to recover it with. - */ - async loginAsGuest() { - const url = apiUrl('/api/auth/login'); - const header = buildGuestNip98Header(url, 'POST'); - const res = await api.post<{ pubkey: string; team: Team | null }>( - '/api/auth/login', - undefined, - { authorization: header }, - ); - this.pubkey = res.pubkey; - this.team = res.team; - }, async logout() { await api.post('/api/auth/logout'); this.pubkey = null; diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 130c5ee..516f3b4 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -2,6 +2,7 @@ import { ref } from 'vue'; import { useAuthStore } from '../stores/auth'; import { hasNip07 } from '../lib/nip07'; +import { generateNewAccount, type NewAccount } from '../lib/nsec'; const auth = useAuthStore(); const error = ref(null); @@ -9,6 +10,9 @@ const loggingIn = ref(false); const showNsecForm = ref(false); const nsecInput = ref(''); +const newAccount = ref(null); +const copied = ref(false); + async function handleLogin() { error.value = null; loggingIn.value = true; @@ -21,11 +25,29 @@ async function handleLogin() { } } -async function handleGuestLogin() { +function handleCreateAccount() { + error.value = null; + copied.value = false; + newAccount.value = generateNewAccount(); +} + +async function copyNsec() { + if (!newAccount.value) return; + try { + await navigator.clipboard.writeText(newAccount.value.nsec); + copied.value = true; + } catch { + // Clipboard API can be denied/unavailable — the field is still selectable by hand. + } +} + +async function confirmNewAccount() { + if (!newAccount.value) return; error.value = null; loggingIn.value = true; try { - await auth.loginAsGuest(); + await auth.loginWithNsec(newAccount.value.nsec); + newAccount.value = null; } catch (err) { error.value = err instanceof Error ? err.message : String(err); } finally { @@ -54,58 +76,104 @@ async function handleNsecLogin() { style="background: radial-gradient(circle at 50% 40%, rgba(0,255,242,0.08), transparent 60%)" > -

REGRESS

-

- Real-world nodes. Two factions. Claim them for orange or green — sign in with Nostr to jack in. -

- -

- No NIP-07 extension detected — install Alby or nos2x, or open this app from the Archipelago dashboard. -

- - -

- Jumps in with a fresh throwaway identity — no extension, nothing to install. It's disposable: if you lose this - session there's no way to get the same identity back, so don't rely on it for territory you care about keeping. -

- - - -
-

- ⚠ UNSAFE: your private key is decoded and used to sign right in this - page. Only paste a throwaway/test nsec — never your real identity's. - The key isn't saved anywhere; it's used once and discarded. + + + +

{{ error }}