Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
445 lines
16 KiB
JavaScript
445 lines
16 KiB
JavaScript
/**
|
|
* nostr-identity.js — Browser-side Nostr identity for Timmy Tower World.
|
|
*
|
|
* Supports two identity modes:
|
|
* 1. NIP-07 extension (window.nostr) — preferred when available.
|
|
* 2. Generated keypair stored in localStorage — fallback for users without
|
|
* a Nostr extension. The user is shown an opt-in prompt before a key
|
|
* is generated; keys are never generated silently.
|
|
*
|
|
* Public API:
|
|
* initNostrIdentity(apiBase) → Promise<void> — call once on page load.
|
|
* getNostrToken() → string|null — current signed token.
|
|
* getPubkey() → string|null — hex pubkey.
|
|
* getOrRefreshToken(apiBase) → Promise<string|null> — valid token or refresh.
|
|
* hasIdentity() → boolean
|
|
* showIdentityPrompt() → void — display the "Identify with Nostr" UI
|
|
*/
|
|
|
|
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
|
|
import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js';
|
|
import { decode as nip19Decode } from 'nostr-tools/nip19';
|
|
|
|
const LS_KEYPAIR_KEY = 'timmy_nostr_keypair_v1';
|
|
const LS_TOKEN_KEY = 'timmy_nostr_token_v1';
|
|
|
|
// Token lifetime: 23 h — server TTL is 24 h; refresh 1 h before expiry
|
|
const TOKEN_TTL_MS = 23 * 3600 * 1000;
|
|
|
|
let _pubkey = null; // hex
|
|
let _token = null; // nostr_token string from server
|
|
let _tokenExp = 0; // unix ms when token was fetched
|
|
let _useNip07 = false; // true if window.nostr is available
|
|
let _canSign = false; // true if we have a signing capability (NIP-07 or local privkey)
|
|
let _identityPromptShown = false;
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
export async function initNostrIdentity(apiBase = '/api') {
|
|
// Prefer NIP-07 extension
|
|
if (typeof window !== 'undefined' && window.nostr) {
|
|
try {
|
|
_pubkey = await window.nostr.getPublicKey();
|
|
_useNip07 = true;
|
|
_canSign = true;
|
|
_saveDiscoveredKeypair(_pubkey, null); // Store pubkey in LS even if NIP-07
|
|
console.info('[nostr] Using NIP-07 extension, pubkey:', _pubkey.slice(0, 8) + '…');
|
|
} catch (err) {
|
|
console.warn('[nostr] NIP-07 getPublicKey failed, will use local keypair', err);
|
|
_useNip07 = false;
|
|
}
|
|
}
|
|
|
|
// Try restoring / discovering an existing keypair
|
|
if (!_pubkey) {
|
|
_pubkey = _loadKeypair();
|
|
if (_pubkey) {
|
|
// Check if we actually have a privkey for signing (npub-only discovery gives pubkey but no privkey)
|
|
_canSign = !!_getPrivkeyBytes();
|
|
if (_canSign) {
|
|
console.info('[nostr] Restored local keypair (with signing), pubkey:', _pubkey.slice(0, 8) + '…');
|
|
} else {
|
|
console.info('[nostr] Discovered pubkey (view-only, no privkey), pubkey:', _pubkey.slice(0, 8) + '…');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to restore cached token
|
|
_loadCachedToken();
|
|
|
|
// If we have signing capability and no valid token, authenticate now
|
|
if (_pubkey && _canSign && !_isTokenValid()) {
|
|
await refreshToken(apiBase);
|
|
}
|
|
|
|
// Show the opt-in prompt if:
|
|
// a) No identity at all — user can generate a keypair or connect NIP-07
|
|
// b) Have a pubkey but no signing capability (npub-only discovery) —
|
|
// user should be offered a way to add signing (generate new key or NIP-07)
|
|
if (!_pubkey || !_canSign) {
|
|
_scheduleIdentityPrompt(apiBase);
|
|
}
|
|
}
|
|
|
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
|
|
export function getPubkey() { return _pubkey; }
|
|
export function getNostrToken() { return _isTokenValid() ? _token : null; }
|
|
export function hasIdentity() { return !!_pubkey; }
|
|
|
|
export function disconnectNostrIdentity() {
|
|
_pubkey = null;
|
|
_token = null;
|
|
_tokenExp = 0;
|
|
_useNip07 = false;
|
|
_canSign = false;
|
|
localStorage.removeItem(LS_KEYPAIR_KEY);
|
|
localStorage.removeItem(LS_TOKEN_KEY);
|
|
window.dispatchEvent(new CustomEvent('nostr:identity-disconnected'));
|
|
console.info('[nostr] identity disconnected');
|
|
}
|
|
|
|
/**
|
|
* getOrRefreshToken — returns a valid token, refreshing if necessary.
|
|
* Returns null if no identity is established.
|
|
*/
|
|
export async function getOrRefreshToken(apiBase = '/api') {
|
|
if (!_pubkey) return null;
|
|
if (_isTokenValid()) return _token;
|
|
return refreshToken(apiBase);
|
|
}
|
|
|
|
/**
|
|
* refreshToken — run the challenge→sign→verify flow with the API.
|
|
*
|
|
* Server API:
|
|
* POST /api/identity/challenge → { nonce, expiresAt }
|
|
* POST /api/identity/verify → { nostr_token, pubkey, trust }
|
|
* body: { event } where event.content = nonce, event.kind = 27235
|
|
*/
|
|
export async function refreshToken(apiBase = '/api') {
|
|
if (!_pubkey) return null;
|
|
|
|
try {
|
|
// 1. POST /api/identity/challenge — server returns a nonce
|
|
const challengeRes = await fetch(`${apiBase}/identity/challenge`, { method: 'POST' });
|
|
if (!challengeRes.ok) {
|
|
console.warn('[nostr] challenge fetch failed', challengeRes.status);
|
|
return null;
|
|
}
|
|
const { nonce } = await challengeRes.json();
|
|
if (!nonce) { console.warn('[nostr] no nonce in challenge response'); return null; }
|
|
|
|
// 2. Sign the nonce as event.content (kind 27235 per NIP-98)
|
|
const event = await _signNonce(nonce);
|
|
if (!event) { console.warn('[nostr] signing failed'); return null; }
|
|
|
|
// 3. POST /api/identity/verify — server checks sig and issues nostr_token
|
|
const verifyRes = await fetch(`${apiBase}/identity/verify`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ event }),
|
|
});
|
|
if (!verifyRes.ok) {
|
|
console.warn('[nostr] verify failed', verifyRes.status);
|
|
return null;
|
|
}
|
|
const data = await verifyRes.json();
|
|
const token = data.nostr_token;
|
|
if (!token) { console.warn('[nostr] no nostr_token in verify response'); return null; }
|
|
|
|
_token = token;
|
|
_tokenExp = Date.now();
|
|
_saveCachedToken();
|
|
console.info('[nostr] token refreshed, tier:', data.trust?.tier ?? 'unknown');
|
|
return token;
|
|
} catch (err) {
|
|
console.warn('[nostr] refreshToken error', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Identity prompt — optional Nostr identification UI ────────────────────────
|
|
// Shown after a short delay if no identity is present. User can dismiss.
|
|
|
|
export function showIdentityPrompt(apiBase = '/api') {
|
|
if (_identityPromptShown || _pubkey) return;
|
|
_identityPromptShown = true;
|
|
|
|
const $prompt = document.createElement('div');
|
|
$prompt.id = 'nostr-identity-prompt';
|
|
$prompt.style.cssText = [
|
|
'position:fixed;bottom:80px;right:16px;z-index:900',
|
|
'background:rgba(8,6,16,0.94);border:1px solid #3355aa',
|
|
'border-radius:8px;padding:12px 14px;font-family:monospace',
|
|
'font-size:12px;color:#88aadd;max-width:240px',
|
|
'box-shadow:0 0 16px rgba(50,80,200,0.3)',
|
|
'animation:fadeInUp .3s ease',
|
|
].join(';');
|
|
|
|
const hasNip07 = typeof window !== 'undefined' && !!window.nostr;
|
|
$prompt.innerHTML = `
|
|
<div style="color:#aaccff;font-weight:bold;margin-bottom:6px">
|
|
⚡ Identify with Nostr
|
|
</div>
|
|
<div style="line-height:1.5;margin-bottom:10px">
|
|
${hasNip07
|
|
? 'Connect your Nostr extension to unlock trust benefits and free-tier access.'
|
|
: 'Generate a local Nostr identity to unlock trust benefits and free-tier access.'}
|
|
</div>
|
|
<div style="display:flex;gap:8px">
|
|
<button id="nostr-prompt-connect"
|
|
style="flex:1;background:#1a3a7a;color:#aaddff;border:1px solid #3366bb;
|
|
border-radius:4px;padding:5px 8px;cursor:pointer;font-family:monospace;font-size:11px">
|
|
${hasNip07 ? 'Connect Extension' : 'Generate Key'}
|
|
</button>
|
|
<button id="nostr-prompt-dismiss"
|
|
style="background:transparent;color:#556688;border:none;cursor:pointer;font-size:11px">
|
|
✕
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild($prompt);
|
|
|
|
document.getElementById('nostr-prompt-connect')?.addEventListener('click', async () => {
|
|
$prompt.remove();
|
|
if (hasNip07) {
|
|
// NIP-07 connect
|
|
try {
|
|
_pubkey = await window.nostr.getPublicKey();
|
|
_useNip07 = true;
|
|
_canSign = true;
|
|
_saveDiscoveredKeypair(_pubkey, null); // Store pubkey in LS even if NIP-07
|
|
} catch { return; }
|
|
} else {
|
|
// Generate + store keypair (user consented by clicking)
|
|
_pubkey = _generateAndSaveKeypair();
|
|
_canSign = true;
|
|
}
|
|
if (_pubkey && _canSign) {
|
|
await refreshToken(apiBase);
|
|
_updateIdentityHUD();
|
|
}
|
|
});
|
|
|
|
document.getElementById('nostr-prompt-dismiss')?.addEventListener('click', () => {
|
|
$prompt.remove();
|
|
});
|
|
}
|
|
|
|
function _scheduleIdentityPrompt(apiBase) {
|
|
// Show prompt 4 s after page load — after Three.js world is running
|
|
setTimeout(() => showIdentityPrompt(apiBase), 4000);
|
|
}
|
|
|
|
function _updateIdentityHUD() {
|
|
// Notify the session button / HUD area if session.js is connected.
|
|
// We dispatch a custom event so session.js can pick it up without a circular dep.
|
|
window.dispatchEvent(new CustomEvent('nostr:identity-ready', { detail: { pubkey: _pubkey } }));
|
|
}
|
|
|
|
// ── Keypair helpers ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* _scanExistingNostrKeys — looks for pre-existing Nostr keys in localStorage
|
|
* using common patterns used by Nostr clients:
|
|
* - Our own LS_KEYPAIR_KEY format { pubkey, privkey }
|
|
* - npub1... / nsec1... bech32 strings stored under common key names
|
|
* - Raw 64-char hex pubkeys / privkeys
|
|
*
|
|
* Returns { pubkey: hex, privkey: hex|null } or null if nothing found.
|
|
* Does NOT generate new keys — only discovers existing ones.
|
|
*/
|
|
function _scanExistingNostrKeys() {
|
|
// 1. Our own format first
|
|
try {
|
|
const stored = localStorage.getItem(LS_KEYPAIR_KEY);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (parsed?.pubkey) return { pubkey: parsed.pubkey, privkey: parsed.privkey ?? null };
|
|
}
|
|
} catch { /* corrupt */ }
|
|
|
|
// 2. Common key names used by Nostr clients (nos2x-style, nostrid, Alby export, etc.)
|
|
const COMMON_LS_KEYS = [
|
|
'nostr_privkey', 'privkey', 'nsec', 'npub',
|
|
'nostr-privkey', 'nostr-nsec', 'nostr_nsec',
|
|
'nostr_keys', 'nostrKeys', 'nostr:privkey',
|
|
// nos2x stores its key here:
|
|
'nostr-extension:privkey',
|
|
];
|
|
|
|
for (const key of COMMON_LS_KEYS) {
|
|
try {
|
|
const val = localStorage.getItem(key);
|
|
if (!val) continue;
|
|
|
|
// Try bech32 nsec1...
|
|
if (val.startsWith('nsec1')) {
|
|
try {
|
|
const decoded = nip19Decode(val);
|
|
if (decoded.type === 'nsec') {
|
|
const privBytes = decoded.data;
|
|
const pubkeyHex = getPublicKey(privBytes);
|
|
const privkeyHex = bytesToHex(privBytes);
|
|
// Save in our format for subsequent loads
|
|
_saveDiscoveredKeypair(pubkeyHex, privkeyHex);
|
|
console.info(`[nostr] Discovered nsec key at localStorage['${key}']`);
|
|
return { pubkey: pubkeyHex, privkey: privkeyHex };
|
|
}
|
|
} catch { /* invalid bech32 */ }
|
|
}
|
|
|
|
// Try bech32 npub1... (pubkey only — no signing possible without privkey)
|
|
if (val.startsWith('npub1')) {
|
|
try {
|
|
const decoded = nip19Decode(val);
|
|
if (decoded.type === 'npub') {
|
|
console.info(`[nostr] Discovered npub key at localStorage['${key}'] (view-only)`);
|
|
return { pubkey: decoded.data, privkey: null };
|
|
}
|
|
} catch { /* invalid */ }
|
|
}
|
|
|
|
// Try raw hex privkey (64 chars)
|
|
if (/^[0-9a-f]{64}$/.test(val)) {
|
|
try {
|
|
const privBytes = hexToBytes(val);
|
|
const pubkeyHex = getPublicKey(privBytes);
|
|
_saveDiscoveredKeypair(pubkeyHex, val);
|
|
console.info(`[nostr] Discovered hex privkey at localStorage['${key}']`);
|
|
return { pubkey: pubkeyHex, privkey: val };
|
|
} catch { /* bad key */ }
|
|
}
|
|
|
|
// Try JSON objects with common shapes
|
|
if (val.startsWith('{')) {
|
|
try {
|
|
const obj = JSON.parse(val);
|
|
const nsec = obj.nsec || obj.privkey || obj.private_key || obj.secret;
|
|
const npub = obj.npub || obj.pubkey || obj.public_key;
|
|
if (nsec && nsec.startsWith('nsec1')) {
|
|
const decoded = nip19Decode(nsec);
|
|
if (decoded.type === 'nsec') {
|
|
const privBytes = decoded.data;
|
|
const pubkeyHex = getPublicKey(privBytes);
|
|
const privkeyHex = bytesToHex(privBytes);
|
|
_saveDiscoveredKeypair(pubkeyHex, privkeyHex);
|
|
console.info(`[nostr] Discovered nsec in JSON at localStorage['${key}']`);
|
|
return { pubkey: pubkeyHex, privkey: privkeyHex };
|
|
}
|
|
}
|
|
if (npub && npub.startsWith('npub1')) {
|
|
const decoded = nip19Decode(npub);
|
|
if (decoded.type === 'npub') {
|
|
return { pubkey: decoded.data, privkey: null };
|
|
}
|
|
}
|
|
if (typeof obj.pubkey === 'string' && /^[0-9a-f]{64}$/.test(obj.pubkey)) {
|
|
const pkey = obj.privkey ?? obj.private_key ?? null;
|
|
return { pubkey: obj.pubkey, privkey: pkey };
|
|
}
|
|
} catch { /* bad JSON */ }
|
|
}
|
|
} catch { /* localStorage access failed */ }
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function _saveDiscoveredKeypair(pubkeyHex, privkeyHex) {
|
|
try {
|
|
localStorage.setItem(LS_KEYPAIR_KEY, JSON.stringify({ pubkey: pubkeyHex, privkey: privkeyHex }));
|
|
} catch { /* storage full */ }
|
|
}
|
|
|
|
function _loadKeypair() {
|
|
const found = _scanExistingNostrKeys();
|
|
if (found) {
|
|
// If only npub found (no privkey), store pubkey but signing won't work
|
|
// (the prompt will offer extension or key generation for signing)
|
|
return found.pubkey;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function _generateAndSaveKeypair() {
|
|
const privkeyBytes = generateSecretKey();
|
|
const privkeyHex = bytesToHex(privkeyBytes);
|
|
const pubkeyHex = getPublicKey(privkeyBytes);
|
|
|
|
try {
|
|
localStorage.setItem(LS_KEYPAIR_KEY, JSON.stringify({ privkey: privkeyHex, pubkey: pubkeyHex }));
|
|
} catch { /* storage full or private browsing */ }
|
|
|
|
return pubkeyHex;
|
|
}
|
|
|
|
function _getPrivkeyBytes() {
|
|
try {
|
|
const stored = localStorage.getItem(LS_KEYPAIR_KEY);
|
|
if (!stored) return null;
|
|
const { privkey } = JSON.parse(stored);
|
|
return privkey ? hexToBytes(privkey) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Signing ───────────────────────────────────────────────────────────────────
|
|
|
|
async function _signNonce(nonce) {
|
|
// event.content must be the nonce string per server contract
|
|
const eventTemplate = {
|
|
kind: 27235,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [],
|
|
content: nonce,
|
|
};
|
|
|
|
if (_useNip07 && window.nostr) {
|
|
try {
|
|
return await window.nostr.signEvent(eventTemplate);
|
|
} catch (err) {
|
|
console.warn('[nostr] NIP-07 signEvent failed, trying local key', err);
|
|
_useNip07 = false;
|
|
}
|
|
}
|
|
|
|
const privkeyBytes = _getPrivkeyBytes();
|
|
if (!privkeyBytes) {
|
|
console.warn('[nostr] no private key available for signing');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return finalizeEvent({ ...eventTemplate, pubkey: _pubkey }, privkeyBytes);
|
|
} catch (err) {
|
|
console.warn('[nostr] finalizeEvent failed', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Token caching ─────────────────────────────────────────────────────────────
|
|
|
|
function _isTokenValid() {
|
|
if (!_token || !_tokenExp) return false;
|
|
return (Date.now() - _tokenExp) < TOKEN_TTL_MS;
|
|
}
|
|
|
|
function _saveCachedToken() {
|
|
try {
|
|
localStorage.setItem(LS_TOKEN_KEY, JSON.stringify({ token: _token, exp: _tokenExp }));
|
|
} catch { /* unavailable */ }
|
|
}
|
|
|
|
function _loadCachedToken() {
|
|
try {
|
|
const raw = localStorage.getItem(LS_TOKEN_KEY);
|
|
if (!raw) return;
|
|
const { token, exp } = JSON.parse(raw);
|
|
if (token && exp) { _token = token; _tokenExp = exp; }
|
|
} catch { /* ignore */ }
|
|
}
|