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 = {
THEME_SETTING: 'themeSetting',
RELAY_GROUPS: 'relayGroups',
ACCOUNT: 'account'
ACCOUNTS: 'accounts'
}

View File

@@ -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
}

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 { 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<boolean>
}) {
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({
</div>
<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 && (
<Button onClick={() => nip07Login().then(() => setOpen(false))} className="w-full">
Login with NIP-07
{t('Login with Browser Extension')}
</Button>
)}
<Button variant="secondary" onClick={() => setLoginMethod('bunker')} className="w-full">
{t('Login with Bunker')}
</Button>
<Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full">
Login with Private Key
{t('Login with Private Key')}
</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}}.',
'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}} 加密',
'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(() => {
if (isReady || !accountPubkey) return
if (!accountPubkey) return
const init = async () => {
setIsReady(false)
setFollowListEvent(undefined)
const event = await client.fetchFollowListEvent(accountPubkey)
setFollowListEvent(event)
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 { 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<string>
logout: () => 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
*/
@@ -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,

View File

@@ -42,7 +42,7 @@ class StorageService {
private initPromise!: Promise<void>
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
}
}