security: keep nsec session-only and fix direct links
This commit is contained in:
parent
722ca1bc7e
commit
fed2ff135b
6 changed files with 320 additions and 10 deletions
11
AGENTS.md
11
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 —
|
`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).
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
45
src/lib/nostrLogin.test.tsx
Normal file
45
src/lib/nostrLogin.test.tsx
Normal 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
253
src/lib/nostrLogin.ts
Normal 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;
|
||||||
17
src/main.tsx
17
src/main.tsx
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue