diff --git a/src/components/AccountList/index.tsx b/src/components/AccountList/index.tsx index 43047a8..be2eb9b 100644 --- a/src/components/AccountList/index.tsx +++ b/src/components/AccountList/index.tsx @@ -60,7 +60,9 @@ function SignerTypeBadge({ signerType }: { signerType: TSignerType }) { return NIP-07 } else if (signerType === 'bunker') { return Bunker + } else if (signerType === 'ncryptsec') { + return NCRYPTSEC } else { - return NSEC + return NSEC } } diff --git a/src/components/AccountManager/NsecLogin.tsx b/src/components/AccountManager/NsecLogin.tsx deleted file mode 100644 index 286a14d..0000000 --- a/src/components/AccountManager/NsecLogin.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { useNostr } from '@/providers/NostrProvider' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' - -export default function PrivateKeyLogin({ - back, - onLoginSuccess -}: { - back: () => void - onLoginSuccess: () => void -}) { - const { t } = useTranslation() - const { nsecLogin } = useNostr() - const [nsec, setNsec] = useState('') - const [errMsg, setErrMsg] = useState(null) - - const handleInputChange = (e: React.ChangeEvent) => { - setNsec(e.target.value) - setErrMsg(null) - } - - const handleLogin = () => { - if (nsec === '') return - - nsecLogin(nsec) - .then(() => onLoginSuccess()) - .catch((err) => { - setErrMsg(err.message) - }) - } - - return ( - <> -
- {t( - 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.' - )} -
-
- - {errMsg &&
{errMsg}
} -
- - - - ) -} diff --git a/src/components/AccountManager/PrivateKeyLogin.tsx b/src/components/AccountManager/PrivateKeyLogin.tsx new file mode 100644 index 0000000..1bf69bc --- /dev/null +++ b/src/components/AccountManager/PrivateKeyLogin.tsx @@ -0,0 +1,142 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { useNostr } from '@/providers/NostrProvider' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function PrivateKeyLogin({ + back, + onLoginSuccess +}: { + back: () => void + onLoginSuccess: () => void +}) { + return ( + + + nsec + ncryptsec + + + + + + + + + ) +} + +function NsecLogin({ back, onLoginSuccess }: { back: () => void; onLoginSuccess: () => void }) { + const { t } = useTranslation() + const { nsecLogin } = useNostr() + const [nsec, setNsec] = useState('') + const [errMsg, setErrMsg] = useState(null) + const [password, setPassword] = useState('') + + const handleInputChange = (e: React.ChangeEvent) => { + setNsec(e.target.value) + setErrMsg(null) + } + + const handleLogin = () => { + if (nsec === '') return + + nsecLogin(nsec, password) + .then(() => onLoginSuccess()) + .catch((err) => { + setErrMsg(err.message) + }) + } + + return ( +
+
+ {t( + 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.' + )} +
+
+
nsec
+ + {errMsg &&
{errMsg}
} +
+
+
{t('password')}
+ setPassword(e.target.value)} + /> +
+ + +
+ ) +} + +function NcryptsecLogin({ + back, + onLoginSuccess +}: { + back: () => void + onLoginSuccess: () => void +}) { + const { t } = useTranslation() + const { ncryptsecLogin } = useNostr() + const [ncryptsec, setNcryptsec] = useState('') + const [errMsg, setErrMsg] = useState(null) + + const handleInputChange = (e: React.ChangeEvent) => { + setNcryptsec(e.target.value) + setErrMsg(null) + } + + const handleLogin = () => { + if (ncryptsec === '') return + + ncryptsecLogin(ncryptsec) + .then(() => onLoginSuccess()) + .catch((err) => { + setErrMsg(err.message) + }) + } + + return ( +
+
+ {t( + 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.' + )} +
+
+ + {errMsg &&
{errMsg}
} +
+ + +
+ ) +} diff --git a/src/components/AccountManager/index.tsx b/src/components/AccountManager/index.tsx index d6245f5..ba3b0a2 100644 --- a/src/components/AccountManager/index.tsx +++ b/src/components/AccountManager/index.tsx @@ -5,7 +5,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import AccountList from '../AccountList' import BunkerLogin from './BunkerLogin' -import PrivateKeyLogin from './NsecLogin' +import PrivateKeyLogin from './PrivateKeyLogin' import GenerateNewAccount from './GenerateNewAccount' type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | null diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 553ec09..275cf86 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -138,6 +138,10 @@ export default { Bio: 'Bio', 'Nostr Address (NIP-05)': 'Nostr Address (NIP-05)', 'Invalid NIP-05 address': 'Invalid NIP-05 address', - 'Copy private key (nsec)': 'Copy private key (nsec)' + 'Copy private key': 'Copy private key', + 'Enter the password to decrypt your ncryptsec': 'Enter the password to decrypt your ncryptsec', + Back: 'Back', + 'optional: encrypt nsec': 'optional: encrypt nsec', + password: 'password' } } diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 7746ce3..0f05bf7 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -138,6 +138,11 @@ export default { Bio: '简介', 'Nostr Address (NIP-05)': 'Nostr 地址 (NIP-05)', 'Invalid NIP-05 address': '无效的 NIP-05 地址', - 'Copy private key (nsec)': '复制私钥 (nsec)' + 'Copy private key': '复制私钥', + 'Enter the password to decrypt your ncryptsec': '输入密码以解密您的 ncryptsec', + Back: '返回', + 'password (optional): encrypt nsec': '密码 (可选): 加密 nsec', + 'optional: encrypt nsec': '可选: 加密 nsec', + password: '密码' } } diff --git a/src/pages/secondary/SettingsPage/index.tsx b/src/pages/secondary/SettingsPage/index.tsx index cc55cb4..8b9ae3f 100644 --- a/src/pages/secondary/SettingsPage/index.tsx +++ b/src/pages/secondary/SettingsPage/index.tsx @@ -14,11 +14,12 @@ import { useTranslation } from 'react-i18next' export default function SettingsPage({ index }: { index?: number }) { const { t, i18n } = useTranslation() - const { nsec } = useNostr() + const { nsec, ncryptsec } = useNostr() const { push } = useSecondaryPage() const [language, setLanguage] = useState(i18n.language as TLanguage) const { themeSetting, setThemeSetting } = useTheme() const [copiedNsec, setCopiedNsec] = useState(false) + const [copiedNcryptsec, setCopiedNcryptsec] = useState(false) const handleLanguageChange = (value: TLanguage) => { i18n.changeLanguage(value) @@ -75,11 +76,26 @@ export default function SettingsPage({ index }: { index?: number }) { >
-
{t('Copy private key (nsec)')}
+
{t('Copy private key')} (nsec)
{copiedNsec ? : } )} + {!!ncryptsec && ( + { + navigator.clipboard.writeText(ncryptsec) + setCopiedNcryptsec(true) + setTimeout(() => setCopiedNcryptsec(false), 2000) + }} + > +
+ +
{t('Copy private key')} (ncryptsec)
+
+ {copiedNcryptsec ? : } +
+ )}
diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index d5ab46a..5eb7649 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -12,10 +12,13 @@ import storage from '@/services/storage.service' import { ISigner, TAccount, TAccountPointer, TDraftEvent, TProfile, TRelayList } from '@/types' import dayjs from 'dayjs' import { Event, kinds } from 'nostr-tools' +import * as nip19 from 'nostr-tools/nip19' +import * as nip49 from 'nostr-tools/nip49' import { createContext, useContext, useEffect, useState } from 'react' import { BunkerSigner } from './bunker.signer' import { Nip07Signer } from './nip-07.signer' import { NsecSigner } from './nsec.signer' +import { useTranslation } from 'react-i18next' type TNostrContext = { pubkey: string | null @@ -26,8 +29,10 @@ type TNostrContext = { account: TAccountPointer | null accounts: TAccountPointer[] nsec: string | null + ncryptsec: string | null switchAccount: (account: TAccountPointer | null) => Promise - nsecLogin: (nsec: string) => Promise + nsecLogin: (nsec: string, password?: string) => Promise + ncryptsecLogin: (ncryptsec: string) => Promise nip07Login: () => Promise bunkerLogin: (bunker: string) => Promise removeAccount: (account: TAccountPointer) => void @@ -56,9 +61,11 @@ export const useNostr = () => { } export function NostrProvider({ children }: { children: React.ReactNode }) { + const { t } = useTranslation() const { toast } = useToast() const [account, setAccount] = useState(null) const [nsec, setNsec] = useState(null) + const [ncryptsec, setNcryptsec] = useState(null) const [signer, setSigner] = useState(null) const [openLoginDialog, setOpenLoginDialog] = useState(false) const [profile, setProfile] = useState(null) @@ -90,6 +97,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const storedNsec = storage.getAccountNsec(account.pubkey) if (storedNsec) { setNsec(storedNsec) + } else { + setNsec(null) + } + const storedNcryptsec = storage.getAccountNcryptsec(account.pubkey) + if (storedNcryptsec) { + setNcryptsec(storedNcryptsec) + } else { + setNcryptsec(null) } const storedRelayListEvent = storage.getAccountRelayListEvent(account.pubkey) if (storedRelayListEvent) { @@ -171,12 +186,31 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { await loginWithAccountPointer(act) } - const nsecLogin = async (nsec: string) => { + const nsecLogin = async (nsec: string, password?: string) => { const browserNsecSigner = new NsecSigner() - const pubkey = browserNsecSigner.login(nsec) + const { type, data: privkey } = nip19.decode(nsec) + if (type !== 'nsec') { + throw new Error('invalid nsec') + } + const pubkey = browserNsecSigner.login(privkey) + if (password) { + const ncryptsec = nip49.encrypt(privkey, password) + return login(browserNsecSigner, { pubkey, signerType: 'ncryptsec', ncryptsec }) + } return login(browserNsecSigner, { pubkey, signerType: 'nsec', nsec }) } + const ncryptsecLogin = async (ncryptsec: string) => { + const password = prompt(t('Enter the password to decrypt your ncryptsec')) + if (!password) { + throw new Error('Password is required') + } + const privkey = nip49.decrypt(ncryptsec, password) + const browserNsecSigner = new NsecSigner() + const pubkey = browserNsecSigner.login(privkey) + return login(browserNsecSigner, { pubkey, signerType: 'ncryptsec', ncryptsec }) + } + const nip07Login = async () => { try { const nip07Signer = new Nip07Signer() @@ -228,6 +262,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } return login(browserNsecSigner, account) } + } else if (account.signerType === 'ncryptsec') { + if (account.ncryptsec) { + const password = prompt(t('Enter the password to decrypt your ncryptsec')) + if (!password) { + return null + } + const privkey = nip49.decrypt(account.ncryptsec, password) + const browserNsecSigner = new NsecSigner() + browserNsecSigner.login(privkey) + return login(browserNsecSigner, account) + } } else if (account.signerType === 'nip-07') { const nip07Signer = new Nip07Signer() return login(nip07Signer, account) @@ -334,8 +379,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { .getAccounts() .map((act) => ({ pubkey: act.pubkey, signerType: act.signerType })), nsec, + ncryptsec, switchAccount, nsecLogin, + ncryptsecLogin, nip07Login, bunkerLogin, removeAccount, diff --git a/src/providers/NostrProvider/nsec.signer.ts b/src/providers/NostrProvider/nsec.signer.ts index 1f8c07b..519c3e4 100644 --- a/src/providers/NostrProvider/nsec.signer.ts +++ b/src/providers/NostrProvider/nsec.signer.ts @@ -5,14 +5,20 @@ export class NsecSigner implements ISigner { private privkey: Uint8Array | null = null private pubkey: string | null = null - login(nsec: string) { - const { type, data } = nip19.decode(nsec) - if (type !== 'nsec') { - throw new Error('invalid nsec') + login(nsecOrPrivkey: string | Uint8Array) { + let privkey + if (typeof nsecOrPrivkey === 'string') { + const { type, data } = nip19.decode(nsecOrPrivkey) + if (type !== 'nsec') { + throw new Error('invalid nsec') + } + privkey = data + } else { + privkey = nsecOrPrivkey } - this.privkey = data - this.pubkey = nGetPublicKey(data) + this.privkey = privkey + this.pubkey = nGetPublicKey(privkey) return this.pubkey } diff --git a/src/services/storage.service.ts b/src/services/storage.service.ts index 637d01b..322de21 100644 --- a/src/services/storage.service.ts +++ b/src/services/storage.service.ts @@ -156,6 +156,13 @@ class StorageService { return account?.nsec } + getAccountNcryptsec(pubkey: string) { + const account = this.accounts.find( + (act) => act.pubkey === pubkey && act.signerType === 'ncryptsec' + ) + return account?.ncryptsec + } + addAccount(account: TAccount) { if (this.accounts.find((act) => isSameAccount(act, account))) { return diff --git a/src/types.ts b/src/types.ts index c0dc785..ae51a35 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,11 +53,12 @@ export interface ISigner { signEvent: (draftEvent: TDraftEvent) => Promise } -export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' +export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' | 'ncryptsec' export type TAccount = { pubkey: string signerType: TSignerType + ncryptsec?: string nsec?: string bunker?: string bunkerClientSecretKey?: string