From cd3d82d1c1977da3c14c48501895fda981a66f87 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 5 Aug 2026 13:56:50 +0000 Subject: [PATCH] Add unsafe raw-nsec login fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapsed-by-default 'paste an nsec (unsafe)' option on the login screen, signs the NIP-98 login event client-side with nostr-tools and never persists the key (used once in memory, discarded) — for testing/kiosk use without a NIP-07 extension. Clearly labeled as unsafe in the UI. --- frontend/package-lock.json | 105 +++++++++++++++++++++++++++++++ frontend/package.json | 1 + frontend/src/lib/nsec.ts | 45 +++++++++++++ frontend/src/stores/auth.ts | 20 ++++++ frontend/src/views/LoginView.vue | 45 +++++++++++++ 5 files changed, 216 insertions(+) create mode 100644 frontend/src/lib/nsec.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bdbf387..c03f400 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "leaflet": "^1.9.4", + "nostr-tools": "^2.24.1", "pinia": "^3.0.0", "vue": "^3.5.0" }, @@ -579,6 +580,45 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -974,6 +1014,42 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1902,6 +1978,35 @@ "node": ">=0.10.0" } }, + "node_modules/nostr-tools": { + "version": "2.24.1", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.24.1.tgz", + "integrity": "sha512-KdrKjC74n/rr6J3eCSfZj8dcbZFvolHYe4S22SefNZ5YWbhHiB0KL/mmJjEZ0u6B9mZK0YcQtl+WQ46KzwapeQ==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5d3349c..759f90c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "leaflet": "^1.9.4", + "nostr-tools": "^2.24.1", "pinia": "^3.0.0", "vue": "^3.5.0" }, diff --git a/frontend/src/lib/nsec.ts b/frontend/src/lib/nsec.ts new file mode 100644 index 0000000..d8136b8 --- /dev/null +++ b/frontend/src/lib/nsec.ts @@ -0,0 +1,45 @@ +// Raw-nsec signing — an *unsafe* login fallback for when there's no NIP-07 +// extension or NIP-46 signer available (testing, kiosk devices, etc). The +// private key only ever lives in page memory for the duration of one sign +// call; it's never persisted (no localStorage/sessionStorage), matching how +// 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 type { SignedEvent, UnsignedEvent } from './nip07'; + +export function isValidNsec(nsec: string): boolean { + try { + return nip19.decode(nsec.trim()).type === 'nsec'; + } catch { + return false; + } +} + +function decodeNsec(nsec: string): Uint8Array { + const decoded = nip19.decode(nsec.trim()); + if (decoded.type !== 'nsec') throw new Error('Not an nsec key'); + return decoded.data; +} + +export function nsecPubkey(nsec: string): string { + return getPublicKey(decodeNsec(nsec)); +} + +function signWithNsec(nsec: string, event: UnsignedEvent): SignedEvent { + return finalizeEvent(event, decodeNsec(nsec)); +} + +/** Sign a NIP-98 (kind 27235) event with a raw nsec and return the Authorization header value. */ +export function buildNip98HeaderWithNsec(nsec: string, url: string, method: string): string { + const event = signWithNsec(nsec, { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [ + ['u', url], + ['method', method], + ], + }); + return `Nostr ${btoa(JSON.stringify(event))}`; +} diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 1086bd0..25006ea 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia'; import { api, ApiError } from '../lib/api'; import { buildNip98Header, hasNip07 } from '../lib/nip07'; +import { buildNip98HeaderWithNsec, isValidNsec } from '../lib/nsec'; export type Team = 'orange' | 'green'; @@ -43,6 +44,25 @@ export const useAuthStore = defineStore('auth', { this.pubkey = res.pubkey; 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. + */ + async loginWithNsec(nsec: string) { + if (!isValidNsec(nsec)) throw new Error('That doesn\'t look like a valid nsec.'); + const url = `${location.origin}/api/auth/login`; + const header = buildNip98HeaderWithNsec(nsec, 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 73de34d..181a470 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -6,6 +6,8 @@ import { hasNip07 } from '../lib/nip07'; const auth = useAuthStore(); const error = ref(null); const loggingIn = ref(false); +const showNsecForm = ref(false); +const nsecInput = ref(''); async function handleLogin() { error.value = null; @@ -18,6 +20,19 @@ async function handleLogin() { loggingIn.value = false; } } + +async function handleNsecLogin() { + error.value = null; + loggingIn.value = true; + try { + await auth.loginWithNsec(nsecInput.value); + nsecInput.value = ''; + } catch (err) { + error.value = err instanceof Error ? err.message : String(err); + } finally { + loggingIn.value = false; + } +}