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:
2026-08-05 13:56:50 +00:00
parent 0bf9b8997c
commit cd3d82d1c1
5 changed files with 216 additions and 0 deletions
+45
View File
@@ -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))}`;
}
+20
View File
@@ -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;
+45
View File
@@ -6,6 +6,8 @@ import { hasNip07 } from '../lib/nip07';
const auth = useAuthStore();
const error = ref<string | null>(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;
}
}
</script>
<template>
@@ -36,6 +51,36 @@ async function handleLogin() {
<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.
</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>
</div>
</template>