feat: bunker login

This commit is contained in:
codytseng
2024-12-18 14:54:35 +08:00
parent 04dd682e0d
commit 4a39941352
11 changed files with 196 additions and 30 deletions

View File

@@ -1,5 +1,5 @@
export const StorageKey = { export const StorageKey = {
THEME_SETTING: 'themeSetting', THEME_SETTING: 'themeSetting',
RELAY_GROUPS: 'relayGroups', RELAY_GROUPS: 'relayGroups',
ACCOUNT: 'account' ACCOUNTS: 'accounts'
} }

View File

@@ -51,6 +51,9 @@ export type TElectronWindow = {
} }
export type TAccount = { export type TAccount = {
signerType: 'nsec' | 'browser-nsec' | 'nip-07' pubkey: string
signerType: 'nsec' | 'browser-nsec' | 'nip-07' | 'bunker'
nsec?: string nsec?: string
bunker?: string
bunkerClientSecretKey?: string
} }

View File

@@ -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<string | null>(null)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<>
<div className="space-y-1">
<Input
placeholder="bunker://..."
value={bunkerInput}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
</div>
<Button onClick={handleLogin} disabled={pending}>
<Loader className={pending ? 'animate-spin' : 'hidden'} />
{t('Login')}
</Button>
</>
)
}

View File

@@ -10,6 +10,8 @@ import { IS_ELECTRON } from '@renderer/lib/env'
import { useNostr } from '@renderer/providers/NostrProvider' import { useNostr } from '@renderer/providers/NostrProvider'
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
import { Dispatch, useState } from 'react' import { Dispatch, useState } from 'react'
import { useTranslation } from 'react-i18next'
import BunkerLogin from './BunkerLogin'
import PrivateKeyLogin from './NsecLogin' import PrivateKeyLogin from './NsecLogin'
export default function LoginDialog({ export default function LoginDialog({
@@ -19,7 +21,8 @@ export default function LoginDialog({
open: boolean open: boolean
setOpen: Dispatch<boolean> setOpen: Dispatch<boolean>
}) { }) {
const [loginMethod, setLoginMethod] = useState<'nsec' | 'nip07' | null>(null) const { t } = useTranslation()
const [loginMethod, setLoginMethod] = useState<'nsec' | 'nip07' | 'bunker' | null>(null)
const { nip07Login } = useNostr() const { nip07Login } = useNostr()
return ( return (
@@ -39,15 +42,28 @@ export default function LoginDialog({
</div> </div>
<PrivateKeyLogin onLoginSuccess={() => setOpen(false)} /> <PrivateKeyLogin onLoginSuccess={() => setOpen(false)} />
</> </>
) : loginMethod === 'bunker' ? (
<>
<div
className="absolute left-4 top-4 opacity-70 hover:opacity-100 cursor-pointer"
onClick={() => setLoginMethod(null)}
>
<ArrowLeft className="h-4 w-4" />
</div>
<BunkerLogin onLoginSuccess={() => setOpen(false)} />
</>
) : ( ) : (
<> <>
{!IS_ELECTRON && !!window.nostr && ( {!IS_ELECTRON && !!window.nostr && (
<Button onClick={() => nip07Login().then(() => setOpen(false))} className="w-full"> <Button onClick={() => nip07Login().then(() => setOpen(false))} className="w-full">
Login with NIP-07 {t('Login with Browser Extension')}
</Button> </Button>
)} )}
<Button variant="secondary" onClick={() => setLoginMethod('bunker')} className="w-full">
{t('Login with Bunker')}
</Button>
<Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full"> <Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full">
Login with Private Key {t('Login with Private Key')}
</Button> </Button>
</> </>
)} )}

View File

@@ -84,6 +84,9 @@ export default {
'Your nsec will be encrypted using the {{backend}}.': 'Your nsec will be encrypted using the {{backend}}.':
'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.' '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'
} }
} }

View File

@@ -82,6 +82,9 @@ export default {
'此设备上没有可用的密码管理工具。您的密钥将不受保护', '此设备上没有可用的密码管理工具。您的密钥将不受保护',
'Your nsec will be encrypted using the {{backend}}.': '您的密钥将使用 {{backend}} 加密', '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.': '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': '私钥登录'
} }
} }

