security: keep nsec session-only and fix direct links

This commit is contained in:
SkateSpots 2026-11-01 13:48:07 +07:00
parent 722ca1bc7e
commit fed2ff135b
6 changed files with 320 additions and 10 deletions

View file

@ -1799,6 +1799,17 @@ session with **no other memory of this project**. So:
`public/images/screenshots/{map,spot,community}.png` (1280×800, quantised — `public/images/screenshots/{map,spot,community}.png` (1280×800, quantised —
captured with headless Chromium; regenerate with a CDP harness if the UI captured with headless Chromium; regenerate with a CDP harness if the UI
changes materially). 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 `/#<path>` 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 - **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 auth upgrades (owner picked these two; read-only/npub sessions were explicitly
declined). declined).

View file

@ -4652,7 +4652,7 @@ settingsUi: {
secretKeyEncDesc: 'Open a password-protected key backup.', secretKeyEncDesc: 'Open a password-protected key backup.',
rawNsec: 'Raw nsec', rawNsec: 'Raw nsec',
encryptedNip49: 'Encrypted (NIP-49)', 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', 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.', 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', password: 'Password',

View file

@ -4652,7 +4652,7 @@ settingsUi: {
secretKeyEncDesc: 'Abre una copia de seguridad de clave protegida con contraseña.', secretKeyEncDesc: 'Abre una copia de seguridad de clave protegida con contraseña.',
rawNsec: 'nsec en claro', rawNsec: 'nsec en claro',
encryptedNip49: 'Cifrada (NIP-49)', 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', 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.', 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', password: 'Contraseña',

View file

@ -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 (
<>
<button type="button" onClick={() => addLogin(NLogin.fromNsec(NSEC))}>
add
</button>
<span>{logins.length}</span>
</>
);
}
describe('SecureNostrLoginProvider', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it('keeps nsec logins out of persistent localStorage', async () => {
render(
<SecureNostrLoginProvider storageKey="test-login">
<Probe />
</SecureNostrLoginProvider>,
);
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');
});
});

253
src/lib/nostrLogin.ts Normal file
View file

@ -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<T extends string, D> {
id: string;
type: T;
pubkey: string;
createdAt: string;
data: D;
}
export class NLogin<T extends string, D> implements NLoginBase<T, D> {
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<NLoginBunker> {
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<NLoginExtension> {
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<T, D> {
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<NostrLoginContextType | undefined>(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;

View file

@ -16,14 +16,15 @@ if (typeof window !== 'undefined') {
} }
} }
// Clean share links: a bare /verify/<naddr>, /card/<npub>, /coach/<pubkey>/<d> // Clean share links: a bare /spot/<pk>/<d>, /crew/<pk>/<d>, /event/...,
// or /lab/<craft>/<pubkey>/<d> hits the Netlify SPA fallback (index.html), but // /verify/..., /card/... or /lab/... hits the Netlify SPA fallback
// HashRouter needs the route in the hash — rewrite it. // (index.html), but HashRouter needs the route in the hash. Rewrite all
if (typeof window !== 'undefined' && !window.location.hash) { // non-root app paths, not just a hand-maintained subset — otherwise direct
const m = window.location.pathname.match(/^\/(verify|card|coach)\/(.+)$/); // links and cold native app links silently land on the map.
const labProfile = window.location.pathname.match(/^\/lab\/[^/]+\/[^/]+\/[^/]+$/); if (typeof window !== 'undefined' && !window.location.hash && window.location.pathname !== '/') {
if (m) window.location.replace(`/#/${m[1]}/${m[2]}${window.location.search}`); window.location.replace(
else if (labProfile) window.location.replace(`/#${window.location.pathname}${window.location.search}`); `${window.location.origin}/#${window.location.pathname}${window.location.search}`,
);
} }
// Outfit font for body text // Outfit font for body text