first
This commit is contained in:
13
packages/auth/.prettierrc.json
Normal file
13
packages/auth/.prettierrc.json
Normal 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
167
packages/auth/README.md
Normal 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
30
packages/auth/index.html
Normal 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>
|
||||
28
packages/auth/package.json
Normal file
28
packages/auth/package.json
Normal 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"
|
||||
}
|
||||
55
packages/auth/rollup.config.js
Normal file
55
packages/auth/rollup.config.js
Normal 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,
|
||||
},
|
||||
})
|
||||
],
|
||||
}
|
||||
];
|
||||
|
||||
1
packages/auth/src/const/index.ts
Normal file
1
packages/auth/src/const/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const CALL_TIMEOUT = 5000;
|
||||
81
packages/auth/src/iife-module.ts
Normal file
81
packages/auth/src/iife-module.ts
Normal 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
347
packages/auth/src/index.ts
Normal 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);
|
||||
});
|
||||
718
packages/auth/src/modules/AuthNostrService.ts
Normal file
718
packages/auth/src/modules/AuthNostrService.ts
Normal 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;
|
||||
146
packages/auth/src/modules/BannerManager.ts
Normal file
146
packages/auth/src/modules/BannerManager.ts
Normal 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;
|
||||
635
packages/auth/src/modules/ModalManager.ts
Normal file
635
packages/auth/src/modules/ModalManager.ts
Normal 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('&', '&'); // 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;
|
||||
429
packages/auth/src/modules/Nip46.ts
Normal file
429
packages/auth/src/modules/Nip46.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
107
packages/auth/src/modules/Nostr.ts
Normal file
107
packages/auth/src/modules/Nostr.ts
Normal 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;
|
||||
99
packages/auth/src/modules/NostrExtensionService.ts
Normal file
99
packages/auth/src/modules/NostrExtensionService.ts
Normal 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;
|
||||
18
packages/auth/src/modules/NostrParams.ts
Normal file
18
packages/auth/src/modules/NostrParams.ts
Normal 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;
|
||||
27
packages/auth/src/modules/Popup.ts
Normal file
27
packages/auth/src/modules/Popup.ts
Normal 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;
|
||||
67
packages/auth/src/modules/ProcessManager.ts
Normal file
67
packages/auth/src/modules/ProcessManager.ts
Normal 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;
|
||||
25
packages/auth/src/modules/Signer.ts
Normal file
25
packages/auth/src/modules/Signer.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
8
packages/auth/src/modules/index.ts
Normal file
8
packages/auth/src/modules/index.ts
Normal 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
124
packages/auth/src/types.ts
Normal 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;
|
||||
}
|
||||
326
packages/auth/src/utils/index.ts
Normal file
326
packages/auth/src/utils/index.ts
Normal 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';
|
||||
};
|
||||
185
packages/auth/src/utils/nip44.ts
Normal file
185
packages/auth/src/utils/nip44.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
15
packages/auth/tsconfig.json
Normal file
15
packages/auth/tsconfig.json
Normal 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"],
|
||||
}
|
||||
Reference in New Issue
Block a user