diff --git a/.env b/.env index 30ed0af..351d2d6 100644 --- a/.env +++ b/.env @@ -2,5 +2,5 @@ # change if you're using a different noauthd server REACT_APP_WEB_PUSH_PUBKEY=BNW_39YcKbV4KunFxFhvMW5JUs8AljfFnGUeZpaerO-gwCoWyQat5ol0xOGB8MLaqqCbz0iptd2Qv3SToSGynMk #REACT_APP_NOAUTHD_URL=http://localhost:8000 -REACT_APP_NOAUTHD_URL=https://noauthd.login.nostrapps.org +REACT_APP_NOAUTHD_URL=https://noauthd.nsec.app REACT_APP_DOMAIN=nsec.app \ No newline at end of file diff --git a/src/components/Modal/ModalImportKeys/ModalImportKeys.tsx b/src/components/Modal/ModalImportKeys/ModalImportKeys.tsx index 216353d..2bfc572 100644 --- a/src/components/Modal/ModalImportKeys/ModalImportKeys.tsx +++ b/src/components/Modal/ModalImportKeys/ModalImportKeys.tsx @@ -28,7 +28,8 @@ export const ModalImportKeys = () => { e.preventDefault() try { if (!enteredNsec.trim().length) return - const k: any = await swicCall('importKey', enteredNsec) + const enteredName = '' // FIXME get from input + const k: any = await swicCall('importKey', enteredName, enteredNsec) notify('Key imported!', 'success') navigate(`/key/${k.npub}`) } catch (error: any) { diff --git a/src/components/Modal/ModalLogin/ModalLogin.tsx b/src/components/Modal/ModalLogin/ModalLogin.tsx index bc9b838..0dcc091 100644 --- a/src/components/Modal/ModalLogin/ModalLogin.tsx +++ b/src/components/Modal/ModalLogin/ModalLogin.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar' import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { swicCall } from '@/modules/swic' @@ -6,7 +6,6 @@ import { Modal } from '@/shared/Modal/Modal' import { MODAL_PARAMS_KEYS } from '@/types/modal' import { IconButton, Stack, Typography } from '@mui/material' import { StyledAppLogo } from './styled' -import { nip19 } from 'nostr-tools' import { Input } from '@/shared/Input/Input' import { Button } from '@/shared/Button/Button' import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined' @@ -15,6 +14,8 @@ import { useNavigate } from 'react-router-dom' import { useForm } from 'react-hook-form' import { FormInputType, schema } from './const' import { yupResolver } from '@hookform/resolvers/yup' +import { DOMAIN } from '@/utils/consts' +import { fetchNip05 } from '@/utils/helpers/helpers' export const ModalLogin = () => { const { getModalOpened, createHandleCloseReplace } = useModalSearchParams() @@ -51,18 +52,15 @@ export const ModalLogin = () => { const submitHandler = async (values: FormInputType) => { try { - const [username, domain] = values.username.split('@') - const response = await fetch( - `https://${domain}/.well-known/nostr.json?name=${username}`, - ) - const getNpub: { - names: { - [name: string]: string - } - } = await response.json() - - const pubkey = getNpub.names[username] - const npub = nip19.npubEncode(pubkey) + let npub = values.username + if (!npub.startsWith('npub1') && !npub.includes('@')) { + npub += '@' + DOMAIN + } + if (npub.includes('@')) { + const npubNip05 = await fetchNip05(npub) + if (!npubNip05) throw new Error(`Username ${npub} not found`) + npub = npubNip05 + } const passphrase = values.password console.log('fetch', npub, passphrase) @@ -103,9 +101,9 @@ export const ModalLogin = () => { @@ -130,7 +128,7 @@ export const ModalLogin = () => { error={!!errors.password} /> diff --git a/src/components/Modal/ModalSignUp/ModalSignUp.tsx b/src/components/Modal/ModalSignUp/ModalSignUp.tsx index 07cfe0b..4920b23 100644 --- a/src/components/Modal/ModalSignUp/ModalSignUp.tsx +++ b/src/components/Modal/ModalSignUp/ModalSignUp.tsx @@ -10,6 +10,8 @@ import { Button } from '@/shared/Button/Button' import { CheckmarkIcon } from '@/assets' import { swicCall } from '@/modules/swic' import { useNavigate } from 'react-router-dom' +import { DOMAIN, NOAUTHD_URL } from '@/utils/consts' +import { fetchNip05 } from '@/utils/helpers/helpers' export const ModalSignUp = () => { const { getModalOpened, createHandleCloseReplace } = useModalSearchParams() @@ -21,27 +23,41 @@ export const ModalSignUp = () => { const navigate = useNavigate() const [enteredValue, setEnteredValue] = useState('') + const [isAvailable, setIsAvailable] = useState(false) - const handleInputChange = (e: ChangeEvent) => { + const handleInputChange = async (e: ChangeEvent) => { setEnteredValue(e.target.value) + const name = e.target.value.trim() + if (name) { + const npubNip05 = await fetchNip05(`${name}@${DOMAIN}`) + setIsAvailable(!npubNip05) + } else { + setIsAvailable(false) + } } - const isAvailable = enteredValue.trim().length > 2 - - const inputHelperText = isAvailable ? ( - <> - Available - + const inputHelperText = enteredValue + ? ( + isAvailable ? ( + <> + Available + + ) : ( + <> + Already taken + + ) ) : ( "Don't worry, username can be changed later." - ) + ); const handleSubmit = async (e: React.FormEvent) => { - if (!enteredValue.trim().length) return + const name = enteredValue.trim() + if (!name.length) return e.preventDefault() try { - const k: any = await swicCall('generateKey') - notify(`New key ${k.npub}`, 'success') + const k: any = await swicCall('generateKey', name) + notify(`Account created for "${name}"`, 'success') navigate(`/key/${k.npub}`) } catch (error: any) { notify(error.message, 'error') @@ -73,22 +89,26 @@ export const ModalSignUp = () => { placeholder='Username' helperText={inputHelperText} endAdornment={ - @nsec.app + @{DOMAIN} } onChange={handleInputChange} value={enteredValue} helperTextProps={{ sx: { '&.helper_text': { - color: isAvailable + color: enteredValue && isAvailable ? theme.palette.success.main - : theme.palette.textSecondaryDecorate.main, + : (enteredValue && !isAvailable + ? theme.palette.error.main + : theme.palette.textSecondaryDecorate.main + ) + , }, }, }} /> diff --git a/src/modules/backend.ts b/src/modules/backend.ts index e50ebed..ea1ea65 100644 --- a/src/modules/backend.ts +++ b/src/modules/backend.ts @@ -8,9 +8,10 @@ import NDK, { NDKPrivateKeySigner, NDKSigner, } from '@nostr-dev-kit/ndk' -import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS } from '../utils/consts' +import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS, MIN_POW, MAX_POW } from '../utils/consts' import { Nip04 } from './nip04' import { getReqPerm, getShortenNpub, isPackagePerm } from '@/utils/helpers/helpers' +import { NostrPowEvent, minePow } from './pow' //import { PrivateKeySigner } from './signer' //const PERF_TEST = false @@ -286,7 +287,8 @@ export class NoauthBackend { }) if (r.status !== 200 && r.status !== 201) { console.log('Fetch error', url, method, r.status) - throw new Error('Failed to fetch ' + url) + const body = await r.json() + throw new Error('Failed to fetch ' + url, { cause: body }) } return await r.json() @@ -297,11 +299,13 @@ export class NoauthBackend { url, method = 'GET', body = '', + pow = 0 }: { npub: string url: string method: string body: string + pow?: number }) { const { data: pubkey } = nip19.decode(npub) @@ -320,6 +324,15 @@ export class NoauthBackend { }) if (body) authEvent.tags.push(['payload', await this.sha256(body)]) + // generate pow on auth evevnt + if (pow) { + const start = Date.now() + const powEvent: NostrPowEvent = authEvent.rawEvent() + const minedEvent = minePow(powEvent, pow) + console.log("mined pow of", pow, "in", Date.now() - start, "ms", minedEvent) + authEvent.tags = minedEvent.tags + } + authEvent.sig = await authEvent.sign(key.signer) const auth = this.swg.btoa(JSON.stringify(authEvent.rawEvent())) @@ -354,6 +367,7 @@ export class NoauthBackend { body, }) } + private async sendKeyToServer(npub: string, enckey: string, pwh: string) { const body = JSON.stringify({ npub, @@ -389,6 +403,38 @@ export class NoauthBackend { }) } + private async sendNameToServer(npub: string, name: string) { + const body = JSON.stringify({ + npub, + name + }) + + const method = 'POST' + const url = `${NOAUTHD_URL}/name` + + // mas pow should be 21 or something like that + let pow = MIN_POW; + while(pow <= MAX_POW) { + console.log("Try name", name, "pow", pow); + try { + return await this.sendPostAuthd({ + npub, + url, + method, + body, + pow + }) + } catch (e: any) { + console.log("error", e.cause); + if (e.cause && e.cause.minPow > pow) + pow = e.cause.minPow + else + throw e; + } + } + throw new Error("Too many requests, retry later") + } + private notify() { // FIXME collect info from accessBuffer and confirmBuffer // and update the notifications @@ -475,7 +521,11 @@ export class NoauthBackend { return generatePrivateKey() } - public async addKey(nsec?: string): Promise { + public async addKey(name: string, nsec?: string): Promise { + + // lowercase + name = name.trim().toLocaleLowerCase() + let sk = '' if (nsec) { const { type, data } = nip19.decode(nsec) @@ -486,14 +536,22 @@ export class NoauthBackend { } const pubkey = getPublicKey(sk) const npub = nip19.npubEncode(pubkey) + const localKey = await this.keysModule.generateLocalKey() const enckey = await this.keysModule.encryptKeyLocal(sk, localKey) // @ts-ignore - const dbKey: DbKey = { npub, enckey, localKey } + const dbKey: DbKey = { npub, name, enckey, localKey } await dbi.addKey(dbKey) this.enckeys.push(dbKey) await this.startKey({ npub, sk }) + // assign nip05 before adding the key + // FIXME set name to db and if this call to 'send' fails + // then retry later + console.log("adding key", npub, name) + if (name) + await this.sendNameToServer(npub, name) + const sub = await this.swg.registration.pushManager.getSubscription() if (sub) await this.sendSubscriptionToServer(npub, sub) @@ -766,14 +824,14 @@ export class NoauthBackend { await this.startKey({ npub, sk }) } - private async generateKey() { - const k = await this.addKey() + private async generateKey(name: string) { + const k = await this.addKey(name) this.updateUI() return k } - private async importKey(nsec: string) { - const k = await this.addKey(nsec) + private async importKey(name: string, nsec: string) { + const k = await this.addKey(name, nsec) this.updateUI() return k } @@ -879,9 +937,9 @@ export class NoauthBackend { //console.log("UI message", id, method, args) let result = undefined if (method === 'generateKey') { - result = await this.generateKey() + result = await this.generateKey(args[0]) } else if (method === 'importKey') { - result = await this.importKey(args[0]) + result = await this.importKey(args[0], args[1]) } else if (method === 'saveKey') { result = await this.saveKey(args[0], args[1]) } else if (method === 'fetchKey') { @@ -902,6 +960,7 @@ export class NoauthBackend { result, }) } catch (e: any) { + console.log("backend error", e) event.source.postMessage({ id, error: e.toString(), diff --git a/src/modules/pow.ts b/src/modules/pow.ts new file mode 100644 index 0000000..33f03a7 --- /dev/null +++ b/src/modules/pow.ts @@ -0,0 +1,51 @@ +// based on https://git.v0l.io/Kieran/snort/src/branch/main/packages/system/src/pow-util.ts + +import { sha256 } from "@noble/hashes/sha256"; +import { bytesToHex } from "@noble/hashes/utils"; + +export interface NostrPowEvent { + id?: string; + pubkey: string; + created_at: number; + kind?: number; + tags: Array>; + content: string; + sig?: string; +} + +export function minePow(e: NostrPowEvent, target: number) { + let ctr = 0; + + let nonceTagIdx = e.tags.findIndex(a => a[0] === "nonce"); + if (nonceTagIdx === -1) { + nonceTagIdx = e.tags.length; + e.tags.push(["nonce", ctr.toString(), target.toString()]); + } + do { + e.tags[nonceTagIdx][1] = (++ctr).toString(); + e.id = createId(e); + } while (countLeadingZeros(e.id) < target); + + return e; +} + +function createId(e: NostrPowEvent) { + const payload = [0, e.pubkey, e.created_at, e.kind, e.tags, e.content]; + return bytesToHex(sha256(JSON.stringify(payload))); +} + +export function countLeadingZeros(hex: string) { + let count = 0; + + for (let i = 0; i < hex.length; i++) { + const nibble = parseInt(hex[i], 16); + if (nibble === 0) { + count += 4; + } else { + count += Math.clz32(nibble) - 28; + break; + } + } + + return count; +} \ No newline at end of file diff --git a/src/pages/HomePage/Home.Page.tsx b/src/pages/HomePage/Home.Page.tsx index 964cf3b..98f9f4c 100644 --- a/src/pages/HomePage/Home.Page.tsx +++ b/src/pages/HomePage/Home.Page.tsx @@ -24,7 +24,7 @@ const HomePage = () => { return ( - {isNoKeys ? 'Welcome' : 'Keys:'} + {isNoKeys ? 'Welcome' : 'Accounts:'} {isNoKeys && ( diff --git a/src/pages/KeyPage/Key.Page.tsx b/src/pages/KeyPage/Key.Page.tsx index 82992ff..cb85195 100644 --- a/src/pages/KeyPage/Key.Page.tsx +++ b/src/pages/KeyPage/Key.Page.tsx @@ -18,18 +18,22 @@ import UserValueSection from './components/UserValueSection' import { useTriggerConfirmModal } from './hooks/useTriggerConfirmModal' import { useLiveQuery } from 'dexie-react-hooks' import { checkNpubSyncQuerier } from './utils' +import { DOMAIN } from '@/utils/consts' const KeyPage = () => { const { npub = '' } = useParams<{ npub: string }>() - const { apps, pending, perms } = useAppSelector((state) => state.content) + const { keys, apps, pending, perms } = useAppSelector((state) => state.content) const isSynced = useLiveQuery(checkNpubSyncQuerier(npub), [npub], false) const { handleOpen } = useModalSearchParams() - const { userNameWithPrefix } = useProfile(npub) + // const { userNameWithPrefix } = useProfile(npub) const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning() + const key = keys.find(k => k.npub === npub) + const username = key?.name ? `${key?.name}@${DOMAIN}` : '' + const filteredApps = apps.filter((a) => a.npub === npub) const { prepareEventPendings } = useTriggerConfirmModal( npub, @@ -53,8 +57,8 @@ const KeyPage = () => { )} void; + debounceTimeout: number; +}; + +export const DebounceInput = (props: InputProps & DebounceProps) => { + const { handleDebounce, debounceTimeout, ...rest } = props; + + const timerRef = useRef(); + + const handleChange = (event: React.ChangeEvent) => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + handleDebounce(event.target.value); + }, debounceTimeout); + }; + + // @ts-ignore + return ; +} + diff --git a/src/utils/consts.ts b/src/utils/consts.ts index ac08807..1a53d68 100644 --- a/src/utils/consts.ts +++ b/src/utils/consts.ts @@ -3,6 +3,9 @@ export const NOAUTHD_URL = process.env.REACT_APP_NOAUTHD_URL export const WEB_PUSH_PUBKEY = process.env.REACT_APP_WEB_PUSH_PUBKEY export const DOMAIN = process.env.REACT_APP_DOMAIN +export const MIN_POW = 14 +export const MAX_POW = 19 + export enum ACTION_TYPE { BASIC = 'basic', ADVANCED = 'advanced', diff --git a/src/utils/helpers/helpers.ts b/src/utils/helpers/helpers.ts index 41bd196..bc8d343 100644 --- a/src/utils/helpers/helpers.ts +++ b/src/utils/helpers/helpers.ts @@ -1,82 +1,101 @@ -import { nip19 } from 'nostr-tools' -import { ACTION_TYPE, NIP46_RELAYS } from '../consts' -import { DbPending } from '@/modules/db' -import { MetaEvent } from '@/types/meta-event' +import { nip19 } from "nostr-tools"; +import { ACTION_TYPE, NIP46_RELAYS } from "../consts"; +import { DbPending } from "@/modules/db"; +import { MetaEvent } from "@/types/meta-event"; export async function call(cb: () => any) { - try { - return await cb() - } catch (e) { - console.log(`Error: ${e}`) - } + try { + return await cb(); + } catch (e) { + console.log(`Error: ${e}`); + } } -export const getShortenNpub = (npub = '') => { - return npub.substring(0, 10) + '...' + npub.slice(-4) -} +export const getShortenNpub = (npub = "") => { + return npub.substring(0, 10) + "..." + npub.slice(-4); +}; export const getProfileUsername = (profile: MetaEvent | null, npub: string) => { - return ( - profile?.info?.name || - profile?.info?.display_name || - getShortenNpub(npub) - ) -} + return ( + profile?.info?.name || profile?.info?.display_name || getShortenNpub(npub) + ); +}; -export const getBunkerLink = (npub = '') => { - if (!npub) return '' - const { data: pubkey } = nip19.decode(npub) - return `bunker://${pubkey}?relay=${NIP46_RELAYS[0]}` -} +export const getBunkerLink = (npub = "") => { + if (!npub) return ""; + const { data: pubkey } = nip19.decode(npub); + return `bunker://${pubkey}?relay=${NIP46_RELAYS[0]}`; +}; export async function askNotificationPermission() { - return new Promise((ok, rej) => { - // Let's check if the browser supports notifications - if (!('Notification' in window)) { - rej('This browser does not support notifications.') - } else { - Notification.requestPermission().then(() => { - if (Notification.permission === 'granted') ok() - else rej() - }) - } - }) + return new Promise((ok, rej) => { + // Let's check if the browser supports notifications + if (!("Notification" in window)) { + rej("This browser does not support notifications."); + } else { + Notification.requestPermission().then(() => { + if (Notification.permission === "granted") ok(); + else rej(); + }); + } + }); } export function getSignReqKind(req: DbPending): number | undefined { - try { - const data = JSON.parse(JSON.parse(req.params)[0]) - return data.kind - } catch {} - return undefined + try { + const data = JSON.parse(JSON.parse(req.params)[0]); + return data.kind; + } catch {} + return undefined; } export function getReqPerm(req: DbPending): string { - if (req.method === 'sign_event') { - const kind = getSignReqKind(req) - if (kind !== undefined) return `${req.method}:${kind}` - } - return req.method + if (req.method === "sign_event") { + const kind = getSignReqKind(req); + if (kind !== undefined) return `${req.method}:${kind}`; + } + return req.method; } export function isPackagePerm(perm: string, reqPerm: string) { - if (perm === ACTION_TYPE.BASIC) { - switch (reqPerm) { - case 'connect': - case 'get_public_key': - case 'nip04_decrypt': - case 'nip04_encrypt': - case 'sign_event:0': - case 'sign_event:1': - case 'sign_event:3': - case 'sign_event:6': - case 'sign_event:7': - case 'sign_event:9734': - case 'sign_event:10002': - case 'sign_event:30023': - case 'sign_event:10000': - return true - } - } - return false + if (perm === ACTION_TYPE.BASIC) { + switch (reqPerm) { + case "connect": + case "get_public_key": + case "nip04_decrypt": + case "nip04_encrypt": + case "sign_event:0": + case "sign_event:1": + case "sign_event:3": + case "sign_event:6": + case "sign_event:7": + case "sign_event:9734": + case "sign_event:10002": + case "sign_event:30023": + case "sign_event:10000": + return true; + } + } + return false; +} + +export async function fetchNip05(value: string, origin?: string) { + try { + const [username, domain] = value.split("@"); + if (!origin) origin = `https://${domain}` + const response = await fetch( + `${origin}/.well-known/nostr.json?name=${username}` + ); + const getNpub: { + names: { + [name: string]: string; + }; + } = await response.json(); + + const pubkey = getNpub.names[username]; + return nip19.npubEncode(pubkey); + } catch (e) { + console.log("Failed to fetch nip05", value, "error: " + e); + return '' + } }