Files
timmy-tower/the-matrix/js/nostr-identity.js
2026-03-23 22:54:07 +00:00

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 */ }
}