Add unsafe raw-nsec login fallback
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.
This commit is contained in:
Generated
+105
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"nostr-tools": "^2.24.1",
|
||||||
"pinia": "^3.0.0",
|
"pinia": "^3.0.0",
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
},
|
},
|
||||||
@@ -579,6 +580,45 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -974,6 +1014,42 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1902,6 +1978,35 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"nostr-tools": "^2.24.1",
|
||||||
"pinia": "^3.0.0",
|
"pinia": "^3.0.0",
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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))}`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { api, ApiError } from '../lib/api';
|
import { api, ApiError } from '../lib/api';
|
||||||
import { buildNip98Header, hasNip07 } from '../lib/nip07';
|
import { buildNip98Header, hasNip07 } from '../lib/nip07';
|
||||||
|
import { buildNip98HeaderWithNsec, isValidNsec } from '../lib/nsec';
|
||||||
|
|
||||||
export type Team = 'orange' | 'green';
|
export type Team = 'orange' | 'green';
|
||||||
|
|
||||||
@@ -43,6 +44,25 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
this.pubkey = res.pubkey;
|
this.pubkey = res.pubkey;
|
||||||
this.team = res.team;
|
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() {
|
async logout() {
|
||||||
await api.post('/api/auth/logout');
|
await api.post('/api/auth/logout');
|
||||||
this.pubkey = null;
|
this.pubkey = null;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { hasNip07 } from '../lib/nip07';
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const loggingIn = ref(false);
|
const loggingIn = ref(false);
|
||||||
|
const showNsecForm = ref(false);
|
||||||
|
const nsecInput = ref('');
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
@@ -18,6 +20,19 @@ async function handleLogin() {
|
|||||||
loggingIn.value = false;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -36,6 +51,36 @@ async function handleLogin() {
|
|||||||
<p v-if="!hasNip07()" class="text-sm text-neutral-500">
|
<p v-if="!hasNip07()" class="text-sm text-neutral-500">
|
||||||
No NIP-07 extension detected — install Alby or nos2x, or open this app from the Archipelago dashboard.
|
No NIP-07 extension detected — install Alby or nos2x, or open this app from the Archipelago dashboard.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="text-xs text-neutral-600 underline hover:text-neutral-400"
|
||||||
|
@click="showNsecForm = !showNsecForm"
|
||||||
|
>
|
||||||
|
{{ showNsecForm ? 'Hide' : 'Or paste an nsec (unsafe)' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showNsecForm" class="flex w-full max-w-xs flex-col gap-2">
|
||||||
|
<p class="rounded bg-red-950 px-3 py-2 text-left text-xs text-red-300">
|
||||||
|
⚠ 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.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
v-model="nsecInput"
|
||||||
|
type="password"
|
||||||
|
placeholder="nsec1…"
|
||||||
|
class="rounded bg-neutral-900 px-3 py-2 text-sm text-white outline-none ring-1 ring-neutral-700 focus:ring-red-500"
|
||||||
|
@keyup.enter="handleNsecLogin"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="rounded bg-red-900 px-3 py-2 text-sm font-semibold text-red-100 transition hover:bg-red-800 disabled:opacity-50"
|
||||||
|
:disabled="loggingIn || !nsecInput"
|
||||||
|
@click="handleNsecLogin"
|
||||||
|
>
|
||||||
|
{{ loggingIn ? 'Signing in…' : 'Log in with nsec (unsafe)' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user