From fed2ff135b1309fabfcf848fa2e3fb7732135874 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 1 Nov 2026 13:48:07 +0700 Subject: [PATCH] security: keep nsec session-only and fix direct links --- AGENTS.md | 11 ++ src/i18n/en.ts | 2 +- src/i18n/es.ts | 2 +- src/lib/nostrLogin.test.tsx | 45 +++++++ src/lib/nostrLogin.ts | 253 ++++++++++++++++++++++++++++++++++++ src/main.tsx | 17 +-- 6 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 src/lib/nostrLogin.test.tsx create mode 100644 src/lib/nostrLogin.ts diff --git a/AGENTS.md b/AGENTS.md index 9d0bd43..f705d45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1799,6 +1799,17 @@ session with **no other memory of this project**. So: `public/images/screenshots/{map,spot,community}.png` (1280×800, quantised — captured with headless Chromium; regenerate with a CDP harness if the UI changes materially). +- **Nsec storage hardening (2026-11-01):** `src/lib/nostrLogin.ts` replaces the + package login provider. Non-secret logins stay in localStorage; **nsec logins + are sessionStorage-only** and are also stripped from localStorage on the next + write. A tab reload keeps the nsec session; closing the browser/app requires + re-entry. Use Amber or a bunker to stay signed in. `src/lib/nostrLogin.test.tsx` + asserts raw nsec never lands in persistent localStorage. Do not import + `@nostrify/react/login` directly again — use `@/lib/nostrLogin`. +- **Direct-link boot rewrite (2026-11-01):** `main.tsx` now rewrites any + non-root pathname without a hash into `/#` before React mounts, so + `/spot/...`, `/crew/...`, `/event/...`, `/listing/...` and cold native App + Links do not silently land on the map. - **Login & signup — NIP-49 + NIP-46 nostrconnect (2026-10-01):** two Nostr-native auth upgrades (owner picked these two; read-only/npub sessions were explicitly declined). diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 7ebc0c4..6c90b38 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -4652,7 +4652,7 @@ settingsUi: { secretKeyEncDesc: 'Open a password-protected key backup.', rawNsec: 'Raw nsec', encryptedNip49: 'Encrypted (NIP-49)', - nsecWarning: '⚠️ Only use this on a trusted device. Never share your nsec.', + nsecWarning: '⚠️ Raw nsec stays in this browser session only — it is not saved across restarts. Use Amber or a bunker to stay signed in. Never share your nsec.', login: 'Login', encSaferDesc: 'Safer: the key stays encrypted until you enter your password, so the backup is safe to keep in cloud storage or a password manager.', password: 'Password', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 2a88d9c..1dc1c32 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -4652,7 +4652,7 @@ settingsUi: { secretKeyEncDesc: 'Abre una copia de seguridad de clave protegida con contraseña.', rawNsec: 'nsec en claro', encryptedNip49: 'Cifrada (NIP-49)', - nsecWarning: '⚠️ Úsalo solo en un dispositivo de confianza. Nunca compartas tu nsec.', + nsecWarning: '⚠️ El nsec en claro solo vive en esta sesión del navegador; no se guarda entre reinicios. Usa Amber o un bunker para mantener la sesión. Nunca compartas tu nsec.', login: 'Iniciar sesión', encSaferDesc: 'Más seguro: la clave permanece cifrada hasta que introduzcas tu contraseña, así que la copia es segura para guardarla en la nube o en un gestor de contraseñas.', password: 'Contraseña', diff --git a/src/lib/nostrLogin.test.tsx b/src/lib/nostrLogin.test.tsx new file mode 100644 index 0000000..fa33fc3 --- /dev/null +++ b/src/lib/nostrLogin.test.tsx @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { generateSecretKey, nip19 } from 'nostr-tools'; +import { + NLogin, + SecureNostrLoginProvider, + useNostrLogin, +} from './nostrLogin'; + +const NSEC = nip19.nsecEncode(generateSecretKey()); + +function Probe() { + const { addLogin, logins } = useNostrLogin(); + return ( + <> + + {logins.length} + + ); +} + +describe('SecureNostrLoginProvider', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + it('keeps nsec logins out of persistent localStorage', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'add' })); + await waitFor(() => expect(screen.getByText('1')).toBeTruthy()); + + const local = localStorage.getItem('test-login') ?? ''; + const session = sessionStorage.getItem('test-login:session') ?? ''; + expect(local).not.toContain('nsec1'); + expect(session).toContain('nsec1'); + }); +}); diff --git a/src/lib/nostrLogin.ts b/src/lib/nostrLogin.ts new file mode 100644 index 0000000..9626b8e --- /dev/null +++ b/src/lib/nostrLogin.ts @@ -0,0 +1,253 @@ +import { createContext, createElement, type FC, type ReactNode, useContext, useEffect, useReducer } from 'react'; +import type { NPool } from '@nostrify/nostrify'; +import { BunkerURI, NConnectSigner, NBrowserSigner, NSecSigner } from '@nostrify/nostrify'; +import type { NostrSigner } from '@nostrify/types'; +import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; + +/** + * App-owned Nostr login storage. + * + * WHY THIS EXISTS + * ─────────────── + * `@nostrify/react/login` persists every login object — including the raw + * nsec — in localStorage. A same-origin XSS or a copied browser profile can + * therefore steal the account key. + * + * We keep non-secret logins (Amber, extension, bunker) in localStorage as + * before, but nsec logins live only in sessionStorage. Reloading the tab keeps + * the session; closing the browser/new tab requires re-entering the nsec. Use + * Amber or a bunker when you want to stay signed in across restarts. + * + * Existing raw nsec entries are migrated out of localStorage on the first + * write. The migration is intentionally conservative: the current nsec session + * keeps working until the tab closes. + */ + +export type NLoginType = NLoginNsec | NLoginBunker | NLoginExtension | NLoginOther; + +export type NLoginNsec = NLoginBase<'nsec', { nsec: `nsec1${string}` }>; +export type NLoginBunker = NLoginBase<'bunker', { + bunkerPubkey: string; + clientNsec: `nsec1${string}`; + relays: string[]; +}>; +export type NLoginExtension = NLoginBase<'extension', null>; +export type NLoginOther = NLoginBase<`x-${string}`, { [key: string]: unknown }>; + +interface NLoginBase { + id: string; + type: T; + pubkey: string; + createdAt: string; + data: D; +} + +export class NLogin implements NLoginBase { + public id: string; + public type: T; + public pubkey: string; + public createdAt: string; + public data: D; + + constructor(type: T, pubkey: string, data: D) { + this.id = `${type}:${pubkey}`; + this.type = type; + this.pubkey = pubkey; + this.createdAt = new Date().toISOString(); + this.data = data; + } + + static fromNsec(nsec: string): NLoginNsec { + const decoded = nip19.decode(nsec); + if (decoded.type !== 'nsec') throw new Error('Invalid nsec'); + const sk = decoded.data; + return new NLogin('nsec', getPublicKey(sk), { nsec: nip19.nsecEncode(sk) }); + } + + static async fromBunker(uri: string, pool: NPool): Promise { + const { pubkey: bunkerPubkey, secret, relays } = new BunkerURI(uri); + if (!relays.length) throw new Error('No relay provided'); + const sk = generateSecretKey(); + const clientNsec = nip19.nsecEncode(sk); + const clientSigner = new NSecSigner(sk); + const signer = new NConnectSigner({ + relay: pool.group(relays), + pubkey: bunkerPubkey, + signer: clientSigner, + timeout: 60_000, + }); + await signer.connect(secret); + const pubkey = await signer.getPublicKey(); + return new NLogin('bunker', pubkey, { bunkerPubkey, clientNsec, relays }); + } + + static async fromExtension(): Promise { + const windowSigner = (globalThis as unknown as { nostr?: NostrSigner }).nostr; + if (!windowSigner) throw new Error('Nostr extension is not available'); + const pubkey = await windowSigner.getPublicKey(); + return new NLogin('extension', pubkey, null); + } + + toJSON(): NLoginBase { + return { + id: this.id, + type: this.type, + pubkey: this.pubkey, + createdAt: this.createdAt, + data: this.data, + }; + } +} + +/** Minimal NUser implementation matching the package's behavior. */ +export class NUser { + constructor( + readonly method: 'nsec' | 'bunker' | 'extension' | `x-${string}`, + readonly pubkey: string, + readonly signer: NostrSigner, + ) {} + + static fromNsecLogin(login: NLoginNsec): NUser { + const sk = nip19.decode(login.data.nsec) as { type: 'nsec'; data: Uint8Array }; + return new NUser(login.type, login.pubkey, new NSecSigner(sk.data)); + } + + static fromBunkerLogin(login: NLoginBunker, pool: NPool): NUser { + const sk = nip19.decode(login.data.clientNsec) as { type: 'nsec'; data: Uint8Array }; + return new NUser( + login.type, + login.pubkey, + new NConnectSigner({ + relay: pool.group(login.data.relays), + pubkey: login.data.bunkerPubkey, + signer: new NSecSigner(sk.data), + timeout: 60_000, + }), + ); + } + + static fromExtensionLogin(login: NLoginExtension): NUser { + return new NUser(login.type, login.pubkey, new NBrowserSigner()); + } +} + +type NLoginAction = + | { type: 'login.add'; login: NLoginType; set?: boolean } + | { type: 'login.remove'; id: string } + | { type: 'login.set'; id: string } + | { type: 'login.clear' }; + +function reducer(state: NLoginType[], action: NLoginAction): NLoginType[] { + switch (action.type) { + case 'login.add': { + const filtered = state.filter((login) => login.id !== action.login.id); + return action.set ? [action.login, ...filtered] : [...filtered, action.login]; + } + case 'login.remove': + return state.filter((login) => login.id !== action.id); + case 'login.set': { + const login = state.find((item) => item.id === action.id); + if (!login) return state; + return [login, ...state.filter((item) => item.id !== action.id)]; + } + case 'login.clear': + return []; + default: + return state; + } +} + +export interface NostrLoginContextType { + logins: readonly NLoginType[]; + addLogin: (login: NLoginType) => void; + removeLogin: (id: string) => void; + setLogin: (id: string) => void; + clearLogins: () => void; +} + +export const NostrLoginContext = createContext(undefined); + +function parseLogins(value: string | null): NLoginType[] { + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? (parsed as NLoginType[]) : []; + } catch { + return []; + } +} + +function readLocal(storageKey: string): NLoginType[] { + try { + return parseLogins(localStorage.getItem(storageKey)); + } catch { + return []; + } +} + +function readSession(sessionKey: string): NLoginType[] { + try { + return parseLogins(sessionStorage.getItem(sessionKey)); + } catch { + return []; + } +} + +function writeLocal(storageKey: string, logins: NLoginType[]): void { + try { + localStorage.setItem(storageKey, JSON.stringify(logins.filter((login) => login.type !== 'nsec'))); + } catch { + /* storage unavailable */ + } +} + +function writeSession(sessionKey: string, logins: NLoginType[]): void { + try { + if (logins.some((login) => login.type === 'nsec')) { + sessionStorage.setItem(sessionKey, JSON.stringify(logins)); + } else { + sessionStorage.removeItem(sessionKey); + } + } catch { + /* session storage unavailable — never fall back to raw nsec in localStorage */ + } +} + +/** Provider with nsec session isolation. */ +export const SecureNostrLoginProvider: FC<{ children: ReactNode; storageKey: string }> = ({ + children, + storageKey, +}) => { + const sessionKey = `${storageKey}:session`; + const [logins, dispatch] = useReducer(reducer, [], () => { + const session = readSession(sessionKey); + if (session.length > 0) return session; + // First run after upgrade: allow the existing local session, then the write + // effect migrates nsec out of localStorage. + return readLocal(storageKey); + }); + + useEffect(() => { + writeSession(sessionKey, logins); + writeLocal(storageKey, logins); + }, [logins, sessionKey, storageKey]); + + const value: NostrLoginContextType = { + logins, + addLogin: (login) => dispatch({ type: 'login.add', login }), + removeLogin: (id) => dispatch({ type: 'login.remove', id }), + setLogin: (id) => dispatch({ type: 'login.set', id }), + clearLogins: () => dispatch({ type: 'login.clear' }), + }; + + return createElement(NostrLoginContext.Provider, { value }, children); +}; + +export function useNostrLogin(): NostrLoginContextType { + const context = useContext(NostrLoginContext); + if (!context) throw new Error('useNostrLogin must be used within SecureNostrLoginProvider'); + return context; +} + +/** Backwards-compatible name for the app's provider. */ +export const NostrLoginProvider = SecureNostrLoginProvider; diff --git a/src/main.tsx b/src/main.tsx index cdeb31e..89c3382 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,14 +16,15 @@ if (typeof window !== 'undefined') { } } -// Clean share links: a bare /verify/, /card/, /coach// -// or /lab/// hits the Netlify SPA fallback (index.html), but -// HashRouter needs the route in the hash — rewrite it. -if (typeof window !== 'undefined' && !window.location.hash) { - const m = window.location.pathname.match(/^\/(verify|card|coach)\/(.+)$/); - const labProfile = window.location.pathname.match(/^\/lab\/[^/]+\/[^/]+\/[^/]+$/); - if (m) window.location.replace(`/#/${m[1]}/${m[2]}${window.location.search}`); - else if (labProfile) window.location.replace(`/#${window.location.pathname}${window.location.search}`); +// Clean share links: a bare /spot//, /crew//, /event/..., +// /verify/..., /card/... or /lab/... hits the Netlify SPA fallback +// (index.html), but HashRouter needs the route in the hash. Rewrite all +// non-root app paths, not just a hand-maintained subset — otherwise direct +// links and cold native app links silently land on the map. +if (typeof window !== 'undefined' && !window.location.hash && window.location.pathname !== '/') { + window.location.replace( + `${window.location.origin}/#${window.location.pathname}${window.location.search}`, + ); } // Outfit font for body text