This commit is contained in:
Your Name
2025-09-09 09:32:09 -04:00
commit 37fb89c0a9
135 changed files with 36437 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
{
"arrowParens": "avoid",
"bracketSpacing": true,
"jsxBracketSameLine": false,
"jsxSingleQuote": false,
"quoteProps": "consistent",
"printWidth": 180,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false
}

167
packages/auth/README.md Normal file
View File

@@ -0,0 +1,167 @@
Nostr-Login
===========
This library is a powerful `window.nostr` provider.
```
<script src='https://www.unpkg.com/nostr-login@latest/dist/unpkg.js'></script>
```
Just add the above script to your HTML and
get a nice UI for users to login with Nostr Connect (nip46), with an extension, read-only login,
account switching, OAuth-like sign up, etc. Your app just talks to the `window.nostr`, the rest is handled by `nostr-login`.
See it in action on [nostr.band](https://nostr.band).
## Options
You can set these attributes to the `script` tag to customize the behavior:
- `data-dark-mode` - `true`/`false`, default will use the browser's color theme
- `data-bunkers` - the comma-separated list of domain names of Nostr Connect (nip46) providers for sign up, i.e. `nsec.app,highlighter.com`
- `data-perms` - the comma-separated list of [permissions](https://github.com/nostr-protocol/nips/blob/master/46.md#requested-permissions) requested by the app over Nostr Connect, i.e. `sign_event:1,nip04_encrypt`
- `data-theme` - color themes, one of `default`, `ocean`, `lemonade`, `purple`
- `data-no-banner` - if `true`, do not show the `nostr-login` banner, will need to launch the modals using event dispatch, see below
- `data-methods` - comma-separated list of allowed auth methods, method names: `connect`, `extension`, `readOnly`, `local`, all allowed by default.
- `data-otp-request-url` - URL for requesting OTP code
- `data-otp-reply-url` - URL for replying with OTP code
- `data-title` - title for the welcome screen
- `data-description` - description for the welcome screen
- `data-start-screen` - screen shown by default (banner click, window.nostr.* call), options: `welcome`, `welcome-login`, `welcome-signup`, `signup`, `local-signup`, `login`, `otp`, `connect`, `login-bunker-url`, `login-read-only`, `connection-string`, `switch-account`, `import`
- `data-signup-relays` - comma-separated list of relays where nip65 event will be published on local signup
- `data-outbox-relays` - comma-separated list of relays that will be added to nip65 event on local signup
- `data-signup-nstart` - "true" to use start.njump.me instead of local signup
- `data-follow-npubs` - comma-separated list of npubs to follow if njump.me signup is used
Example:
```
<script src='https://www.unpkg.com/nostr-login@latest/dist/unpkg.js' data-perms="sign_event:1,sign_event:0" data-theme="ocean"></script>
```
## Updating the UI
Whenever user performs an auth-related action using `nostr-login`, a `nlAuth` event will be dispatched on the `document`, which you can listen
to in order to update your UI (show user profile, etc):
```
document.addEventListener('nlAuth', (e) => {
// type is login, signup or logout
if (e.detail.type === 'login' || e.detail.type === 'signup') {
onLogin(); // get pubkey with window.nostr and show user profile
} else {
onLogout() // clear local user data, hide profile info
}
})
```
## Launching, logout, etc
The `nostr-login` auth modals will be automatically launched whenever you
make a call to `window.nostr` if user isn't authed yet. However, you can also launch the auth flow by dispatching a custom `nlLaunch` event:
```
document.dispatchEvent(new CustomEvent('nlLaunch', { detail: 'welcome' }));
```
The `detail` event payload can be empty, or can be one of `welcome`, `signup`, `login`, `login-bunker-url`, `login-read-only`, `switch-account`.
To trigger logout in the `nostr-login`, you can dispatch a `nlLogout` event:
```
document.dispatchEvent(new Event("nlLogout"));
```
To change dark mode in the `nostr-login`, you can dispatch a `nlDarkMode` event, with detail as `darkMode` boolean:
```
document.dispatchEvent(new CustomEvent("nlDarkMode", { detail: true }));
```
## Use as a package
Install `nostr-login` package with `npm` and then:
```
import { init as initNostrLogin } from "nostr-login"
// make sure this is called before any
// window.nostr calls are made
initNostrLogin({/*options*/})
```
Now the `window.nostr` will be initialized and on your first call
to it the auth flow will be launched if user isn't authed yet.
You can also launch the auth flow yourself:
```
import { launch as launchNostrLoginDialog } from "nostr-login"
// make sure init() was called
// on your signup button click
function onSignupClick() {
// launch signup screen
launchNostrLoginDialog({
startScreen: 'signup'
})
}
```
### Next.js Fix for Server Side Rendering (SSR)
`nostr-login` calls `document` which is unavailable for server-side rendering. You will have build errors. To fix this, you can import `nostr-login` on the client side in your component with a `useEffect` like this:
```javascript
useEffect(() => {
import('nostr-login')
.then(async ({ init }) => {
init({
// options
})
})
.catch((error) => console.log('Failed to load nostr-login', error));
}, []);
```
Note: even if your component has `"use client"` in the first line, this fix still may be necessary.
---
API:
- `init(opts)` - set mapping of window.nostr to nostr-login
- `launch(startScreen)` - launch nostr-login UI
- `logout()` - drop the current nip46 connection
Options:
- `theme` - same as `data-theme` above
- `startScreen` - same as `startScreen` for `nlLaunch` event above
- `bunkers` - same as `data-bunkers` above
- `devOverrideBunkerOrigin` - for testing, overrides the bunker origin for local setup
- `onAuth: (npub: string, options: NostrLoginAuthOptions)` - a callback to provide instead of listening to `nlAuth` event
- `perms` - same as `data-perms` above
- `darkMode` - same as `data-dark-mode` above
- `noBanner` - same as `data-no-banner` above
- `isSignInWithExtension` - `true` to bring the *Sign in with exception* button into main list of options, `false` to hide to the *Advanced*, default will behave as `true` if extension is detected.
## OTP login
If you supply both `data-otp-request-url` and `data-otp-reply-url` then "Login with DM" button will appear on the welcome screen.
When user enters their nip05 or npub, a GET request is made to `<data-otp-request-url>[?&]pubkey=<user-pubkey>`. Server should send
a DM with one-time code to that pubkey and should return 200.
After user enters the code, a GET request is made to `<data-otp-reply-url>[?&]pubkey=<user-pubkey>&code=<code>`. Server should check that code matches the pubkey and hasn't expired, and should return 200 status and an optional payload. Nostr-login will deliver the payload as `otpData` field in `nlAuth` event, and will save the payload in localstore and will deliver it again as `nlAuth` on page reload.
The reply payload may be used to supply the session token. If token is sent by the server as a cookie then payload might be empty, otherwise the payload should be used by the app to extract the token and use it in future API calls to the server.
## Examples
* [Basic HTML Example](./examples/usage.html)
## TODO
- fetch bunker list using NIP-89
- Amber support
- allow use without the UIs
- add timeout handling
- more at [issues](https://github.com/nostrband/nostr-login/issues)

30
packages/auth/index.html Normal file
View File

@@ -0,0 +1,30 @@
<!doctype html>
<html dir="ltr" lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0" />
<title>Modal Auth Demo</title>
<script type="module">
// import { init } from './dist/index.esm.js';
//
// const test = async () => {
// const bunkerUrl = await launch({
// theme: 'purple',
// startScreen: 'signup',
// });
//
// console.log(bunkerUrl);
// };
</script>
<script src="./dist/unpkg.js" data-start-screen="local-signup"
data-signup-relays="wss://relay.nostr.band/,wss://relay.primal.net"></script>
</head>
<body>
<!--<nl-button title-btn="Sign in" start-screen="login" nl-theme="lemonade"></nl-button>-->
</body>
</html>

View File

@@ -0,0 +1,28 @@
{
"name": "nostr-login",
"version": "1.7.11",
"description": "",
"main": "./dist/index.esm.js",
"types": "./dist/index.d.ts",
"type": "module",
"scripts": {
"build": "rollup -c",
"format": "npx prettier --write src"
},
"author": "a-fralou",
"dependencies": {
"@nostr-dev-kit/ndk": "^2.3.1",
"nostr-tools": "^1.17.0",
"tseep": "^1.2.1"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"nostr-login-components": "^1.0.3",
"prettier": "^3.2.2",
"rollup": "^4.9.6",
"rollup-plugin-typescript2": "^0.36.0"
},
"license": "MIT"
}

View File

@@ -0,0 +1,55 @@
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import typescript from 'rollup-plugin-typescript2';
import terser from '@rollup/plugin-terser';
export default [
{
input: 'src/index.ts',
output: [
{
file: 'dist/index.esm.js',
format: 'esm',
sourcemap: true,
},
],
plugins: [
typescript({
tsconfig: 'tsconfig.json',
}),
resolve({
browser: true
}),
commonjs(),
terser({
compress: {
toplevel: true,
}
})
],
},
{
input: 'src/iife-module.ts',
output: [
{
file: 'dist/unpkg.js',
format: 'iife',
}
],
plugins: [
typescript({
tsconfig: 'tsconfig.json',
}),
resolve({
browser: true
}),
commonjs(),
terser({
compress: {
toplevel: true,
},
})
],
}
];

View File

@@ -0,0 +1 @@
export const CALL_TIMEOUT = 5000;

View File

@@ -0,0 +1,81 @@
import { init } from './index';
import { NostrLoginOptions, StartScreens } from './types';
// wrap to hide local vars
(() => {
// currentScript only visible in global scope code, not event handlers
const cs = document.currentScript;
const start = async () => {
const options: NostrLoginOptions = {};
if (cs) {
const dm = cs.getAttribute('data-dark-mode');
if (dm) options.darkMode = dm === 'true';
const bunkers = cs.getAttribute('data-bunkers');
if (bunkers) options.bunkers = bunkers;
const startScreen = cs.getAttribute('data-start-screen');
if (startScreen) options.startScreen = startScreen as StartScreens;
const perms = cs.getAttribute('data-perms');
if (perms) options.perms = perms;
const theme = cs.getAttribute('data-theme');
if (theme) options.theme = theme;
const noBanner = cs.getAttribute('data-no-banner');
if (noBanner) options.noBanner = noBanner === 'true';
const localSignup = cs.getAttribute('data-local-signup');
if (localSignup) options.localSignup = localSignup === 'true';
const signupNjump = cs.getAttribute('data-signup-nstart') || cs.getAttribute('data-signup-njump');
if (signupNjump) options.signupNstart = signupNjump === 'true';
const followNpubs = cs.getAttribute('data-follow-npubs');
if (followNpubs) options.followNpubs = followNpubs;
const otpRequestUrl = cs.getAttribute('data-otp-request-url');
if (otpRequestUrl) options.otpRequestUrl = otpRequestUrl;
const otpReplyUrl = cs.getAttribute('data-otp-reply-url');
if (otpReplyUrl) options.otpReplyUrl = otpReplyUrl;
if (!!otpRequestUrl !== !!otpReplyUrl) console.warn('nostr-login: need request and reply urls for OTP auth');
const methods = cs.getAttribute('data-methods');
if (methods) {
// @ts-ignore
options.methods = methods
.trim()
.split(',')
.filter(m => !!m);
}
const title = cs.getAttribute('data-title');
if (title) options.title = title;
const description = cs.getAttribute('data-description');
if (description) options.description = description;
const signupRelays = cs.getAttribute('data-signup-relays');
if (signupRelays) options.signupRelays = signupRelays;
const outboxRelays = cs.getAttribute('data-outbox-relays');
if (outboxRelays) options.outboxRelays = outboxRelays.split(',');
const dev = cs.getAttribute('data-dev') === 'true';
if (dev) options.dev = dev;
const custom = cs.getAttribute('data-custom-nostr-connect') === 'true';
if (custom) options.customNostrConnect = custom;
console.log('nostr-login options', options);
}
init(options);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
})();

347
packages/auth/src/index.ts Normal file
View File

@@ -0,0 +1,347 @@
import 'nostr-login-components';
import { AuthNostrService, NostrExtensionService, Popup, NostrParams, Nostr, ProcessManager, BannerManager, ModalManager } from './modules';
import { NostrLoginAuthOptions, NostrLoginOptions, StartScreens } from './types';
import { localStorageGetAccounts, localStorageGetCurrent, localStorageGetRecents, localStorageSetItem } from './utils';
import { Info } from 'nostr-login-components/dist/types/types';
import { NostrObjectParams } from './modules/Nostr';
export class NostrLoginInitializer {
public extensionService: NostrExtensionService;
public params: NostrParams;
public authNostrService: AuthNostrService;
public nostr: Nostr;
public processManager: ProcessManager;
public popupManager: Popup;
public bannerManager: BannerManager;
public modalManager: ModalManager;
private customLaunchCallback?: () => void;
constructor() {
this.params = new NostrParams();
this.processManager = new ProcessManager();
this.popupManager = new Popup();
this.bannerManager = new BannerManager(this.params);
this.authNostrService = new AuthNostrService(this.params);
this.extensionService = new NostrExtensionService(this.params);
this.modalManager = new ModalManager(this.params, this.authNostrService, this.extensionService);
const nostrApi: NostrObjectParams = {
waitReady: async () => {
await this.authNostrService.waitReady();
await this.modalManager.waitReady();
},
getUserInfo: () => this.params.userInfo,
getSigner: () => {
if (this.params.userInfo!.authMethod === 'readOnly') throw new Error('Read only');
return this.params.userInfo!.authMethod === 'extension' ? this.extensionService.getExtension() : this.authNostrService;
},
launch: () => {
return this.launch();
},
wait: cb => this.processManager.wait(cb),
};
this.nostr = new Nostr(nostrApi);
this.processManager.on('onCallTimeout', () => {
this.bannerManager.onCallTimeout();
});
this.processManager.on('onCallEnd', () => {
this.bannerManager.onCallEnd();
this.modalManager.onCallEnd();
});
this.processManager.on('onCallStart', () => {
this.bannerManager.onCallStart();
});
this.authNostrService.on('onIframeUrl', url => {
this.modalManager.onIframeUrl(url);
});
this.authNostrService.on('iframeRestart', ({ iframeUrl }) => {
this.processManager.onIframeUrl();
this.bannerManager.onIframeRestart(iframeUrl);
});
this.authNostrService.on('onAuthUrl', ({ url, iframeUrl, eventToAddAccount }) => {
this.processManager.onAuthUrl();
if (eventToAddAccount) {
this.modalManager.onAuthUrl(url);
return;
}
if (this.params.userInfo) {
// show the 'Please confirm' banner
this.bannerManager.onAuthUrl(url, iframeUrl);
} else {
// if it fails we will either return 'failed'
// to the window.nostr caller, or show proper error
// in our modal
this.modalManager.onAuthUrl(url);
}
});
this.authNostrService.on('updateAccounts', () => {
this.updateAccounts();
});
this.authNostrService.on('onUserInfo', info => {
this.bannerManager.onUserInfo(info);
});
this.modalManager.on('onAuthUrlClick', url => {
this.openPopup(url);
});
this.bannerManager.on('onIframeAuthUrlClick', url => {
this.modalManager.showIframeUrl(url);
});
this.modalManager.on('onSwitchAccount', async (info: Info) => {
this.switchAccount(info);
});
this.modalManager.on('onLogoutBanner', async (info: Info) => {
logout();
});
this.bannerManager.on('onConfirmLogout', async () => {
// @ts-ignore
this.launch('confirm-logout');
});
this.modalManager.on('updateAccounts', () => {
this.updateAccounts();
});
this.bannerManager.on('logout', () => {
logout();
});
this.bannerManager.on('onAuthUrlClick', url => {
this.openPopup(url);
});
this.bannerManager.on('onSwitchAccount', async (info: Info) => {
this.switchAccount(info);
});
this.bannerManager.on('import', () => {
this.launch('import');
});
this.extensionService.on('extensionLogin', (pubkey: string) => {
this.authNostrService.setExtension(pubkey);
});
this.extensionService.on('extensionLogout', () => {
logout();
});
this.bannerManager.on('launch', (startScreen?: StartScreens) => {
this.launch(startScreen);
});
}
private openPopup(url: string) {
this.popupManager.openPopup(url);
}
private async switchAccount(info: Info, signup = false) {
console.log('nostr login switch to info', info);
// make sure extension is unlinked
this.extensionService.unsetExtension(this.nostr);
if (info.authMethod === 'readOnly') {
this.authNostrService.setReadOnly(info.pubkey);
} else if (info.authMethod === 'otp') {
this.authNostrService.setOTP(info.pubkey, info.otpData || '');
} else if (info.authMethod === 'local' && info.sk) {
this.authNostrService.setLocal(info, signup);
} else if (info.authMethod === 'extension') {
// trySetExtensionForPubkey will check if
// we still have the extension and it's the same pubkey
await this.extensionService.trySetExtensionForPubkey(info.pubkey);
} else if (info.authMethod === 'connect' && info.sk && info.relays && info.relays[0]) {
this.authNostrService.setConnect(info);
} else {
throw new Error('Bad auth info');
}
}
private updateAccounts() {
const accounts = localStorageGetAccounts();
const recents = localStorageGetRecents();
this.bannerManager.onUpdateAccounts(accounts);
this.modalManager.onUpdateAccounts(accounts, recents);
}
public async launchCustomNostrConnect() {
try {
if (this.authNostrService.isAuthing()) this.authNostrService.cancelNostrConnect();
const customLaunchPromise = new Promise<void>(ok => (this.customLaunchCallback = ok));
await this.authNostrService.startAuth();
await this.authNostrService.sendNeedAuth();
try {
await this.authNostrService.nostrConnect();
await this.authNostrService.endAuth();
} catch (e) {
// if client manually launches the UI we'll
// have cancelled error from the nostrConnect call,
// and that's when we should block on the customLaunchPromise
if (e === 'cancelled') await customLaunchPromise;
}
} catch (e) {
console.error('launchCustomNostrConnect', e);
}
}
private fulfillCustomLaunchPromise() {
if (this.customLaunchCallback) {
const cb = this.customLaunchCallback;
this.customLaunchCallback = undefined;
cb();
}
}
public launch = async (startScreen?: StartScreens | 'default') => {
if (!startScreen) {
if (this.params.optionsModal.customNostrConnect) {
return this.launchCustomNostrConnect();
}
}
const recent = localStorageGetRecents();
const accounts = localStorageGetAccounts();
const options = { ...this.params.optionsModal };
if (startScreen && startScreen !== 'default') options.startScreen = startScreen;
else if (Boolean(recent?.length) || Boolean(accounts?.length)) {
options.startScreen = 'switch-account';
}
// if we're being manually called in the middle of customNostrConnect
// flow then we'll reset the current auth session and launch
// our manual flow and then release the customNostrConnect session
// as if it finished properly
if (this.customLaunchCallback) this.authNostrService.cancelNostrConnect();
try {
await this.modalManager.launch(options);
// if custom launch was interrupted by manual
// launch then we unlock the custom launch to make
// it proceed
this.fulfillCustomLaunchPromise();
} catch (e) {
// don't throw if cancelled
console.log('nostr-login failed', e);
}
};
public init = async (opt: NostrLoginOptions) => {
// watch for extension trying to overwrite our window.nostr
this.extensionService.startCheckingExtension(this.nostr);
// set ourselves as nostr
// @ts-ignore
window.nostr = this.nostr;
// connect launching of our modals to nl-button elements
this.modalManager.connectModals(opt);
// launch
this.bannerManager.launchAuthBanner(opt);
// store options
if (opt) {
this.params.optionsModal = { ...opt };
}
try {
// read conf from localstore
const info = localStorageGetCurrent();
// have current session?
if (info) {
// wtf?
if (!info.pubkey) throw new Error('Bad stored info');
// switch to it
await this.switchAccount(info);
}
} catch (e) {
console.log('nostr login init error', e);
await logout();
}
// ensure current state
this.updateAccounts();
};
public logout = async () => {
// replace back
this.extensionService.unsetExtension(this.nostr);
await this.authNostrService.logout();
};
public setDarkMode = (dark: boolean) => {
localStorageSetItem('nl-dark-mode', `${dark}`);
this.bannerManager.onDarkMode(dark);
this.modalManager.onDarkMode(dark);
};
public setAuth = async (o: NostrLoginAuthOptions) => {
if (!o.type) throw new Error('Invalid auth event');
if (o.type !== 'login' && o.type !== 'logout' && o.type !== 'signup') throw new Error('Invalid auth event');
if (o.method && o.method !== 'connect' && o.method !== 'extension' && o.method !== 'local' && o.method !== 'otp' && o.method !== 'readOnly')
throw new Error('Invalid auth event');
if (o.type === 'logout') return this.logout();
if (!o.method || !o.pubkey) throw new Error('Invalid pubkey');
const info: Info = {
authMethod: o.method,
pubkey: o.pubkey,
relays: o.relays,
sk: o.localNsec,
otpData: o.otpData,
name: o.name,
};
await this.switchAccount(info, o.type === 'signup');
};
public cancelNeedAuth = () => {
console.log("cancelNeedAuth");
this.fulfillCustomLaunchPromise();
this.authNostrService.cancelNostrConnect();
};
}
const initializer = new NostrLoginInitializer();
export const { init, launch, logout, setDarkMode, setAuth, cancelNeedAuth } = initializer;
document.addEventListener('nlLogout', logout);
document.addEventListener('nlLaunch', (event: any) => {
launch(event.detail || '');
});
document.addEventListener('nlNeedAuthCancel', () => {
cancelNeedAuth();
});
document.addEventListener('nlDarkMode', (event: any) => {
setDarkMode(!!event.detail);
});
document.addEventListener('nlSetAuth', (event: any) => {
setAuth(event.detail as NostrLoginAuthOptions);
});

View File

@@ -0,0 +1,718 @@
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;

View File

@@ -0,0 +1,146 @@
import { NostrLoginOptions, TypeBanner } from '../types';
import { NostrParams } from '.';
import { Info } from 'nostr-login-components/dist/types/types';
import { EventEmitter } from 'tseep';
import { getDarkMode } from '../utils';
import { ReadyListener } from './Nip46';
class BannerManager extends EventEmitter {
private banner: TypeBanner | null = null;
private iframeReady?: ReadyListener;
private params: NostrParams;
constructor(params: NostrParams) {
super();
this.params = params;
}
public onAuthUrl(url: string, iframeUrl: string) {
if (this.banner) {
if (url)
this.banner.notify = {
mode: iframeUrl ? 'iframeAuthUrl' : 'authUrl',
url,
};
else
this.banner.notify = {
mode: '',
};
}
}
public onIframeRestart(iframeUrl: string) {
if (this.banner) {
this.iframeReady = new ReadyListener(['rebinderDone', 'rebinderError'], new URL(iframeUrl).origin);
this.banner.notify = {
mode: 'rebind',
url: iframeUrl,
};
}
}
public onUserInfo(info: Info | null) {
if (this.banner) {
this.banner.userInfo = info;
}
}
public onCallTimeout() {
if (this.banner) {
this.banner.notify = {
mode: 'timeout',
};
}
}
public onCallStart() {
if (this.banner) {
this.banner.isLoading = true;
}
}
public async onCallEnd() {
if (this.banner) {
if (this.iframeReady) {
await this.iframeReady.wait();
this.iframeReady = undefined;
}
this.banner.isLoading = false;
this.banner.notify = { mode: '' };
}
}
public onUpdateAccounts(accounts: Info[]) {
if (this.banner) {
this.banner.accounts = accounts;
}
}
public onDarkMode(dark: boolean) {
if (this.banner) this.banner.darkMode = dark;
}
public launchAuthBanner(opt: NostrLoginOptions) {
this.banner = document.createElement('nl-banner');
this.banner.setAttribute('dark-mode', String(getDarkMode(opt)));
if (opt.theme) this.banner.setAttribute('theme', opt.theme);
if (opt.noBanner) this.banner.setAttribute('hidden-mode', 'true');
this.banner.addEventListener('handleLoginBanner', (event: any) => {
this.emit('launch', event.detail);
});
this.banner.addEventListener('handleConfirmLogout', () => {
this.emit('onConfirmLogout');
});
this.banner.addEventListener('handleLogoutBanner', async () => {
this.emit('logout');
});
this.banner.addEventListener('handleImportModal', (event: any) => {
this.emit('import');
});
this.banner.addEventListener('handleNotifyConfirmBanner', (event: any) => {
this.emit('onAuthUrlClick', event.detail);
});
this.banner.addEventListener('handleNotifyConfirmBannerIframe', (event: any) => {
this.emit('onIframeAuthUrlClick', event.detail);
});
this.banner.addEventListener('handleSwitchAccount', (event: any) => {
this.emit('onSwitchAccount', event.detail);
});
this.banner.addEventListener('handleOpenWelcomeModal', () => {
this.emit('launch');
if (this.banner) {
this.banner.isOpen = false;
}
});
// this.banner.addEventListener('handleRetryConfirmBanner', () => {
// const url = this.listNotifies.pop();
// // FIXME go to nip05 domain?
// if (!url) {
// return;
// }
// if (this.banner) {
// this.banner.listNotifies = this.listNotifies;
// }
// this.emit('onAuthUrlClick', url);
// });
document.body.appendChild(this.banner);
}
}
export default BannerManager;

View File

@@ -0,0 +1,635 @@
import { NostrLoginOptions, StartScreens, TypeModal } from '../types';
import { checkNip05, getBunkerUrl, getDarkMode, localStorageRemoveRecent, localStorageSetItem, prepareSignupRelays } from '../utils';
import { AuthNostrService, NostrExtensionService, NostrParams } from '.';
import { EventEmitter } from 'tseep';
import { ConnectionString, Info, RecentType } from 'nostr-login-components/dist/types/types';
import { nip19 } from 'nostr-tools';
import { setDarkMode } from '..';
class ModalManager extends EventEmitter {
private modal: TypeModal | null = null;
private params: NostrParams;
private extensionService: NostrExtensionService;
private authNostrService: AuthNostrService;
private launcherPromise?: Promise<void>;
private accounts: Info[] = [];
private recents: RecentType[] = [];
private opt?: NostrLoginOptions;
constructor(params: NostrParams, authNostrService: AuthNostrService, extensionManager: NostrExtensionService) {
super();
this.params = params;
this.extensionService = extensionManager;
this.authNostrService = authNostrService;
}
public async waitReady() {
if (this.launcherPromise) {
try {
await this.launcherPromise;
} catch {}
this.launcherPromise = undefined;
}
}
public async launch(opt: NostrLoginOptions) {
console.log('nostr-login launch', opt);
// mutex
if (this.launcherPromise) await this.waitReady();
// hmm?!
if (this.authNostrService.isAuthing()) this.authNostrService.resetAuth();
this.opt = opt;
const dialog = document.createElement('dialog');
this.modal = document.createElement('nl-auth');
this.modal.accounts = this.accounts;
this.modal.recents = this.recents;
this.modal.setAttribute('dark-mode', String(getDarkMode(opt)));
if (opt.theme) {
this.modal.setAttribute('theme', opt.theme);
}
if (opt.startScreen) {
this.modal.setAttribute('start-screen', opt.startScreen);
}
if (opt.bunkers) {
this.modal.setAttribute('bunkers', opt.bunkers);
} else {
let bunkers = 'nsec.app,highlighter.com';
// if (opt.dev) bunkers += ',new.nsec.app';
this.modal.setAttribute('bunkers', bunkers);
}
if (opt.methods !== undefined) {
this.modal.authMethods = opt.methods;
}
if (opt.localSignup !== undefined) {
this.modal.localSignup = opt.localSignup;
}
if (opt.signupNstart !== undefined) {
this.modal.signupNjump = opt.signupNstart;
}
if (opt.title) {
this.modal.welcomeTitle = opt.title;
}
if (opt.description) {
this.modal.welcomeDescription = opt.description;
}
this.modal.hasExtension = this.extensionService.hasExtension();
this.modal.hasOTP = !!opt.otpRequestUrl && !!opt.otpReplyUrl;
this.modal.isLoadingExtension = false;
this.modal.isLoading = false;
[this.modal.connectionString, this.modal.connectionStringServices] = await this.authNostrService.getNostrConnectServices();
dialog.appendChild(this.modal);
document.body.appendChild(dialog);
let otpPubkey = '';
this.launcherPromise = new Promise<void>((ok, err) => {
dialog.addEventListener('close', () => {
// noop if already resolved
err(new Error('Closed'));
this.authNostrService.resetAuth();
if (this.modal) {
// it's reset on modal creation
// // reset state
// this.modal.isLoading = false;
// this.modal.authUrl = '';
// this.modal.iframeUrl = '';
// this.modal.error = '';
// this.modal.isLoadingExtension = false;
// drop it
// @ts-ignore
document.body.removeChild(this.modal.parentNode);
this.modal = null;
}
});
const done = async (ok: () => void) => {
if (this.modal) this.modal.isLoading = false;
await this.authNostrService.endAuth();
dialog.close();
this.modal = null;
ok();
};
const exec = async (
body: () => Promise<void>,
options?: {
start?: boolean;
end?: boolean;
},
) => {
if (this.modal) {
this.modal.isLoading = true;
}
try {
if (!options || options.start) await this.authNostrService.startAuth();
await body();
if (!options || options.end) await done(ok);
} catch (e: any) {
console.log('error', e);
if (this.modal) {
this.modal.isLoading = false;
this.modal.authUrl = '';
this.modal.iframeUrl = '';
if (e !== 'cancelled') this.modal.error = e.toString();
}
}
};
const login = async (name: string, domain?: string) => {
await exec(async () => {
// convert name to bunker url
const bunkerUrl = await getBunkerUrl(name, this.params.optionsModal);
// connect to bunker by url
await this.authNostrService.authNip46('login', { name, bunkerUrl, domain });
});
};
const signup = async (name: string) => {
await exec(async () => {
// create acc on service and get bunker url
const { bunkerUrl, sk } = await this.authNostrService.createAccount(name);
// connect to bunker by url
await this.authNostrService.authNip46('signup', { name, bunkerUrl, sk });
});
};
const exportKeys = async () => {
try {
await navigator.clipboard.writeText(this.authNostrService.exportKeys());
localStorageSetItem('backupKey', 'true');
} catch (err) {
console.error('Failed to copy to clipboard: ', err);
}
};
const importKeys = async (cs: ConnectionString) => {
await exec(async () => {
const { iframeUrl } = cs;
cs.link = this.authNostrService.prepareImportUrl(cs.link);
if (this.modal && iframeUrl) {
// we pass the link down to iframe so it could open it
this.modal.authUrl = cs.link;
this.modal.iframeUrl = iframeUrl;
this.modal.isLoading = false;
console.log('nostrconnect authUrl', this.modal.authUrl, this.modal.iframeUrl);
}
await this.authNostrService.importAndConnect(cs);
});
};
const nostrConnect = async (cs?: ConnectionString) => {
await exec(async () => {
const { relay, domain, link, iframeUrl } = cs || {};
console.log('nostrConnect', cs, relay, domain, link, iframeUrl);
if (this.modal) {
if (iframeUrl) {
// we pass the link down to iframe so it could open it
this.modal.authUrl = link;
this.modal.iframeUrl = iframeUrl;
this.modal.isLoading = false;
console.log('nostrconnect authUrl', this.modal.authUrl, this.modal.iframeUrl);
}
if (!cs) this.modal.isLoading = false;
}
await this.authNostrService.nostrConnect(relay, { domain, link, iframeUrl });
});
};
const localSignup = async (name?: string) => {
await exec(async () => {
if (!name) throw new Error('Please enter some nickname');
await this.authNostrService.localSignup(name);
});
};
const signupNjump = async () => {
await exec(async () => {
const self = new URL(window.location.href);
const name =
self.hostname
.toLocaleLowerCase()
.replace(/^www\./i, '')
.charAt(0)
.toUpperCase() + self.hostname.slice(1);
const relays = prepareSignupRelays(this.params.optionsModal.signupRelays);
// const url = `https://start.njump.me/?an=${name}&at=popup&ac=${window.location.href}&s=${this.opt!.followNpubs || ''}&arr=${relays}&awr=${relays}`;
// console.log('njump url', url);
this.modal!.njumpIframe = `
<html><body>
<script src='https://start.njump.me/modal.js'></script>
<script>
new NstartModal({
baseUrl: 'https://start.njump.me',
// Required parameters
an: '${name}',
// Optional parameters
s: [${this.opt!.followNpubs ? `'${this.opt!.followNpubs}'` : ''}],
afb: false, // forceBunker
asb: false, // skipBunker
aan: false, // avoidNsec
aac: true, // avoidNcryptsec
ahc: true, // hide close button
arr: ${JSON.stringify(relays)}, //readRelays
awr: ${JSON.stringify(relays)}, //writeRelays
// Callbacks
onComplete: (result) => {
console.log('Login token:', result.nostrLogin);
window.parent.location.href='${window.location.href}#nostr-login='+result.nostrLogin;
},
onCancel: () => {
window.parent.location.href='${window.location.href}#nostr-login=null';
},
}).open();
</script>
</body></html>
`.replaceAll('&', '&amp;'); // needed?
return new Promise((ok, err) => {
const process = async (nsecOrBunker: string) => {
// process the returned value
console.log('nsecOrBunker', nsecOrBunker);
if (nsecOrBunker.startsWith('nsec1')) {
let decoded;
try {
decoded = nip19.decode(nsecOrBunker);
} catch (e) {
throw new Error('Bad nsec value');
}
if (decoded.type !== 'nsec') throw new Error('Bad bech32 type');
await this.authNostrService.localSignup('', decoded.data);
ok();
} else if (nsecOrBunker.startsWith('bunker:')) {
await this.authNostrService.authNip46('login', { name: '', bunkerUrl: nsecOrBunker });
ok();
} else if (nsecOrBunker === 'null') {
err('Cancelled');
} else {
err('Unknown return value');
}
};
const onOpen = async () => {
if (window.location.hash.startsWith('#nostr-login=')) {
const nsecOrBunker = window.location.hash.split('#nostr-login=')[1];
// clear hash from history
const url = new URL(window.location.toString());
url.hash = '';
window.history.replaceState({}, '', url.toString());
process(nsecOrBunker);
}
};
// // use random 'target' to make sure window.opener is
// // accessible to the popup
// window.open(url, '' + Date.now(), 'popup=true,width=600,height=950');
window.addEventListener('hashchange', onOpen);
});
});
};
if (!this.modal) throw new Error('WTH?');
this.modal.addEventListener('handleContinue', () => {
if (this.modal) {
this.modal.isLoading = true;
this.emit('onAuthUrlClick', this.modal.authUrl);
}
});
this.modal.addEventListener('nlLogin', (event: any) => {
login(event.detail);
});
this.modal.addEventListener('nlSignup', (event: any) => {
signup(event.detail);
});
this.modal.addEventListener('nlLocalSignup', (event: any) => {
localSignup(event.detail);
});
this.modal.addEventListener('nlSignupNjump', (event: any) => {
signupNjump();
});
this.modal.addEventListener('nlImportAccount', (event: any) => {
importKeys(event.detail);
});
this.modal.addEventListener('nlExportKeys', (event: any) => {
exportKeys();
});
this.modal.addEventListener('handleLogoutBanner', () => {
this.emit('onLogoutBanner');
});
this.modal.addEventListener('nlNostrConnect', (event: any) => {
nostrConnect(event.detail);
});
this.modal.addEventListener('nlNostrConnectDefault', () => {
// dedup the calls
if (!this.authNostrService.isAuthing()) nostrConnect();
});
this.modal.addEventListener('nlNostrConnectDefaultCancel', () => {
console.log('nlNostrConnectDefaultCancel');
this.authNostrService.cancelNostrConnect();
});
this.modal.addEventListener('nlSwitchAccount', (event: any) => {
const eventInfo: Info = event.detail as Info;
this.emit('onSwitchAccount', eventInfo);
// wait a bit, if dialog closes before
// switching finishes then launched promise rejects
// FIXME this calls resetAuth which then prevents
// endAuth from getting properly called. 300 is not
// enough to init iframe, so there should be a
// feedback from switchAccount here
setTimeout(() => dialog.close(), 300);
});
this.modal.addEventListener('nlLoginRecentAccount', async (event: any) => {
const userInfo: Info = event.detail as Info;
if (userInfo.authMethod === 'readOnly') {
this.authNostrService.setReadOnly(userInfo.pubkey);
dialog.close();
} else if (userInfo.authMethod === 'otp') {
try {
this.modal!.dispatchEvent(
new CustomEvent('nlLoginOTPUser', {
detail: userInfo.nip05 || userInfo.pubkey,
}),
);
} catch (e) {
console.error(e);
}
} else if (userInfo.authMethod === 'extension') {
await this.extensionService.trySetExtensionForPubkey(userInfo.pubkey);
dialog.close();
} else {
const input = userInfo.bunkerUrl || userInfo.nip05;
if (!input) throw new Error('Bad connect info');
login(input, userInfo.domain);
}
});
this.modal.addEventListener('nlRemoveRecent', (event: any) => {
localStorageRemoveRecent(event.detail as RecentType);
this.emit('updateAccounts');
});
const nameToPubkey = async (nameNpub: string) => {
let pubkey = '';
if (nameNpub.includes('@')) {
const { error, pubkey: nip05pubkey } = await checkNip05(nameNpub);
if (nip05pubkey) pubkey = nip05pubkey;
else throw new Error(error);
} else if (nameNpub.startsWith('npub')) {
const { type, data } = nip19.decode(nameNpub);
if (type === 'npub') pubkey = data as string;
else throw new Error('Bad npub');
} else if (nameNpub.trim().length === 64) {
pubkey = nameNpub.trim();
nip19.npubEncode(pubkey); // check
}
return pubkey;
};
this.modal.addEventListener('nlLoginReadOnly', async (event: any) => {
await exec(async () => {
const nameNpub = event.detail;
const pubkey = await nameToPubkey(nameNpub);
this.authNostrService.setReadOnly(pubkey);
});
});
this.modal.addEventListener('nlLoginExtension', async () => {
if (!this.extensionService.hasExtension()) {
throw new Error('No extension');
}
await exec(async () => {
if (!this.modal) return;
this.modal.isLoadingExtension = true;
await this.extensionService.setExtension();
this.modal.isLoadingExtension = false;
});
});
this.modal.addEventListener('nlLoginOTPUser', async (event: any) => {
await exec(
async () => {
if (!this.modal) return;
const nameNpub = event.detail;
const pubkey = await nameToPubkey(nameNpub);
const url = this.opt!.otpRequestUrl! + (this.opt!.otpRequestUrl!.includes('?') ? '&' : '?') + 'pubkey=' + pubkey;
const r = await fetch(url);
if (r.status !== 200) {
console.warn('nostr-login: bad otp reply', r);
throw new Error('Failed to send DM');
}
// switch to 'enter code' mode
this.modal.isOTP = true;
// remember for code handler below
otpPubkey = pubkey;
// spinner off
this.modal.isLoading = false;
},
{ start: true },
);
});
this.modal.addEventListener('nlLoginOTPCode', async (event: any) => {
await exec(
async () => {
if (!this.modal) return;
const code = event.detail;
const url = this.opt!.otpReplyUrl! + (this.opt!.otpRequestUrl!.includes('?') ? '&' : '?') + 'pubkey=' + otpPubkey + '&code=' + code;
const r = await fetch(url);
if (r.status !== 200) {
console.warn('nostr-login: bad otp reply', r);
throw new Error('Invalid code');
}
const data = await r.text();
this.authNostrService.setOTP(otpPubkey, data);
this.modal.isOTP = false;
},
{ end: true },
);
});
this.modal.addEventListener('nlCheckSignup', async (event: any) => {
const { available, taken, error } = await checkNip05(event.detail);
if (this.modal) {
this.modal.error = String(error);
if (!error && taken) {
this.modal.error = 'Already taken';
}
this.modal.signupNameIsAvailable = available;
}
});
this.modal.addEventListener('nlCheckLogin', async (event: any) => {
const { available, taken, error } = await checkNip05(event.detail);
if (this.modal) {
this.modal.error = String(error);
if (available) {
this.modal.error = 'Name not found';
}
this.modal.loginIsGood = taken;
}
});
const cancel = () => {
if (this.modal) {
this.modal.isLoading = false;
}
// this.authNostrService.cancelListenNostrConnect();
dialog.close();
err(new Error('Cancelled'));
};
this.modal.addEventListener('stopFetchHandler', cancel);
this.modal.addEventListener('nlCloseModal', cancel);
this.modal.addEventListener('nlChangeDarkMode', (event: any) => {
setDarkMode(event.detail);
document.dispatchEvent(new CustomEvent('nlDarkMode', { detail: event.detail }));
});
this.on('onIframeAuthUrlCallEnd', () => {
dialog.close();
this.modal = null;
ok();
});
dialog.showModal();
});
return this.launcherPromise;
}
public async showIframeUrl(url: string) {
// make sure we consume the previous promise,
// otherwise launch will start await-ing
// before modal is created and setting iframeUrl will fail
await this.waitReady();
this.launch({
startScreen: 'iframe' as StartScreens,
}).catch(() => console.log('closed auth iframe'));
this.modal!.authUrl = url;
}
public connectModals(defaultOpt: NostrLoginOptions) {
const initialModals = async (opt: NostrLoginOptions) => {
await this.launch(opt);
};
const nlElements = document.getElementsByTagName('nl-button');
for (let i = 0; i < nlElements.length; i++) {
const theme = nlElements[i].getAttribute('nl-theme');
const startScreen = nlElements[i].getAttribute('start-screen');
const elementOpt = {
...defaultOpt,
};
if (theme) elementOpt.theme = theme;
switch (startScreen as StartScreens) {
case 'login':
case 'login-bunker-url':
case 'login-read-only':
case 'signup':
case 'switch-account':
case 'welcome':
elementOpt.startScreen = startScreen as StartScreens;
}
nlElements[i].addEventListener('click', function () {
initialModals(elementOpt);
});
}
}
public onAuthUrl(url: string) {
if (this.modal) {
this.modal.authUrl = url;
this.modal.isLoading = false;
}
}
public onIframeUrl(url: string) {
if (this.modal) {
console.log('modal iframe url', url);
this.modal.iframeUrl = url;
}
}
public onCallEnd() {
if (this.modal && this.modal.authUrl && this.params.userInfo?.iframeUrl) {
this.emit('onIframeAuthUrlCallEnd');
}
}
public onUpdateAccounts(accounts: Info[], recents: RecentType[]) {
this.accounts = accounts;
this.recents = recents;
if (!this.modal) return;
this.modal.accounts = accounts;
this.modal.recents = recents;
}
public onDarkMode(dark: boolean) {
if (this.modal) this.modal.darkMode = dark;
}
}
export default ModalManager;

View File

@@ -0,0 +1,429 @@
import NDK, { NDKEvent, NDKFilter, NDKNip46Signer, NDKNostrRpc, NDKRpcRequest, NDKRpcResponse, NDKSubscription, NDKSubscriptionCacheUsage, NostrEvent } from '@nostr-dev-kit/ndk';
import { validateEvent, verifySignature } from 'nostr-tools';
import { PrivateKeySigner } from './Signer';
class NostrRpc extends NDKNostrRpc {
protected _ndk: NDK;
protected _signer: PrivateKeySigner;
protected requests: Set<string> = new Set();
private sub?: NDKSubscription;
protected _useNip44: boolean = false;
public constructor(ndk: NDK, signer: PrivateKeySigner) {
super(ndk, signer, ndk.debug.extend('nip46:signer:rpc'));
this._ndk = ndk;
this._signer = signer;
}
public async subscribe(filter: NDKFilter): Promise<NDKSubscription> {
// NOTE: fixing ndk
filter.kinds = filter.kinds?.filter(k => k === 24133);
this.sub = await super.subscribe(filter);
return this.sub;
}
public stop() {
if (this.sub) {
this.sub.stop();
this.sub = undefined;
}
}
public setUseNip44(useNip44: boolean) {
this._useNip44 = useNip44;
}
private isNip04(ciphertext: string) {
const l = ciphertext.length;
if (l < 28) return false;
return ciphertext[l - 28] === '?' && ciphertext[l - 27] === 'i' && ciphertext[l - 26] === 'v' && ciphertext[l - 25] === '=';
}
// override to auto-decrypt nip04/nip44
public async parseEvent(event: NDKEvent): Promise<NDKRpcRequest | NDKRpcResponse> {
const remoteUser = this._ndk.getUser({ pubkey: event.pubkey });
remoteUser.ndk = this._ndk;
const decrypt = this.isNip04(event.content) ? this._signer.decrypt : this._signer.decryptNip44;
const decryptedContent = await decrypt.call(this._signer, remoteUser, event.content);
const parsedContent = JSON.parse(decryptedContent);
const { id, method, params, result, error } = parsedContent;
if (method) {
return { id, pubkey: event.pubkey, method, params, event };
} else {
return { id, result, error, event };
}
}
public async parseNostrConnectReply(reply: any, secret: string) {
const event = new NDKEvent(this._ndk, reply);
const parsedEvent = await this.parseEvent(event);
console.log('nostr connect parsedEvent', parsedEvent);
if (!(parsedEvent as NDKRpcRequest).method) {
const response = parsedEvent as NDKRpcResponse;
if (response.result !== secret) throw new Error(response.error);
return event.pubkey;
} else {
throw new Error('Bad nostr connect reply');
}
}
// ndk doesn't support nostrconnect:
// we just listed to an unsolicited reply to
// our pubkey and if it's ack/secret - we're fine
public async listen(nostrConnectSecret: string): Promise<string> {
const pubkey = this._signer.pubkey;
console.log('nostr-login listening for conn to', pubkey);
const sub = await this.subscribe({
'kinds': [24133],
'#p': [pubkey],
});
return new Promise<string>((ok, err) => {
sub.on('event', async (event: NDKEvent) => {
try {
const parsedEvent = await this.parseEvent(event);
// console.log('ack parsedEvent', parsedEvent);
if (!(parsedEvent as NDKRpcRequest).method) {
const response = parsedEvent as NDKRpcResponse;
// ignore
if (response.result === 'auth_url') return;
// FIXME for now accept 'ack' replies, later on only
// accept secrets
if (response.result === 'ack' || response.result === nostrConnectSecret) {
ok(event.pubkey);
} else {
err(response.error);
}
}
} catch (e) {
console.log('error parsing event', e, event.rawEvent());
}
// done
this.stop();
});
});
}
// since ndk doesn't yet support perms param
// we reimplement the 'connect' call here
// instead of await signer.blockUntilReady();
public async connect(pubkey: string, token?: string, perms?: string) {
return new Promise<void>((ok, err) => {
const connectParams = [pubkey!, token || '', perms || ''];
this.sendRequest(pubkey!, 'connect', connectParams, 24133, (response: NDKRpcResponse) => {
if (response.result === 'ack') {
ok();
} else {
err(response.error);
}
});
});
}
protected getId(): string {
return Math.random().toString(36).substring(7);
}
public async sendRequest(remotePubkey: string, method: string, params: string[] = [], kind = 24133, cb?: (res: NDKRpcResponse) => void): Promise<NDKRpcResponse> {
const id = this.getId();
// response handler will deduplicate auth urls and responses
this.setResponseHandler(id, cb);
// create and sign request
const event = await this.createRequestEvent(id, remotePubkey, method, params, kind);
console.log("sendRequest", { event, method, remotePubkey, params });
// send to relays
await event.publish();
// NOTE: ndk returns a promise that never resolves and
// in fact REQUIRES cb to be provided (otherwise no way
// to consume the result), we've already stepped on the bug
// of waiting for this unresolvable result, so now we return
// undefined to make sure waiters fail, not hang.
// @ts-ignore
return undefined as NDKRpcResponse;
}
protected setResponseHandler(id: string, cb?: (res: NDKRpcResponse) => void) {
let authUrlSent = false;
const now = Date.now();
return new Promise<NDKRpcResponse>(() => {
const responseHandler = (response: NDKRpcResponse) => {
if (response.result === 'auth_url') {
this.once(`response-${id}`, responseHandler);
if (!authUrlSent) {
authUrlSent = true;
this.emit('authUrl', response.error);
}
} else if (cb) {
if (this.requests.has(id)) {
this.requests.delete(id);
console.log('nostr-login processed nip46 request in', Date.now() - now, 'ms');
cb(response);
}
}
};
this.once(`response-${id}`, responseHandler);
});
}
protected async createRequestEvent(id: string, remotePubkey: string, method: string, params: string[] = [], kind = 24133) {
this.requests.add(id);
const localUser = await this._signer.user();
const remoteUser = this._ndk.getUser({ pubkey: remotePubkey });
const request = { id, method, params };
const event = new NDKEvent(this._ndk, {
kind,
content: JSON.stringify(request),
tags: [['p', remotePubkey]],
pubkey: localUser.pubkey,
} as NostrEvent);
const useNip44 = this._useNip44 && method !== 'create_account';
const encrypt = useNip44 ? this._signer.encryptNip44 : this._signer.encrypt;
event.content = await encrypt.call(this._signer, remoteUser, event.content);
await event.sign(this._signer);
return event;
}
}
export class IframeNostrRpc extends NostrRpc {
private peerOrigin?: string;
private iframePort?: MessagePort;
private iframeRequests = new Map<string, { id: string; pubkey: string }>();
public constructor(ndk: NDK, localSigner: PrivateKeySigner, iframePeerOrigin?: string) {
super(ndk, localSigner);
this._ndk = ndk;
this.peerOrigin = iframePeerOrigin;
}
public async subscribe(filter: NDKFilter): Promise<NDKSubscription> {
if (!this.peerOrigin) return super.subscribe(filter);
return new NDKSubscription(
this._ndk,
{},
{
// don't send to relay
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.ONLY_CACHE,
},
);
}
public setWorkerIframePort(port: MessagePort) {
if (!this.peerOrigin) throw new Error('Unexpected iframe port');
this.iframePort = port;
// to make sure Chrome doesn't terminate the channel
setInterval(() => {
console.log('iframe-nip46 ping');
this.iframePort!.postMessage('ping');
}, 5000);
port.onmessage = async ev => {
console.log('iframe-nip46 got response', ev.data);
if (typeof ev.data === 'string' && ev.data.startsWith('errorNoKey')) {
const event_id = ev.data.split(':')[1];
const { id = '', pubkey = '' } = this.iframeRequests.get(event_id) || {};
if (id && pubkey && this.requests.has(id)) this.emit(`iframeRestart-${pubkey}`);
return;
}
// a copy-paste from rpc.subscribe
try {
const event = ev.data;
if (!validateEvent(event)) throw new Error('Invalid event from iframe');
if (!verifySignature(event)) throw new Error('Invalid event signature from iframe');
const nevent = new NDKEvent(this._ndk, event);
const parsedEvent = await this.parseEvent(nevent);
// we're only implementing client-side rpc
if (!(parsedEvent as NDKRpcRequest).method) {
console.log('parsed response', parsedEvent);
this.emit(`response-${parsedEvent.id}`, parsedEvent);
}
} catch (e) {
console.log('error parsing event', e, ev.data);
}
};
}
public async sendRequest(remotePubkey: string, method: string, params: string[] = [], kind = 24133, cb?: (res: NDKRpcResponse) => void): Promise<NDKRpcResponse> {
const id = this.getId();
// create and sign request event
const event = await this.createRequestEvent(id, remotePubkey, method, params, kind);
// set response handler, it will dedup auth urls,
// and also dedup response handlers - we're sending
// to relays and to iframe
this.setResponseHandler(id, cb);
if (this.iframePort) {
// map request event id to request id, if iframe
// has no key it will reply with error:event_id (it can't
// decrypt the request id without keys)
this.iframeRequests.set(event.id, { id, pubkey: remotePubkey });
// send to iframe
console.log('iframe-nip46 sending request to', this.peerOrigin, event.rawEvent());
this.iframePort.postMessage(event.rawEvent());
} else {
// send to relays
await event.publish();
}
// see notes in 'super'
// @ts-ignore
return undefined as NDKRpcResponse;
}
}
export class ReadyListener {
origin: string;
messages: string[];
promise: Promise<any>;
constructor(messages: string[], origin: string) {
this.origin = origin;
this.messages = messages;
this.promise = new Promise<any>(ok => {
console.log(new Date(), 'started listener for', this.messages);
// ready message handler
const onReady = async (e: MessageEvent) => {
const originHostname = new URL(origin!).hostname;
const messageHostname = new URL(e.origin).hostname;
// same host or subdomain
const validHost = messageHostname === originHostname || messageHostname.endsWith('.' + originHostname);
if (!validHost || !Array.isArray(e.data) || !e.data.length || !this.messages.includes(e.data[0])) {
// console.log(new Date(), 'got invalid ready message', e.origin, e.data);
return;
}
console.log(new Date(), 'got ready message from', e.origin, e.data);
window.removeEventListener('message', onReady);
ok(e.data);
};
window.addEventListener('message', onReady);
});
}
async wait(): Promise<any> {
console.log(new Date(), 'waiting for', this.messages);
const r = await this.promise;
// NOTE: timer here doesn't help bcs it must be activated when
// user "confirms", but that's happening on a different
// origin and we can't really know.
// await new Promise<any>((ok, err) => {
// // 10 sec should be more than enough
// setTimeout(() => err(new Date() + ' timeout for ' + this.message), 10000);
// // if promise already resolved or will resolve in the future
// this.promise.then(ok);
// });
console.log(new Date(), 'finished waiting for', this.messages, r);
return r;
}
}
export class Nip46Signer extends NDKNip46Signer {
private _userPubkey: string = '';
private _rpc: IframeNostrRpc;
constructor(ndk: NDK, localSigner: PrivateKeySigner, signerPubkey: string, iframeOrigin?: string) {
super(ndk, signerPubkey, localSigner);
// override with our own rpc implementation
this._rpc = new IframeNostrRpc(ndk, localSigner, iframeOrigin);
this._rpc.setUseNip44(true); // !!this.params.optionsModal.dev);
this._rpc.on('authUrl', (url: string) => {
this.emit('authUrl', url);
});
this.rpc = this._rpc;
}
get userPubkey() {
return this._userPubkey;
}
private async setSignerPubkey(signerPubkey: string, sameAsUser: boolean = false) {
console.log("setSignerPubkey", signerPubkey);
// ensure it's set
this.remotePubkey = signerPubkey;
// when we're sure it's known
this._rpc.on(`iframeRestart-${signerPubkey}`, () => {
this.emit('iframeRestart');
});
// now call getPublicKey and swap remotePubkey w/ that
await this.initUserPubkey(sameAsUser ? signerPubkey : '');
}
public async initUserPubkey(hintPubkey?: string) {
if (this._userPubkey) throw new Error('Already called initUserPubkey');
if (hintPubkey) {
this._userPubkey = hintPubkey;
return;
}
this._userPubkey = await new Promise<string>((ok, err) => {
if (!this.remotePubkey) throw new Error('Signer pubkey not set');
console.log("get_public_key", this.remotePubkey);
this._rpc.sendRequest(this.remotePubkey, 'get_public_key', [], 24133, (response: NDKRpcResponse) => {
ok(response.result);
});
});
}
public async listen(nostrConnectSecret: string) {
const signerPubkey = await (this.rpc as IframeNostrRpc).listen(nostrConnectSecret);
await this.setSignerPubkey(signerPubkey);
}
public async connect(token?: string, perms?: string) {
if (!this.remotePubkey) throw new Error('No signer pubkey');
await this._rpc.connect(this.remotePubkey, token, perms);
await this.setSignerPubkey(this.remotePubkey);
}
public async setListenReply(reply: any, nostrConnectSecret: string) {
const signerPubkey = await this._rpc.parseNostrConnectReply(reply, nostrConnectSecret);
await this.setSignerPubkey(signerPubkey, true);
}
public async createAccount2({ bunkerPubkey, name, domain, perms = '' }: { bunkerPubkey: string; name: string; domain: string; perms?: string }) {
const params = [
name,
domain,
'', // email
perms,
];
const r = await new Promise<NDKRpcResponse>(ok => {
this.rpc.sendRequest(bunkerPubkey, 'create_account', params, undefined, ok);
});
console.log('create_account pubkey', r);
if (r.result === 'error') {
throw new Error(r.error);
}
return r.result;
}
}

View File

@@ -0,0 +1,107 @@
import { Info } from 'nostr-login-components/dist/types/types';
export interface Signer {
signEvent: (event: any) => Promise<any>;
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>;
};
}
export interface NostrObjectParams {
waitReady(): Promise<void>;
getUserInfo(): Info | null;
launch(): Promise<void>;
getSigner(): Signer;
wait<T>(cb: () => Promise<T>): Promise<T>;
}
class Nostr {
#params: NostrObjectParams;
private nip04: {
encrypt: (pubkey: string, plaintext: string) => Promise<any>;
decrypt: (pubkey: string, ciphertext: string) => Promise<any>;
};
private nip44: {
encrypt: (pubkey: string, plaintext: string) => Promise<any>;
decrypt: (pubkey: string, ciphertext: string) => Promise<any>;
};
constructor(params: NostrObjectParams) {
this.#params = params;
this.getPublicKey = this.getPublicKey.bind(this);
this.signEvent = this.signEvent.bind(this);
this.getRelays = this.getRelays.bind(this);
this.nip04 = {
encrypt: this.encrypt04.bind(this),
decrypt: this.decrypt04.bind(this),
};
this.nip44 = {
encrypt: this.encrypt44.bind(this),
decrypt: this.decrypt44.bind(this),
};
}
private async ensureAuth() {
await this.#params.waitReady();
// authed?
if (this.#params.getUserInfo()) return;
// launch auth flow
await this.#params.launch();
// give up
if (!this.#params.getUserInfo()) {
throw new Error('Rejected by user');
}
}
async getPublicKey() {
await this.ensureAuth();
const userInfo = this.#params.getUserInfo();
if (userInfo) {
return userInfo.pubkey;
} else {
throw new Error('No user');
}
}
// @ts-ignore
async signEvent(event) {
await this.ensureAuth();
return this.#params.wait(async () => await this.#params.getSigner().signEvent(event));
}
async getRelays() {
// FIXME implement!
return {};
}
async encrypt04(pubkey: string, plaintext: string) {
await this.ensureAuth();
return this.#params.wait(async () => await this.#params.getSigner().nip04.encrypt(pubkey, plaintext));
}
async decrypt04(pubkey: string, ciphertext: string) {
await this.ensureAuth();
return this.#params.wait(async () => await this.#params.getSigner().nip04.decrypt(pubkey, ciphertext));
}
async encrypt44(pubkey: string, plaintext: string) {
await this.ensureAuth();
return this.#params.wait(async () => await this.#params.getSigner().nip44.encrypt(pubkey, plaintext));
}
async decrypt44(pubkey: string, ciphertext: string) {
await this.ensureAuth();
return this.#params.wait(async () => await this.#params.getSigner().nip44.decrypt(pubkey, ciphertext));
}
}
export default Nostr;

View File

@@ -0,0 +1,99 @@
import { Nostr, NostrParams } from './';
import { EventEmitter } from 'tseep';
class NostrExtensionService extends EventEmitter {
private params: NostrParams;
private nostrExtension: any | undefined;
constructor(params: NostrParams) {
super();
this.params = params;
}
public startCheckingExtension(nostr: Nostr) {
if (this.checkExtension(nostr)) return;
// watch out for extension trying to overwrite us
const to = setInterval(() => {
if (this.checkExtension(nostr)) clearTimeout(to);
}, 100);
}
private checkExtension(nostr: Nostr) {
// @ts-ignore
if (!this.nostrExtension && window.nostr && window.nostr !== nostr) {
this.initExtension(nostr);
return true;
}
return false;
}
private async initExtension(nostr: Nostr, lastTry?: boolean) {
// @ts-ignore
this.nostrExtension = window.nostr;
// @ts-ignore
window.nostr = nostr;
// we're signed in with extesions? well execute that
if (this.params.userInfo?.authMethod === 'extension') {
await this.trySetExtensionForPubkey(this.params.userInfo.pubkey);
}
// schedule another check
if (!lastTry) {
setTimeout(() => {
// NOTE: we can't know if user has >1 extension and thus
// if the current one we detected is the actual 'last one'
// that will set the window.nostr. So the simplest
// solution is to wait a bit more, hoping that if one
// extension started then the rest are likely to start soon,
// and then just capture the most recent one
// @ts-ignore
if (window.nostr !== nostr && this.nostrExtension !== window.nostr) {
this.initExtension(nostr, true);
}
}, 300);
}
// in the worst case of app saving the nostrExtension reference
// it will be calling it directly, not a big deal
}
private async setExtensionReadPubkey(expectedPubkey?: string) {
window.nostr = this.nostrExtension;
// @ts-ignore
const pubkey = await window.nostr.getPublicKey();
if (expectedPubkey && expectedPubkey !== pubkey) {
this.emit('extensionLogout');
} else {
this.emit('extensionLogin', pubkey);
}
}
public async trySetExtensionForPubkey(expectedPubkey: string) {
if (this.nostrExtension) {
return this.setExtensionReadPubkey(expectedPubkey);
}
}
public async setExtension() {
return this.setExtensionReadPubkey();
}
public unsetExtension(nostr: Nostr) {
if (window.nostr === this.nostrExtension) {
// @ts-ignore
window.nostr = nostr;
}
}
public getExtension() {
return this.nostrExtension;
}
public hasExtension() {
return !!this.nostrExtension;
}
}
export default NostrExtensionService;

View File

@@ -0,0 +1,18 @@
import { Info } from 'nostr-login-components/dist/types/types';
import { NostrLoginOptions } from '../types';
class NostrParams {
public userInfo: Info | null;
public optionsModal: NostrLoginOptions;
constructor() {
this.userInfo = null;
this.optionsModal = {
theme: 'default',
startScreen: 'welcome',
devOverrideBunkerOrigin: '',
};
}
}
export default NostrParams;

View File

@@ -0,0 +1,27 @@
class Popup {
private popup: Window | null = null;
constructor() {}
public openPopup(url: string) {
// user might have closed it already
if (!this.popup || this.popup.closed) {
// NOTE: do not set noreferrer, bunker might use referrer to
// simplify the naming of the connected app.
// NOTE: do not pass noopener, otherwise null is returned
this.popup = window.open(url, '_blank', 'width=400,height=700');
console.log('popup', this.popup);
if (!this.popup) throw new Error('Popup blocked. Try again, please!');
}
}
public closePopup() {
// make sure we release the popup
try {
this.popup?.close();
this.popup = null;
} catch {}
}
}
export default Popup;

View File

@@ -0,0 +1,67 @@
import { EventEmitter } from 'tseep';
import { CALL_TIMEOUT } from '../const';
class ProcessManager extends EventEmitter {
private callCount: number = 0;
private callTimer: NodeJS.Timeout | undefined;
constructor() {
super();
}
public onAuthUrl() {
if (Boolean(this.callTimer)) {
clearTimeout(this.callTimer);
}
}
public onIframeUrl() {
if (Boolean(this.callTimer)) {
clearTimeout(this.callTimer);
}
}
public async wait<T>(cb: () => Promise<T>): Promise<T> {
// FIXME only allow 1 parallel req
if (!this.callTimer) {
this.callTimer = setTimeout(() => this.emit('onCallTimeout'), CALL_TIMEOUT);
}
if (!this.callCount) {
this.emit('onCallStart');
}
this.callCount++;
let error;
let result;
try {
result = await cb();
} catch (e) {
error = e;
}
this.callCount--;
this.emit('onCallEnd');
if (this.callTimer) {
clearTimeout(this.callTimer);
}
this.callTimer = undefined;
if (error) {
throw error;
}
// we can't return undefined bcs an exception is
// thrown above on error
// @ts-ignore
return result;
}
}
export default ProcessManager;

View File

@@ -0,0 +1,25 @@
import { NDKPrivateKeySigner, NDKUser } from '@nostr-dev-kit/ndk';
import { Nip44 } from '../utils/nip44';
import { getPublicKey } from 'nostr-tools';
export class PrivateKeySigner extends NDKPrivateKeySigner {
private nip44: Nip44 = new Nip44();
private _pubkey: string;
constructor(privateKey: string) {
super(privateKey);
this._pubkey = getPublicKey(privateKey);
}
get pubkey() {
return this._pubkey;
}
encryptNip44(recipient: NDKUser, value: string): Promise<string> {
return Promise.resolve(this.nip44.encrypt(this.privateKey!, recipient.pubkey, value));
}
decryptNip44(sender: NDKUser, value: string): Promise<string> {
return Promise.resolve(this.nip44.decrypt(this.privateKey!, sender.pubkey, value));
}
}

View File

@@ -0,0 +1,8 @@
export { default as BannerManager } from './BannerManager';
export { default as AuthNostrService } from './AuthNostrService';
export { default as ModalManager } from './ModalManager';
export { default as Nostr } from './Nostr';
export { default as NostrExtensionService } from './NostrExtensionService';
export { default as NostrParams } from './NostrParams';
export { default as Popup } from './Popup';
export { default as ProcessManager } from './ProcessManager';

124
packages/auth/src/types.ts Normal file
View File

@@ -0,0 +1,124 @@
import { Info, AuthMethod, ConnectionString, RecentType, BannerNotify } from 'nostr-login-components/dist/types/types';
export interface NostrLoginAuthOptions {
localNsec?: string;
relays?: string[];
type: 'login' | 'signup' | 'logout';
method?: AuthMethod;
pubkey?: string;
otpData?: string;
name?: string;
}
// NOTE: must be a subset of CURRENT_MODULE enum
export type StartScreens =
| 'welcome'
| 'welcome-login'
| 'welcome-signup'
| 'signup'
| 'local-signup'
| 'login'
| 'otp'
| 'connect'
| 'login-bunker-url'
| 'login-read-only'
| 'connection-string'
| 'switch-account'
| 'import';
export interface NostrLoginOptions {
// optional
theme?: string;
startScreen?: StartScreens;
bunkers?: string;
onAuth?: (npub: string, options: NostrLoginAuthOptions) => void;
perms?: string;
darkMode?: boolean;
// do not show the banner, modals must be `launch`-ed
noBanner?: boolean;
// forward reqs to this bunker origin for testing
devOverrideBunkerOrigin?: string;
// deprecated, use methods=['local']
// use local signup instead of nostr connect
localSignup?: boolean;
// allowed auth methods
methods?: AuthMethod[];
// otp endpoints
otpRequestUrl?: string;
otpReplyUrl?: string;
// welcome screen's title/desc
title?: string;
description?: string;
// comma-separated list of relays added
// to relay list of new profiles created with local signup
signupRelays?: string;
// relay list to override hardcoded `OUTBOX_RELAYS` constant
outboxRelays?: string[];
// dev mode
dev?: boolean;
// use start.njump.me instead of local signup
signupNstart?: boolean;
// list of npubs to auto/suggest-follow on signup
followNpubs?: string;
// when method call auth needed, instead of showing
// the modal, we start waiting for incoming nip46
// connection and send the nostrconnect string using
// nlNeedAuth event
customNostrConnect?: boolean;
}
export interface IBanner {
userInfo?: Info | null;
titleBanner?: string;
isLoading?: boolean;
listNotifies?: string[];
accounts?: Info[];
isOpen?: boolean;
darkMode?: boolean;
notify?: BannerNotify;
}
export type TypeBanner = IBanner & HTMLElement;
export interface IModal {
authUrl?: string;
iframeUrl?: string;
isLoading?: boolean;
isOTP?: boolean;
isLoadingExtension?: boolean;
localSignup?: boolean;
signupNjump?: boolean;
njumpIframe?: string;
authMethods?: AuthMethod[];
hasExtension?: boolean;
hasOTP?: boolean;
error?: string;
signupNameIsAvailable?: string | boolean;
loginIsGood?: string | boolean;
recents?: RecentType[];
accounts?: Info[];
darkMode?: boolean;
welcomeTitle?: string;
welcomeDescription?: string;
connectionString?: string;
connectionStringServices?: ConnectionString[];
}
export type TypeModal = IModal & HTMLElement;
export interface Response {
result?: string;
error?: string;
}

View File

@@ -0,0 +1,326 @@
import { Info, RecentType } from 'nostr-login-components/dist/types/types';
import NDK, { NDKEvent, NDKRelaySet, NDKSigner, NDKUser } from '@nostr-dev-kit/ndk';
import { generatePrivateKey } from 'nostr-tools';
import { NostrLoginOptions } from '../types';
const LOCAL_STORE_KEY = '__nostrlogin_nip46';
const LOGGED_IN_ACCOUNTS = '__nostrlogin_accounts';
const RECENT_ACCOUNTS = '__nostrlogin_recent';
const OUTBOX_RELAYS = ['wss://purplepag.es', 'wss://relay.nos.social', 'wss://user.kindpag.es', 'wss://relay.damus.io', 'wss://nos.lol'];
const DEFAULT_SIGNUP_RELAYS = ['wss://relay.damus.io/', 'wss://nos.lol/', 'wss://relay.primal.net/'];
export const localStorageSetItem = (key: string, value: string) => {
localStorage.setItem(key, value);
};
export const localStorageGetItem = (key: string) => {
const value = window.localStorage.getItem(key);
if (value) {
try {
return JSON.parse(value);
} catch {}
}
return null;
};
export const localStorageRemoveItem = (key: string) => {
localStorage.removeItem(key);
};
export const fetchProfile = async (info: Info, profileNdk: NDK) => {
const user = new NDKUser({ pubkey: info.pubkey });
user.ndk = profileNdk;
return await user.fetchProfile();
};
export const prepareSignupRelays = (signupRelays?: string) => {
const relays = (signupRelays || '')
.split(',')
.map(r => r.trim())
.filter(r => r.startsWith('ws'));
if (!relays.length) relays.push(...DEFAULT_SIGNUP_RELAYS);
return relays;
};
export const createProfile = async (info: Info, profileNdk: NDK, signer: NDKSigner, signupRelays?: string, outboxRelays?: string[]) => {
const meta = {
name: info.name,
};
const profileEvent = new NDKEvent(profileNdk, {
kind: 0,
created_at: Math.floor(Date.now() / 1000),
pubkey: info.pubkey,
content: JSON.stringify(meta),
tags: [],
});
if (window.location.hostname) profileEvent.tags.push(['client', window.location.hostname]);
const relaysEvent = new NDKEvent(profileNdk, {
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
pubkey: info.pubkey,
content: '',
tags: [],
});
const relays = prepareSignupRelays(signupRelays)
for (const r of relays) {
relaysEvent.tags.push(['r', r]);
}
await profileEvent.sign(signer);
console.log('signed profile', profileEvent);
await relaysEvent.sign(signer);
console.log('signed relays', relaysEvent);
const outboxRelaysFinal = outboxRelays && outboxRelays.length ? outboxRelays : OUTBOX_RELAYS;
await profileEvent.publish(NDKRelaySet.fromRelayUrls(outboxRelaysFinal, profileNdk));
console.log('published profile', profileEvent);
await relaysEvent.publish(NDKRelaySet.fromRelayUrls(outboxRelaysFinal, profileNdk));
console.log('published relays', relaysEvent);
};
export const bunkerUrlToInfo = (bunkerUrl: string, sk = ''): Info => {
const url = new URL(bunkerUrl);
return {
pubkey: '',
signerPubkey: url.hostname || url.pathname.split('//')[1],
sk: sk || generatePrivateKey(),
relays: url.searchParams.getAll('relay'),
token: url.searchParams.get('secret') || '',
authMethod: 'connect',
};
};
export const isBunkerUrl = (value: string) => value.startsWith('bunker://');
export const getBunkerUrl = async (value: string, optionsModal: NostrLoginOptions) => {
if (!value) {
return '';
}
if (isBunkerUrl(value)) {
return value;
}
if (value.includes('@')) {
const [name, domain] = value.toLocaleLowerCase().split('@');
const origin = optionsModal.devOverrideBunkerOrigin || `https://${domain}`;
const bunkerUrl = `${origin}/.well-known/nostr.json?name=_`;
const userUrl = `${origin}/.well-known/nostr.json?name=${name}`;
const bunker = await fetch(bunkerUrl);
const bunkerData = await bunker.json();
const bunkerPubkey = bunkerData.names['_'];
const bunkerRelays = bunkerData.nip46[bunkerPubkey];
const user = await fetch(userUrl);
const userData = await user.json();
const userPubkey = userData.names[name];
// console.log({
// bunkerData, userData, bunkerPubkey, bunkerRelays, userPubkey,
// name, domain, origin
// })
if (!bunkerRelays.length) {
throw new Error('Bunker relay not provided');
}
return `bunker://${userPubkey}?relay=${bunkerRelays[0]}`;
}
throw new Error('Invalid user name or bunker url');
};
export const checkNip05 = async (nip05: string) => {
let available = false;
let error = '';
let pubkey = '';
await (async () => {
if (!nip05 || !nip05.includes('@')) return;
const [name, domain] = nip05.toLocaleLowerCase().split('@');
if (!name) return;
const REGEXP = new RegExp(/^[\w-.]+@([\w-]+\.)+[\w-]{2,8}$/g);
if (!REGEXP.test(nip05)) {
error = 'Invalid name';
return;
}
if (!domain) {
error = 'Select service';
return;
}
const url = `https://${domain}/.well-known/nostr.json?name=${name.toLowerCase()}`;
try {
const r = await fetch(url);
const d = await r.json();
if (d.names[name]) {
pubkey = d.names[name];
return;
}
} catch {}
available = true;
})();
return {
available,
taken: pubkey != '',
error,
pubkey,
};
};
const upgradeInfo = (info: Info | RecentType) => {
if ('typeAuthMethod' in info) delete info['typeAuthMethod'];
if (!info.authMethod) {
if ('extension' in info && info['extension']) info.authMethod = 'extension';
else if ('readOnly' in info && info['readOnly']) info.authMethod = 'readOnly';
else info.authMethod = 'connect';
}
if (info.nip05 && isBunkerUrl(info.nip05)) {
info.bunkerUrl = info.nip05;
info.nip05 = '';
}
if (info.authMethod === 'connect' && !info.signerPubkey) {
info.signerPubkey = info.pubkey;
}
};
export const localStorageAddAccount = (info: Info) => {
// make current
localStorageSetItem(LOCAL_STORE_KEY, JSON.stringify(info));
const loggedInAccounts: Info[] = localStorageGetItem(LOGGED_IN_ACCOUNTS) || [];
const recentAccounts: RecentType[] = localStorageGetItem(RECENT_ACCOUNTS) || [];
// upgrade first
loggedInAccounts.forEach(a => upgradeInfo(a));
recentAccounts.forEach(a => upgradeInfo(a));
// upsert new info into accounts
const accounts: Info[] = loggedInAccounts;
const index = loggedInAccounts.findIndex((el: Info) => el.pubkey === info.pubkey && el.authMethod === info.authMethod);
if (index !== -1) {
accounts[index] = info;
} else {
accounts.push(info);
}
// remove new info from recent
const recents = recentAccounts.filter(el => el.pubkey !== info.pubkey || el.authMethod !== info.authMethod);
localStorageSetItem(RECENT_ACCOUNTS, JSON.stringify(recents));
localStorageSetItem(LOGGED_IN_ACCOUNTS, JSON.stringify(accounts));
};
export const localStorageRemoveCurrentAccount = () => {
const user: Info = localStorageGetItem(LOCAL_STORE_KEY);
if (!user) return;
// make sure it's valid
upgradeInfo(user);
// remove secret fields
const recentUser: RecentType = { ...user };
// make sure session keys are dropped
// @ts-ignore
delete recentUser['sk'];
// @ts-ignore
delete recentUser['otpData'];
// get accounts and recent
const loggedInAccounts: Info[] = localStorageGetItem(LOGGED_IN_ACCOUNTS) || [];
const recentsAccounts: RecentType[] = localStorageGetItem(RECENT_ACCOUNTS) || [];
// upgrade first
loggedInAccounts.forEach(a => upgradeInfo(a));
recentsAccounts.forEach(a => upgradeInfo(a));
const recents: RecentType[] = recentsAccounts;
if (recentUser.authMethod === 'connect' && recentUser.bunkerUrl && recentUser.bunkerUrl.includes('secret=')) {
console.log('nostr login bunker conn with a secret not saved to recent');
} else if (recentUser.authMethod === 'local') {
console.log('nostr login temporary local keys not save to recent');
} else {
// upsert to recent
const index = recentsAccounts.findIndex((el: RecentType) => el.pubkey === recentUser.pubkey && el.authMethod === recentUser.authMethod);
if (index !== -1) {
recents[index] = recentUser;
} else {
recents.push(recentUser);
}
}
// remove from accounts
const accounts = loggedInAccounts.filter(el => el.pubkey !== user.pubkey || el.authMethod !== user.authMethod);
// update accounts and recent, clear current
localStorageSetItem(RECENT_ACCOUNTS, JSON.stringify(recents));
localStorageSetItem(LOGGED_IN_ACCOUNTS, JSON.stringify(accounts));
localStorageRemoveItem(LOCAL_STORE_KEY);
};
export const localStorageRemoveRecent = (user: RecentType) => {
const recentsAccounts: RecentType[] = localStorageGetItem(RECENT_ACCOUNTS) || [];
recentsAccounts.forEach(a => upgradeInfo(a));
const recents = recentsAccounts.filter(el => el.pubkey !== user.pubkey || el.authMethod !== user.authMethod);
localStorageSetItem(RECENT_ACCOUNTS, JSON.stringify(recents));
};
export const localStorageGetRecents = (): RecentType[] => {
const recents: RecentType[] = localStorageGetItem(RECENT_ACCOUNTS) || [];
recents.forEach(r => upgradeInfo(r));
return recents;
};
export const localStorageGetAccounts = (): Info[] => {
const accounts: Info[] = localStorageGetItem(LOGGED_IN_ACCOUNTS) || [];
accounts.forEach(a => upgradeInfo(a));
return accounts;
};
export const localStorageGetCurrent = (): Info | null => {
const info = localStorageGetItem(LOCAL_STORE_KEY);
if (info) upgradeInfo(info);
return info;
};
export const setDarkMode = (dark: boolean) => {
localStorageSetItem('nl-dark-mode', dark ? 'true' : 'false');
};
export const getDarkMode = (opt: NostrLoginOptions) => {
const getDarkModeLocal = localStorage.getItem('nl-dark-mode');
if (getDarkModeLocal) {
// user already changed it
return Boolean(JSON.parse(getDarkModeLocal));
} else if (opt.darkMode !== undefined) {
// app provided an option
return opt.darkMode;
} else {
// auto-detect
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return true;
} else {
return false;
}
}
};
export const getIcon = async () => {
// FIXME look at meta tags or manifest
return document.location.origin + '/favicon.ico';
};

View File

@@ -0,0 +1,185 @@
import { chacha20 } from "@noble/ciphers/chacha"
import { concatBytes, randomBytes, utf8ToBytes } from "@noble/hashes/utils"
import { equalBytes } from "@noble/ciphers/utils";
import { secp256k1 } from "@noble/curves/secp256k1"
import {
expand as hkdf_expand,
extract as hkdf_extract,
} from "@noble/hashes/hkdf"
import { sha256 } from "@noble/hashes/sha256"
import { hmac } from "@noble/hashes/hmac";
import { base64 } from "@scure/base";
import { getPublicKey } from 'nostr-tools'
// from https://github.com/nbd-wtf/nostr-tools
const decoder = new TextDecoder()
const u = {
minPlaintextSize: 0x0001, // 1b msg => padded to 32b
maxPlaintextSize: 0xffff, // 65535 (64kb-1) => padded to 64kb
utf8Encode: utf8ToBytes,
utf8Decode(bytes: Uint8Array): string {
return decoder.decode(bytes);
},
getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
const sharedX = secp256k1
.getSharedSecret(privkeyA, "02" + pubkeyB)
.subarray(1, 33);
return hkdf_extract(sha256, sharedX, "nip44-v2");
},
getMessageKeys(conversationKey: Uint8Array, nonce: Uint8Array) {
const keys = hkdf_expand(sha256, conversationKey, nonce, 76);
return {
chacha_key: keys.subarray(0, 32),
chacha_nonce: keys.subarray(32, 44),
hmac_key: keys.subarray(44, 76),
};
},
calcPaddedLen(len: number): number {
if (!Number.isSafeInteger(len) || len < 1)
throw new Error("expected positive integer");
if (len <= 32) return 32;
const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1);
const chunk = nextPower <= 256 ? 32 : nextPower / 8;
return chunk * (Math.floor((len - 1) / chunk) + 1);
},
writeU16BE(num: number): Uint8Array {
if (
!Number.isSafeInteger(num) ||
num < u.minPlaintextSize ||
num > u.maxPlaintextSize
)
throw new Error(
"invalid plaintext size: must be between 1 and 65535 bytes"
);
const arr = new Uint8Array(2);
new DataView(arr.buffer).setUint16(0, num, false);
return arr;
},
pad(plaintext: string): Uint8Array {
const unpadded = u.utf8Encode(plaintext);
const unpaddedLen = unpadded.length;
const prefix = u.writeU16BE(unpaddedLen);
const suffix = new Uint8Array(u.calcPaddedLen(unpaddedLen) - unpaddedLen);
return concatBytes(prefix, unpadded, suffix);
},
unpad(padded: Uint8Array): string {
const unpaddedLen = new DataView(padded.buffer).getUint16(0);
const unpadded = padded.subarray(2, 2 + unpaddedLen);
if (
unpaddedLen < u.minPlaintextSize ||
unpaddedLen > u.maxPlaintextSize ||
unpadded.length !== unpaddedLen ||
padded.length !== 2 + u.calcPaddedLen(unpaddedLen)
)
throw new Error("invalid padding");
return u.utf8Decode(unpadded);
},
hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array): Uint8Array {
if (aad.length !== 32)
throw new Error("AAD associated data must be 32 bytes");
const combined = concatBytes(aad, message);
return hmac(sha256, key, combined);
},
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
// plaintext: 1b to 0xffff
// padded plaintext: 32b to 0xffff
// ciphertext: 32b+2 to 0xffff+2
// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
// compressed payload (base64): 132b to 87472b
decodePayload(payload: string): {
nonce: Uint8Array;
ciphertext: Uint8Array;
mac: Uint8Array;
} {
if (typeof payload !== "string")
throw new Error("payload must be a valid string");
const plen = payload.length;
if (plen < 132 || plen > 87472)
throw new Error("invalid payload length: " + plen);
if (payload[0] === "#") throw new Error("unknown encryption version");
let data: Uint8Array;
try {
data = base64.decode(payload);
} catch (error) {
throw new Error("invalid base64: " + (error as Error).message);
}
const dlen = data.length;
if (dlen < 99 || dlen > 65603)
throw new Error("invalid data length: " + dlen);
const vers = data[0];
if (vers !== 2) throw new Error("unknown encryption version " + vers);
return {
nonce: data.subarray(1, 33),
ciphertext: data.subarray(33, -32),
mac: data.subarray(-32),
};
},
};
export function encryptNip44(
plaintext: string,
conversationKey: Uint8Array,
nonce: Uint8Array = randomBytes(32)
): string {
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(
conversationKey,
nonce
);
const padded = u.pad(plaintext);
const ciphertext = chacha20(chacha_key, chacha_nonce, padded);
const mac = u.hmacAad(hmac_key, ciphertext, nonce);
return base64.encode(
concatBytes(new Uint8Array([2]), nonce, ciphertext, mac)
);
}
export function decryptNip44(payload: string, conversationKey: Uint8Array): string {
const { nonce, ciphertext, mac } = u.decodePayload(payload);
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(
conversationKey,
nonce
);
const calculatedMac = u.hmacAad(hmac_key, ciphertext, nonce);
if (!equalBytes(calculatedMac, mac)) throw new Error("invalid MAC");
const padded = chacha20(chacha_key, chacha_nonce, ciphertext);
return u.unpad(padded);
}
export class Nip44 {
private cache = new Map<string, Uint8Array>()
public createKey(privkey: string, pubkey: string) {
return u.getConversationKey(privkey, pubkey)
}
private getKey(privkey: string, pubkey: string, extractable?: boolean) {
const id = getPublicKey(privkey) + pubkey
let cryptoKey = this.cache.get(id)
if (cryptoKey) return cryptoKey
const key = this.createKey(privkey, pubkey)
this.cache.set(id, key)
return key
}
public encrypt(privkey: string, pubkey: string, text: string): string {
const key = this.getKey(privkey, pubkey)
return encryptNip44(text, key)
}
public decrypt(privkey: string, pubkey: string, data: string): string {
const key = this.getKey(privkey, pubkey)
return decryptNip44(data, key)
}
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"declaration": true,
"outDir": "./dist",
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"],
}