Files
nostr_login_lite/packages/auth/src/modules/AuthNostrService.ts
Your Name 37fb89c0a9 first
2025-09-09 09:32:09 -04:00

719 lines
22 KiB
TypeScript

import { localStorageAddAccount, bunkerUrlToInfo, isBunkerUrl, fetchProfile, getBunkerUrl, localStorageRemoveCurrentAccount, createProfile, getIcon } from '../utils';
import { ConnectionString, Info } from 'nostr-login-components/dist/types/types';
import { generatePrivateKey, getEventHash, getPublicKey, nip19 } from 'nostr-tools';
import { NostrLoginAuthOptions, Response } from '../types';
import NDK, { NDKEvent, NDKNip46Signer, NDKRpcResponse, NDKUser, NostrEvent } from '@nostr-dev-kit/ndk';
import { NostrParams } from './';
import { EventEmitter } from 'tseep';
import { Signer } from './Nostr';
import { Nip44 } from '../utils/nip44';
import { IframeNostrRpc, Nip46Signer, ReadyListener } from './Nip46';
import { PrivateKeySigner } from './Signer';
const OUTBOX_RELAYS = ['wss://user.kindpag.es', 'wss://purplepag.es', 'wss://relay.nos.social'];
const DEFAULT_NOSTRCONNECT_RELAY = 'wss://relay.nsec.app/';
const NOSTRCONNECT_APPS: ConnectionString[] = [
{
name: 'Nsec.app',
domain: 'nsec.app',
canImport: true,
img: 'https://nsec.app/assets/favicon.ico',
link: 'https://use.nsec.app/<nostrconnect>',
relay: 'wss://relay.nsec.app/',
},
{
name: 'Amber',
img: 'https://raw.githubusercontent.com/greenart7c3/Amber/refs/heads/master/assets/android-icon.svg',
link: '<nostrconnect>',
relay: 'wss://relay.nsec.app/',
},
{
name: 'Other key stores',
img: '',
link: '<nostrconnect>',
relay: 'wss://relay.nsec.app/',
},
];
class AuthNostrService extends EventEmitter implements Signer {
private ndk: NDK;
private profileNdk: NDK;
private signer: Nip46Signer | null = null;
private localSigner: PrivateKeySigner | null = null;
private params: NostrParams;
private signerPromise?: Promise<void>;
private signerErrCallback?: (err: string) => void;
private readyPromise?: Promise<void>;
private readyCallback?: () => void;
private nip44Codec = new Nip44();
private nostrConnectKey: string = '';
private nostrConnectSecret: string = '';
private iframe?: HTMLIFrameElement;
private starterReady?: ReadyListener;
nip04: {
encrypt: (pubkey: string, plaintext: string) => Promise<string>;
decrypt: (pubkey: string, ciphertext: string) => Promise<string>;
};
nip44: {
encrypt: (pubkey: string, plaintext: string) => Promise<string>;
decrypt: (pubkey: string, ciphertext: string) => Promise<string>;
};
constructor(params: NostrParams) {
super();
this.params = params;
this.ndk = new NDK({
enableOutboxModel: false,
});
this.profileNdk = new NDK({
enableOutboxModel: true,
explicitRelayUrls: OUTBOX_RELAYS,
});
this.profileNdk.connect();
this.nip04 = {
encrypt: this.encrypt04.bind(this),
decrypt: this.decrypt04.bind(this),
};
this.nip44 = {
encrypt: this.encrypt44.bind(this),
decrypt: this.decrypt44.bind(this),
};
}
public isIframe() {
return !!this.iframe;
}
public async waitReady() {
if (this.signerPromise) {
try {
await this.signerPromise;
} catch {}
}
if (this.readyPromise) {
try {
await this.readyPromise;
} catch {}
}
}
public cancelNostrConnect() {
this.releaseSigner();
this.resetAuth();
}
public async nostrConnect(
relay?: string,
{
domain = '',
link = '',
iframeUrl = '',
importConnect = false,
}: {
domain?: string;
link?: string;
importConnect?: boolean;
iframeUrl?: string;
} = {},
) {
relay = relay || DEFAULT_NOSTRCONNECT_RELAY;
const info: Info = {
authMethod: 'connect',
pubkey: '', // unknown yet!
signerPubkey: '', // unknown too!
sk: this.nostrConnectKey,
domain: domain,
relays: [relay],
iframeUrl,
};
console.log('nostrconnect info', info, link);
// non-iframe flow
if (link && !iframeUrl) window.open(link, '_blank', 'width=400,height=700');
// init nip46 signer
await this.initSigner(info, { listen: true });
// signer learns the remote pubkey
if (!info.pubkey || !info.signerPubkey) throw new Error('Bad remote pubkey');
info.bunkerUrl = `bunker://${info.signerPubkey}?relay=${relay}`;
// callback
if (!importConnect) this.onAuth('login', info);
return info;
}
public async createNostrConnect(relay?: string) {
this.nostrConnectKey = generatePrivateKey();
this.nostrConnectSecret = Math.random().toString(36).substring(7);
const pubkey = getPublicKey(this.nostrConnectKey);
const meta = {
name: encodeURIComponent(document.location.host),
url: encodeURIComponent(document.location.origin),
icon: encodeURIComponent(await getIcon()),
perms: encodeURIComponent(this.params.optionsModal.perms || ''),
};
return `nostrconnect://${pubkey}?image=${meta.icon}&url=${meta.url}&name=${meta.name}&perms=${meta.perms}&secret=${this.nostrConnectSecret}${relay ? `&relay=${relay}` : ''}`;
}
public async getNostrConnectServices(): Promise<[string, ConnectionString[]]> {
const nostrconnect = await this.createNostrConnect();
// copy defaults
const apps = NOSTRCONNECT_APPS.map(a => ({ ...a }));
// if (this.params.optionsModal.dev) {
// apps.push({
// name: 'Dev.Nsec.app',
// domain: 'new.nsec.app',
// canImport: true,
// img: 'https://new.nsec.app/assets/favicon.ico',
// link: 'https://dev.nsec.app/<nostrconnect>',
// relay: 'wss://relay.nsec.app/',
// });
// }
for (const a of apps) {
let relay = DEFAULT_NOSTRCONNECT_RELAY;
if (a.link.startsWith('https://')) {
let domain = a.domain || new URL(a.link).hostname;
try {
const info = await (await fetch(`https://${domain}/.well-known/nostr.json`)).json();
const pubkey = info.names['_'];
const relays = info.nip46[pubkey] as string[];
if (relays && relays.length) relay = relays[0];
a.iframeUrl = info.nip46.iframe_url || '';
} catch (e) {
console.log('Bad app info', e, a);
}
}
const nc = nostrconnect + '&relay=' + relay;
if (a.iframeUrl) {
// pass plain nc url for iframe-based flow
a.link = nc;
} else {
// we will open popup ourselves
a.link = a.link.replace('<nostrconnect>', nc);
}
}
return [nostrconnect, apps];
}
public async localSignup(name: string, sk?: string) {
const signup = !sk;
sk = sk || generatePrivateKey();
const pubkey = getPublicKey(sk);
const info: Info = {
pubkey,
sk,
name,
authMethod: 'local',
};
console.log(`localSignup name: ${name}`);
await this.setLocal(info, signup);
}
public async setLocal(info: Info, signup?: boolean) {
this.releaseSigner();
this.localSigner = new PrivateKeySigner(info.sk!);
if (signup) await createProfile(info, this.profileNdk, this.localSigner, this.params.optionsModal.signupRelays, this.params.optionsModal.outboxRelays);
this.onAuth(signup ? 'signup' : 'login', info);
}
public prepareImportUrl(url: string) {
// for OTP we choose interactive import
if (this.params.userInfo?.authMethod === 'otp') return url + '&import=true';
// for local we export our existing key
if (!this.localSigner || this.params.userInfo?.authMethod !== 'local') throw new Error('Most be local keys');
return url + '#import=' + nip19.nsecEncode(this.localSigner.privateKey!);
}
public async importAndConnect(cs: ConnectionString) {
const { relay, domain, link, iframeUrl } = cs;
if (!domain) throw new Error('Domain required');
const info = await this.nostrConnect(relay, { domain, link, importConnect: true, iframeUrl });
// logout to remove local keys from storage
// but keep the connect signer
await this.logout(/*keepSigner*/ true);
// release local one
this.localSigner = null;
// notify app that we've switched to 'connect' keys
this.onAuth('login', info);
}
public setReadOnly(pubkey: string) {
const info: Info = { pubkey, authMethod: 'readOnly' };
this.onAuth('login', info);
}
public setExtension(pubkey: string) {
const info: Info = { pubkey, authMethod: 'extension' };
this.onAuth('login', info);
}
public setOTP(pubkey: string, data: string) {
const info: Info = { pubkey, authMethod: 'otp', otpData: data };
this.onAuth('login', info);
}
public async setConnect(info: Info) {
this.releaseSigner();
await this.startAuth();
await this.initSigner(info);
this.onAuth('login', info);
await this.endAuth();
}
public async createAccount(nip05: string) {
const [name, domain] = nip05.split('@');
// bunker's own url
const bunkerUrl = await getBunkerUrl(`_@${domain}`, this.params.optionsModal);
console.log("create account bunker's url", bunkerUrl);
// parse bunker url and generate local nsec
const info = bunkerUrlToInfo(bunkerUrl);
if (!info.signerPubkey) throw new Error('Bad bunker url');
const eventToAddAccount = Boolean(this.params.userInfo);
// init signer to talk to the bunker (not the user!)
await this.initSigner(info, { eventToAddAccount });
const userPubkey = await this.signer!.createAccount2({ bunkerPubkey: info.signerPubkey!, name, domain, perms: this.params.optionsModal.perms });
return {
bunkerUrl: `bunker://${userPubkey}?relay=${info.relays?.[0]}`,
sk: info.sk, // reuse the same local key
};
}
private releaseSigner() {
this.signer = null;
this.signerErrCallback?.('cancelled');
this.localSigner = null;
// disconnect from signer relays
for (const r of this.ndk.pool.relays.keys()) {
this.ndk.pool.removeRelay(r);
}
}
public async logout(keepSigner = false) {
if (!keepSigner) this.releaseSigner();
// move current to recent
localStorageRemoveCurrentAccount();
// notify everyone
this.onAuth('logout');
this.emit('updateAccounts');
}
private setUserInfo(userInfo: Info | null) {
this.params.userInfo = userInfo;
this.emit('onUserInfo', userInfo);
if (userInfo) {
localStorageAddAccount(userInfo);
this.emit('updateAccounts');
}
}
public exportKeys() {
if (!this.params.userInfo) return '';
if (this.params.userInfo.authMethod !== 'local') return '';
return nip19.nsecEncode(this.params.userInfo.sk!);
}
private onAuth(type: 'login' | 'signup' | 'logout', info: Info | null = null) {
if (type !== 'logout' && !info) throw new Error('No user info in onAuth');
// make sure we emulate logout first
if (info && this.params.userInfo && (info.pubkey !== this.params.userInfo.pubkey || info.authMethod !== this.params.userInfo.authMethod)) {
const event = new CustomEvent('nlAuth', { detail: { type: 'logout' } });
console.log('nostr-login auth', event.detail);
document.dispatchEvent(event)
}
this.setUserInfo(info);
if (info) {
// async profile fetch
fetchProfile(info, this.profileNdk).then(p => {
if (this.params.userInfo !== info) return;
const userInfo = {
...this.params.userInfo,
picture: p?.image || p?.picture,
name: p?.name || p?.displayName || p?.nip05 || nip19.npubEncode(info.pubkey),
// NOTE: do not overwrite info.nip05 with the one from profile!
// info.nip05 refers to nip46 provider,
// profile.nip05 is just a fancy name that user has chosen
// nip05: p?.nip05
};
this.setUserInfo(userInfo);
});
}
try {
const npub = info ? nip19.npubEncode(info.pubkey) : '';
const options: NostrLoginAuthOptions = {
type,
};
if (type === 'logout') {
// reset
if (this.iframe) this.iframe.remove();
this.iframe = undefined;
} else {
options.pubkey = info!.pubkey;
options.name = info!.name;
if (info!.sk) {
options.localNsec = nip19.nsecEncode(info!.sk);
}
if (info!.relays) {
options.relays = info!.relays;
}
if (info!.otpData) {
options.otpData = info!.otpData;
}
options.method = info!.authMethod || 'connect';
}
const event = new CustomEvent('nlAuth', { detail: options });
console.log('nostr-login auth', options);
document.dispatchEvent(event);
if (this.params.optionsModal.onAuth) {
this.params.optionsModal.onAuth(npub, options);
}
} catch (e) {
console.log('onAuth error', e);
}
}
private async createIframe(iframeUrl?: string) {
if (!iframeUrl) return undefined;
// ensure iframe
const url = new URL(iframeUrl);
const domain = url.hostname;
let iframe: HTMLIFrameElement | undefined;
// one iframe per domain
const did = domain.replaceAll('.', '-');
const id = '__nostr-login-worker-iframe-' + did;
iframe = document.querySelector(`#${id}`) as HTMLIFrameElement;
console.log('iframe', id, iframe);
if (!iframe) {
iframe = document.createElement('iframe');
iframe.setAttribute('width', '0');
iframe.setAttribute('height', '0');
iframe.setAttribute('border', '0');
iframe.style.display = 'none';
// iframe.setAttribute('sandbox', 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts');
iframe.id = id;
document.body.append(iframe);
}
// wait until loaded
iframe.setAttribute('src', iframeUrl);
// we start listening right now to avoid races
// with 'load' event below
const ready = new ReadyListener(['workerReady', 'workerError'], url.origin);
await new Promise(ok => {
iframe!.addEventListener('load', ok);
});
// now make sure the iframe is ready,
// timeout timer starts here
const r = await ready.wait();
// FIXME wait until the iframe is ready to accept requests,
// maybe it should send us some message?
console.log('nostr-login iframe ready', iframeUrl, r);
return { iframe, port: r[1] as MessagePort };
}
// private async getIframeUrl(domain?: string) {
// if (!domain) return '';
// try {
// const r = await fetch(`https://${domain}/.well-known/nostr.json`);
// const data = await r.json();
// return data.nip46?.iframe_url || '';
// } catch (e) {
// console.log('failed to fetch iframe url', e, domain);
// return '';
// }
// }
public async sendNeedAuth() {
const [nostrconnect] = await this.getNostrConnectServices();
const event = new CustomEvent('nlNeedAuth', { detail: { nostrconnect } });
console.log('nostr-login need auth', nostrconnect);
document.dispatchEvent(event);
}
public isAuthing() {
return !!this.readyCallback;
}
public async startAuth() {
console.log("startAuth");
if (this.readyCallback) throw new Error('Already started');
// start the new promise
this.readyPromise = new Promise<void>(ok => (this.readyCallback = ok));
}
public async endAuth() {
console.log('endAuth', this.params.userInfo);
if (this.params.userInfo && this.params.userInfo.iframeUrl) {
// create iframe
const { iframe, port } = (await this.createIframe(this.params.userInfo.iframeUrl)) || {};
this.iframe = iframe;
if (!this.iframe || !port) return;
// assign iframe to RPC object
(this.signer!.rpc as IframeNostrRpc).setWorkerIframePort(port);
}
this.readyCallback!();
this.readyCallback = undefined;
}
public resetAuth() {
if (this.readyCallback) this.readyCallback();
this.readyCallback = undefined;
}
private async listen(info: Info) {
if (!info.iframeUrl) return this.signer!.listen(this.nostrConnectSecret);
const r = await this.starterReady!.wait();
if (r[0] === 'starterError') throw new Error(r[1]);
return this.signer!.setListenReply(r[1], this.nostrConnectSecret);
}
public async connect(info: Info, perms?: string) {
return this.signer!.connect(info.token, perms);
}
public async initSigner(info: Info, { listen = false, connect = false, eventToAddAccount = false } = {}) {
// mutex
if (this.signerPromise) {
try {
await this.signerPromise;
} catch {}
}
// we remove support for iframe from nip05 and bunker-url methods,
// only nostrconnect flow will use it.
// info.iframeUrl = info.iframeUrl || (await this.getIframeUrl(info.domain));
console.log('initSigner info', info);
// start listening for the ready signal
const iframeOrigin = info.iframeUrl ? new URL(info.iframeUrl!).origin : undefined;
if (iframeOrigin) this.starterReady = new ReadyListener(['starterDone', 'starterError'], iframeOrigin);
// notify modals so they could show the starter iframe,
// FIXME shouldn't this come from nostrconnect service list?
this.emit('onIframeUrl', info.iframeUrl);
this.signerPromise = new Promise<void>(async (ok, err) => {
this.signerErrCallback = err;
try {
// pre-connect if we're creating the connection (listen|connect) or
// not iframe mode
if (info.relays && !info.iframeUrl) {
for (const r of info.relays) {
this.ndk.addExplicitRelay(r, undefined);
}
}
// wait until we connect, otherwise
// signer won't start properly
await this.ndk.connect();
// create and prepare the signer
const localSigner = new PrivateKeySigner(info.sk!);
this.signer = new Nip46Signer(this.ndk, localSigner, info.signerPubkey!, iframeOrigin);
// we should notify the banner the same way as
// the onAuthUrl does
this.signer.on(`iframeRestart`, async () => {
const iframeUrl = info.iframeUrl + (info.iframeUrl!.includes('?') ? '&' : '?') + 'pubkey=' + info.pubkey + '&rebind=' + localSigner.pubkey;
this.emit('iframeRestart', { pubkey: info.pubkey, iframeUrl });
});
// OAuth flow
// if (!listen) {
this.signer.on('authUrl', (url: string) => {
console.log('nostr login auth url', url);
// notify our UI
this.emit('onAuthUrl', { url, iframeUrl: info.iframeUrl, eventToAddAccount });
});
// }
if (listen) {
// nostrconnect: flow
// wait for the incoming message from signer
await this.listen(info);
} else if (connect) {
// bunker: flow
// send 'connect' message to signer
await this.connect(info, this.params.optionsModal.perms);
} else {
// provide saved pubkey as a hint
await this.signer!.initUserPubkey(info.pubkey);
}
// ensure, we're using it in callbacks above
// and expect info to be valid after this call
info.pubkey = this.signer!.userPubkey;
// learned after nostrconnect flow
info.signerPubkey = this.signer!.remotePubkey;
ok();
} catch (e) {
console.log('initSigner failure', e);
// make sure signer isn't set
this.signer = null;
err(e);
}
});
return this.signerPromise;
}
public async authNip46(
type: 'login' | 'signup',
{ name, bunkerUrl, sk = '', domain = '', iframeUrl = '' }: { name: string; bunkerUrl: string; sk?: string; domain?: string; iframeUrl?: string },
) {
try {
const info = bunkerUrlToInfo(bunkerUrl, sk);
if (isBunkerUrl(name)) info.bunkerUrl = name;
else {
info.nip05 = name;
info.domain = name.split('@')[1];
}
if (domain) info.domain = domain;
if (iframeUrl) info.iframeUrl = iframeUrl;
// console.log('nostr login auth info', info);
if (!info.signerPubkey || !info.sk || !info.relays?.[0]) {
throw new Error(`Bad bunker url ${bunkerUrl}`);
}
const eventToAddAccount = Boolean(this.params.userInfo);
console.log('authNip46', type, info);
// updates the info
await this.initSigner(info, { connect: true, eventToAddAccount });
// callback
this.onAuth(type, info);
} catch (e) {
console.log('nostr login auth failed', e);
// make ure it's closed
// this.popupManager.closePopup();
throw e;
}
}
public async signEvent(event: any) {
if (this.localSigner) {
event.pubkey = getPublicKey(this.localSigner.privateKey!);
event.id = getEventHash(event);
event.sig = await this.localSigner.sign(event);
} else {
event.pubkey = this.signer?.remotePubkey;
event.id = getEventHash(event);
event.sig = await this.signer?.sign(event);
}
console.log('signed', { event });
return event;
}
private async codec_call(method: string, pubkey: string, param: string) {
return new Promise<string>((resolve, reject) => {
this.signer!.rpc.sendRequest(this.signer!.remotePubkey!, method, [pubkey, param], 24133, (response: NDKRpcResponse) => {
if (!response.error) {
resolve(response.result);
} else {
reject(response.error);
}
});
});
}
public async encrypt04(pubkey: string, plaintext: string) {
if (this.localSigner) {
return this.localSigner.encrypt(new NDKUser({ pubkey }), plaintext);
} else {
return this.signer!.encrypt(new NDKUser({ pubkey }), plaintext);
}
}
public async decrypt04(pubkey: string, ciphertext: string) {
if (this.localSigner) {
return this.localSigner.decrypt(new NDKUser({ pubkey }), ciphertext);
} else {
// decrypt is broken in ndk v2.3.1, and latest
// ndk v2.8.1 doesn't allow to override connect easily,
// so we reimplement and fix decrypt here as a temporary fix
return this.codec_call('nip04_decrypt', pubkey, ciphertext);
}
}
public async encrypt44(pubkey: string, plaintext: string) {
if (this.localSigner) {
return this.nip44Codec.encrypt(this.localSigner.privateKey!, pubkey, plaintext);
} else {
// no support of nip44 in ndk yet
return this.codec_call('nip44_encrypt', pubkey, plaintext);
}
}
public async decrypt44(pubkey: string, ciphertext: string) {
if (this.localSigner) {
return this.nip44Codec.decrypt(this.localSigner.privateKey!, pubkey, ciphertext);
} else {
// no support of nip44 in ndk yet
return this.codec_call('nip44_decrypt', pubkey, ciphertext);
}
}
}
export default AuthNostrService;