View File

@@ -39,9 +39,11 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
) )
useEffect(() => { useEffect(() => {
if (isReady || !accountPubkey) return if (!accountPubkey) return
const init = async () => { const init = async () => {
setIsReady(false)
setFollowListEvent(undefined)
const event = await client.fetchFollowListEvent(accountPubkey) const event = await client.fetchFollowListEvent(accountPubkey)
setFollowListEvent(event) setFollowListEvent(event)
setIsReady(true) setIsReady(true)

View File

@@ -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<string> {
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()
})
}
}

View File

@@ -1,4 +1,5 @@
import { ISigner, TDraftEvent } from '@common/types' import { ISigner, TDraftEvent } from '@common/types'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import LoginDialog from '@renderer/components/LoginDialog' import LoginDialog from '@renderer/components/LoginDialog'
import { useToast } from '@renderer/hooks' import { useToast } from '@renderer/hooks'
import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList' import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
@@ -10,6 +11,7 @@ import { Event, kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
import { useRelaySettings } from '../RelaySettingsProvider' import { useRelaySettings } from '../RelaySettingsProvider'
import { BrowserNsecSigner } from './browser-nsec.signer' import { BrowserNsecSigner } from './browser-nsec.signer'
import { BunkerSigner } from './bunker.signer'
import { Nip07Signer } from './nip-07.signer' import { Nip07Signer } from './nip-07.signer'
import { NsecSigner } from './nsec.signer' import { NsecSigner } from './nsec.signer'
@@ -18,8 +20,9 @@ type TNostrContext = {
pubkey: string | null pubkey: string | null
setPubkey: (pubkey: string) => void setPubkey: (pubkey: string) => void
nsecLogin: (nsec: string) => Promise<string> nsecLogin: (nsec: string) => Promise<string>
logout: () => Promise<void>
nip07Login: () => Promise<void> nip07Login: () => Promise<void>
bunkerLogin: (bunker: string) => Promise<string>
logout: () => Promise<void>
/** /**
* Default publish the event to current relays, user's write relays and additional relays * 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(() => { useEffect(() => {
const init = async () => { const init = async () => {
const account = await storage.getAccountInfo() const [account] = await storage.getAccounts()
if (!account) { if (!account) {
if (isElectron(window) || !window.nostr) { if (isElectron(window) || !window.nostr) {
return setIsReady(true) return setIsReady(true)
@@ -64,14 +67,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
setPubkey(pubkey) setPubkey(pubkey)
setSigner(nip07Signer) 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') { if (account.signerType === 'nsec') {
const nsecSigner = new NsecSigner() const nsecSigner = new NsecSigner()
const pubkey = await nsecSigner.getPublicKey() const pubkey = await nsecSigner.getPublicKey()
if (!pubkey) { if (!pubkey) {
await storage.setAccountInfo(null) setPubkey(null)
await storage.setAccounts([])
return setIsReady(true) return setIsReady(true)
} }
setPubkey(pubkey) setPubkey(pubkey)
@@ -81,7 +90,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (account.signerType === 'browser-nsec') { if (account.signerType === 'browser-nsec') {
if (!account.nsec) { if (!account.nsec) {
await storage.setAccountInfo(null) setPubkey(null)
await storage.setAccounts([])
return setIsReady(true) return setIsReady(true)
} }
const browserNsecSigner = new BrowserNsecSigner() const browserNsecSigner = new BrowserNsecSigner()
@@ -95,7 +105,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const nip07Signer = new Nip07Signer() const nip07Signer = new Nip07Signer()
const pubkey = await nip07Signer.getPublicKey() const pubkey = await nip07Signer.getPublicKey()
if (!pubkey) { if (!pubkey) {
await storage.setAccountInfo(null) setPubkey(null)
await storage.setAccounts([])
return setIsReady(true) return setIsReady(true)
} }
setPubkey(pubkey) setPubkey(pubkey)
@@ -103,11 +114,25 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return setIsReady(true) 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) return setIsReady(true)
} }
init().catch(() => { init().catch(() => {
storage.setAccountInfo(null) setPubkey(null)
storage.setAccounts([])
setIsReady(true) setIsReady(true)
}) })
}, []) }, [])
@@ -119,14 +144,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (!pubkey) { if (!pubkey) {
throw new Error(reason ?? 'invalid nsec') throw new Error(reason ?? 'invalid nsec')
} }
await storage.setAccountInfo({ signerType: 'nsec' }) await storage.setAccounts([{ pubkey, signerType: 'nsec' }])
setPubkey(pubkey) setPubkey(pubkey)
setSigner(nsecSigner) setSigner(nsecSigner)
return pubkey return pubkey
} }
const browserNsecSigner = new BrowserNsecSigner() const browserNsecSigner = new BrowserNsecSigner()
const pubkey = browserNsecSigner.login(nsec) const pubkey = browserNsecSigner.login(nsec)
await storage.setAccountInfo({ signerType: 'browser-nsec', nsec }) await storage.setAccounts([{ pubkey, signerType: 'browser-nsec', nsec }])
setPubkey(pubkey) setPubkey(pubkey)
setSigner(browserNsecSigner) setSigner(browserNsecSigner)
return pubkey return pubkey
@@ -139,7 +164,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (!pubkey) { if (!pubkey) {
throw new Error('You did not allow to access your 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) setPubkey(pubkey)
setSigner(nip07Signer) setSigner(nip07Signer)
} catch (err) { } 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 () => { const logout = async () => {
if (signer instanceof NsecSigner) { if (signer instanceof NsecSigner) {
await signer.logout() await signer.logout()
@@ -159,7 +205,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
signer.logout() signer.logout()
} }
setPubkey(null) setPubkey(null)
await storage.setAccountInfo(null) await storage.setAccounts([])
} }
const signEvent = async (draftEvent: TDraftEvent) => { const signEvent = async (draftEvent: TDraftEvent) => {
@@ -207,6 +253,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setPubkey, setPubkey,
nsecLogin, nsecLogin,
nip07Login, nip07Login,
bunkerLogin,
logout, logout,
publish, publish,
signHttpAuth, signHttpAuth,

View File

@@ -42,7 +42,7 @@ class StorageService {
private initPromise!: Promise<void> private initPromise!: Promise<void>
private relayGroups: TRelayGroup[] = [] private relayGroups: TRelayGroup[] = []
private themeSetting: TThemeSetting = 'system' private themeSetting: TThemeSetting = 'system'
private account: TAccount | null = null private accounts: TAccount[] = []
private storage: Storage = new Storage() private storage: Storage = new Storage()
constructor() { constructor() {
@@ -58,8 +58,8 @@ class StorageService {
this.relayGroups = relayGroupsStr ? JSON.parse(relayGroupsStr) : DEFAULT_RELAY_GROUPS this.relayGroups = relayGroupsStr ? JSON.parse(relayGroupsStr) : DEFAULT_RELAY_GROUPS
this.themeSetting = this.themeSetting =
((await this.storage.getItem(StorageKey.THEME_SETTING)) as TThemeSetting) ?? 'system' ((await this.storage.getItem(StorageKey.THEME_SETTING)) as TThemeSetting) ?? 'system'
const accountStr = await this.storage.getItem(StorageKey.ACCOUNT) const accountsStr = await this.storage.getItem(StorageKey.ACCOUNTS)
this.account = accountStr ? JSON.parse(accountStr) : null this.accounts = accountsStr ? JSON.parse(accountsStr) : []
} }
async getRelayGroups() { async getRelayGroups() {
@@ -84,19 +84,19 @@ class StorageService {
this.themeSetting = themeSetting this.themeSetting = themeSetting
} }
async getAccountInfo() { async getAccounts() {
await this.initPromise await this.initPromise
return this.account return this.accounts
} }
async setAccountInfo(account: TAccount | null) { async setAccounts(accounts: TAccount[]) {
await this.initPromise await this.initPromise
if (account === null) { if (accounts === null) {
await this.storage.removeItem(StorageKey.ACCOUNT) await this.storage.removeItem(StorageKey.ACCOUNTS)
} else { } 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
} }
} }

View File

@@ -8,6 +8,7 @@
"src/common/**/*" "src/common/**/*"
], ],
"compilerOptions": { "compilerOptions": {
"moduleResolution": "bundler",
"composite": true, "composite": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"baseUrl": ".", "baseUrl": ".",