From 4a3994135291c98c4b59a715b14fb87d99bb13dc Mon Sep 17 00:00:00 2001 From: codytseng Date: Wed, 18 Dec 2024 14:54:35 +0800 Subject: [PATCH] feat: bunker login --- src/common/constants.ts | 2 +- src/common/types.ts | 5 +- .../components/LoginDialog/BunkerLogin.tsx | 47 ++++++++++++ .../src/components/LoginDialog/index.tsx | 22 +++++- src/renderer/src/i18n/en.ts | 5 +- src/renderer/src/i18n/zh.ts | 5 +- .../src/providers/FollowListProvider.tsx | 4 +- .../providers/NostrProvider/bunker.signer.ts | 44 ++++++++++++ .../src/providers/NostrProvider/index.tsx | 71 +++++++++++++++---- src/renderer/src/services/storage.service.ts | 20 +++--- tsconfig.web.json | 1 + 11 files changed, 196 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/components/LoginDialog/BunkerLogin.tsx create mode 100644 src/renderer/src/providers/NostrProvider/bunker.signer.ts diff --git a/src/common/constants.ts b/src/common/constants.ts index dfacd6d..2447bd5 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,5 +1,5 @@ export const StorageKey = { THEME_SETTING: 'themeSetting', RELAY_GROUPS: 'relayGroups', - ACCOUNT: 'account' + ACCOUNTS: 'accounts' } diff --git a/src/common/types.ts b/src/common/types.ts index e3ebebe..8075888 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -51,6 +51,9 @@ export type TElectronWindow = { } export type TAccount = { - signerType: 'nsec' | 'browser-nsec' | 'nip-07' + pubkey: string + signerType: 'nsec' | 'browser-nsec' | 'nip-07' | 'bunker' nsec?: string + bunker?: string + bunkerClientSecretKey?: string } diff --git a/src/renderer/src/components/LoginDialog/BunkerLogin.tsx b/src/renderer/src/components/LoginDialog/BunkerLogin.tsx new file mode 100644 index 0000000..63eef05 --- /dev/null +++ b/src/renderer/src/components/LoginDialog/BunkerLogin.tsx @@ -0,0 +1,47 @@ +import { Button } from '@renderer/components/ui/button' +import { Input } from '@renderer/components/ui/input' +import { useNostr } from '@renderer/providers/NostrProvider' +import { Loader } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function BunkerLogin({ onLoginSuccess }: { onLoginSuccess: () => void }) { + const { t } = useTranslation() + const { bunkerLogin } = useNostr() + const [pending, setPending] = useState(false) + const [bunkerInput, setBunkerInput] = useState('') + const [errMsg, setErrMsg] = useState(null) + + const handleInputChange = (e: React.ChangeEvent) => { + setBunkerInput(e.target.value) + setErrMsg(null) + } + + const handleLogin = () => { + if (bunkerInput === '') return + + setPending(true) + bunkerLogin(bunkerInput) + .then(() => onLoginSuccess()) + .catch((err) => setErrMsg(err.message)) + .finally(() => setPending(false)) + } + + return ( + <> +
+ + {errMsg &&
{errMsg}
} +
+ + + ) +} diff --git a/src/renderer/src/components/LoginDialog/index.tsx b/src/renderer/src/components/LoginDialog/index.tsx index 1721f96..d55f193 100644 --- a/src/renderer/src/components/LoginDialog/index.tsx +++ b/src/renderer/src/components/LoginDialog/index.tsx @@ -10,6 +10,8 @@ import { IS_ELECTRON } from '@renderer/lib/env' import { useNostr } from '@renderer/providers/NostrProvider' import { ArrowLeft } from 'lucide-react' import { Dispatch, useState } from 'react' +import { useTranslation } from 'react-i18next' +import BunkerLogin from './BunkerLogin' import PrivateKeyLogin from './NsecLogin' export default function LoginDialog({ @@ -19,7 +21,8 @@ export default function LoginDialog({ open: boolean setOpen: Dispatch }) { - const [loginMethod, setLoginMethod] = useState<'nsec' | 'nip07' | null>(null) + const { t } = useTranslation() + const [loginMethod, setLoginMethod] = useState<'nsec' | 'nip07' | 'bunker' | null>(null) const { nip07Login } = useNostr() return ( @@ -39,15 +42,28 @@ export default function LoginDialog({ setOpen(false)} /> + ) : loginMethod === 'bunker' ? ( + <> +
setLoginMethod(null)} + > + +
+ setOpen(false)} /> + ) : ( <> {!IS_ELECTRON && !!window.nostr && ( )} + )} diff --git a/src/renderer/src/i18n/en.ts b/src/renderer/src/i18n/en.ts index b1c53ee..821dc0e 100644 --- a/src/renderer/src/i18n/en.ts +++ b/src/renderer/src/i18n/en.ts @@ -84,6 +84,9 @@ export default { 'Your nsec will be encrypted using the {{backend}}.': 'Your nsec will be encrypted using the {{backend}}.', 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.': - 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.' + 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.', + 'Login with Browser Extension': 'Login with Browser Extension', + 'Login with Bunker': 'Login with Bunker', + 'Login with Private Key': 'Login with Private Key' } } diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 7a9dda8..ac2c38b 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -82,6 +82,9 @@ export default { '此设备上没有可用的密码管理工具。您的密钥将不受保护', 'Your nsec will be encrypted using the {{backend}}.': '您的密钥将使用 {{backend}} 加密', 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.': - '使用私钥登录是不安全的。建议使用浏览器插件进行登录,例如 alby、nostr-keyx 或 nos2x' + '使用私钥登录是不安全的。建议使用浏览器插件进行登录,例如 alby、nostr-keyx 或 nos2x', + 'Login with Browser Extension': '浏览器插件登录', + 'Login with Bunker': 'Bunker 登录', + 'Login with Private Key': '私钥登录' } } diff --git a/src/renderer/src/providers/FollowListProvider.tsx b/src/renderer/src/providers/FollowListProvider.tsx index e0edb74..ac49a14 100644 --- a/src/renderer/src/providers/FollowListProvider.tsx +++ b/src/renderer/src/providers/FollowListProvider.tsx @@ -39,9 +39,11 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) ) useEffect(() => { - if (isReady || !accountPubkey) return + if (!accountPubkey) return const init = async () => { + setIsReady(false) + setFollowListEvent(undefined) const event = await client.fetchFollowListEvent(accountPubkey) setFollowListEvent(event) setIsReady(true) diff --git a/src/renderer/src/providers/NostrProvider/bunker.signer.ts b/src/renderer/src/providers/NostrProvider/bunker.signer.ts new file mode 100644 index 0000000..eeda6cf --- /dev/null +++ b/src/renderer/src/providers/NostrProvider/bunker.signer.ts @@ -0,0 +1,44 @@ +import { ISigner, TDraftEvent } from '@common/types' +import { generateSecretKey } from 'nostr-tools' +import { BunkerSigner as NBunkerSigner, parseBunkerInput } from 'nostr-tools/nip46' + +export class BunkerSigner implements ISigner { + signer: NBunkerSigner | null = null + clientSecretKey: Uint8Array + + constructor(clientSecretKey?: Uint8Array) { + this.clientSecretKey = clientSecretKey ?? generateSecretKey() + } + + async login(bunker: string): Promise { + const bunkerPointer = await parseBunkerInput(bunker) + if (!bunkerPointer) { + throw new Error('Invalid bunker') + } + + this.signer = new NBunkerSigner(this.clientSecretKey, bunkerPointer, { + onauth: (url) => { + window.open(url, '_blank') + } + }) + await this.signer.connect() + return await this.signer.getPublicKey() + } + + async getPublicKey() { + if (!this.signer) { + throw new Error('Not logged in') + } + return this.signer.getPublicKey() + } + + async signEvent(draftEvent: TDraftEvent) { + if (!this.signer) { + throw new Error('Not logged in') + } + return this.signer.signEvent({ + ...draftEvent, + pubkey: await this.signer.getPublicKey() + }) + } +} diff --git a/src/renderer/src/providers/NostrProvider/index.tsx b/src/renderer/src/providers/NostrProvider/index.tsx index 21fa025..8ff6d96 100644 --- a/src/renderer/src/providers/NostrProvider/index.tsx +++ b/src/renderer/src/providers/NostrProvider/index.tsx @@ -1,4 +1,5 @@ import { ISigner, TDraftEvent } from '@common/types' +import { bytesToHex, hexToBytes } from '@noble/hashes/utils' import LoginDialog from '@renderer/components/LoginDialog' import { useToast } from '@renderer/hooks' import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList' @@ -10,6 +11,7 @@ import { Event, kinds } from 'nostr-tools' import { createContext, useContext, useEffect, useState } from 'react' import { useRelaySettings } from '../RelaySettingsProvider' import { BrowserNsecSigner } from './browser-nsec.signer' +import { BunkerSigner } from './bunker.signer' import { Nip07Signer } from './nip-07.signer' import { NsecSigner } from './nsec.signer' @@ -18,8 +20,9 @@ type TNostrContext = { pubkey: string | null setPubkey: (pubkey: string) => void nsecLogin: (nsec: string) => Promise - logout: () => Promise nip07Login: () => Promise + bunkerLogin: (bunker: string) => Promise + logout: () => Promise /** * Default publish the event to current relays, user's write relays and additional relays */ @@ -50,7 +53,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const init = async () => { - const account = await storage.getAccountInfo() + const [account] = await storage.getAccounts() if (!account) { if (isElectron(window) || !window.nostr) { return setIsReady(true) @@ -64,14 +67,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } setPubkey(pubkey) setSigner(nip07Signer) - return setIsReady(true) + setIsReady(true) + return await storage.setAccounts([{ pubkey, signerType: 'nip-07' }]) + } + + if (account.pubkey) { + setPubkey(account.pubkey) } if (account.signerType === 'nsec') { const nsecSigner = new NsecSigner() const pubkey = await nsecSigner.getPublicKey() if (!pubkey) { - await storage.setAccountInfo(null) + setPubkey(null) + await storage.setAccounts([]) return setIsReady(true) } setPubkey(pubkey) @@ -81,7 +90,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { if (account.signerType === 'browser-nsec') { if (!account.nsec) { - await storage.setAccountInfo(null) + setPubkey(null) + await storage.setAccounts([]) return setIsReady(true) } const browserNsecSigner = new BrowserNsecSigner() @@ -95,7 +105,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const nip07Signer = new Nip07Signer() const pubkey = await nip07Signer.getPublicKey() if (!pubkey) { - await storage.setAccountInfo(null) + setPubkey(null) + await storage.setAccounts([]) return setIsReady(true) } setPubkey(pubkey) @@ -103,11 +114,25 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { return setIsReady(true) } - await storage.setAccountInfo(null) + if (account.signerType === 'bunker') { + if (!account.bunker || !account.bunkerClientSecretKey) { + setPubkey(null) + await storage.setAccounts([]) + return setIsReady(true) + } + const bunkerSigner = new BunkerSigner(hexToBytes(account.bunkerClientSecretKey)) + const pubkey = await bunkerSigner.login(account.bunker) + setPubkey(pubkey) + setSigner(bunkerSigner) + return setIsReady(true) + } + + await storage.setAccounts([]) return setIsReady(true) } init().catch(() => { - storage.setAccountInfo(null) + setPubkey(null) + storage.setAccounts([]) setIsReady(true) }) }, []) @@ -119,14 +144,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { if (!pubkey) { throw new Error(reason ?? 'invalid nsec') } - await storage.setAccountInfo({ signerType: 'nsec' }) + await storage.setAccounts([{ pubkey, signerType: 'nsec' }]) setPubkey(pubkey) setSigner(nsecSigner) return pubkey } const browserNsecSigner = new BrowserNsecSigner() const pubkey = browserNsecSigner.login(nsec) - await storage.setAccountInfo({ signerType: 'browser-nsec', nsec }) + await storage.setAccounts([{ pubkey, signerType: 'browser-nsec', nsec }]) setPubkey(pubkey) setSigner(browserNsecSigner) return pubkey @@ -139,7 +164,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { if (!pubkey) { throw new Error('You did not allow to access your pubkey') } - await storage.setAccountInfo({ signerType: 'nip-07' }) + await storage.setAccounts([{ pubkey, signerType: 'nip-07' }]) setPubkey(pubkey) setSigner(nip07Signer) } catch (err) { @@ -152,6 +177,27 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } } + const bunkerLogin = async (bunker: string) => { + const bunkerSigner = new BunkerSigner() + const pubkey = await bunkerSigner.login(bunker) + if (!pubkey) { + throw new Error('Invalid bunker') + } + const bunkerUrl = new URL(bunker) + bunkerUrl.searchParams.delete('secret') + await storage.setAccounts([ + { + pubkey, + signerType: 'bunker', + bunker: bunkerUrl.toString(), + bunkerClientSecretKey: bytesToHex(bunkerSigner.clientSecretKey) + } + ]) + setPubkey(pubkey) + setSigner(bunkerSigner) + return pubkey + } + const logout = async () => { if (signer instanceof NsecSigner) { await signer.logout() @@ -159,7 +205,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { signer.logout() } setPubkey(null) - await storage.setAccountInfo(null) + await storage.setAccounts([]) } const signEvent = async (draftEvent: TDraftEvent) => { @@ -207,6 +253,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { setPubkey, nsecLogin, nip07Login, + bunkerLogin, logout, publish, signHttpAuth, diff --git a/src/renderer/src/services/storage.service.ts b/src/renderer/src/services/storage.service.ts index 3558111..d72ec03 100644 --- a/src/renderer/src/services/storage.service.ts +++ b/src/renderer/src/services/storage.service.ts @@ -42,7 +42,7 @@ class StorageService { private initPromise!: Promise private relayGroups: TRelayGroup[] = [] private themeSetting: TThemeSetting = 'system' - private account: TAccount | null = null + private accounts: TAccount[] = [] private storage: Storage = new Storage() constructor() { @@ -58,8 +58,8 @@ class StorageService { this.relayGroups = relayGroupsStr ? JSON.parse(relayGroupsStr) : DEFAULT_RELAY_GROUPS this.themeSetting = ((await this.storage.getItem(StorageKey.THEME_SETTING)) as TThemeSetting) ?? 'system' - const accountStr = await this.storage.getItem(StorageKey.ACCOUNT) - this.account = accountStr ? JSON.parse(accountStr) : null + const accountsStr = await this.storage.getItem(StorageKey.ACCOUNTS) + this.accounts = accountsStr ? JSON.parse(accountsStr) : [] } async getRelayGroups() { @@ -84,19 +84,19 @@ class StorageService { this.themeSetting = themeSetting } - async getAccountInfo() { + async getAccounts() { await this.initPromise - return this.account + return this.accounts } - async setAccountInfo(account: TAccount | null) { + async setAccounts(accounts: TAccount[]) { await this.initPromise - if (account === null) { - await this.storage.removeItem(StorageKey.ACCOUNT) + if (accounts === null) { + await this.storage.removeItem(StorageKey.ACCOUNTS) } else { - await this.storage.setItem(StorageKey.ACCOUNT, JSON.stringify(account)) + await this.storage.setItem(StorageKey.ACCOUNTS, JSON.stringify(accounts)) } - this.account = account + this.accounts = accounts } } diff --git a/tsconfig.web.json b/tsconfig.web.json index 2c1030d..7baccb6 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -8,6 +8,7 @@ "src/common/**/*" ], "compilerOptions": { + "moduleResolution": "bundler", "composite": true, "jsx": "react-jsx", "baseUrl": ".",