Compare commits

..

6 Commits

18 changed files with 185 additions and 70 deletions

23
README
View File

@ -1,23 +0,0 @@
Noauth - Nostr key manager
--------------------------
THIS IS BETA SOFTWARE, DON'T USE WITH REAL KEYS!
This is a web-based nostr signer app, it uses nip46 signer
running inside a service worker, if SW is not running -
a noauthd server sends a push message and wakes SW up. Also,
keys can be saved to server and fetched later in an end-to-end
encrypted way. Keys are encrypted with user-defined password,
a good key derivation function is used to resist brute force.
This app works in Chrome on desktop and Android out of the box,
try it with snort.social (use bunker:/... string as 'login string').
On iOS web push notifications are still experimental, eventually
it will work on iOS out of the box too.
It works across devices, but that's unreliable, especially if
signer is on mobile - if smartphone is locked then service worker might
not wake up. Thanks to cloud sync/recovery of keys users can import
their keys into this app on every device and then it works well.

95
README.md Normal file
View File

@ -0,0 +1,95 @@
Noauth - Nostr key manager
--------------------------
Nsec.app is a web app to store your Nostr keys
and provide remote access to keys using nip46.
Features:
- non-custodial store for your keys
- can store many keys
- provides nip46 access to apps
- permission management for connected apps
- works in any browser or platform
- background operation even if app tab is closed
- cloud e2ee sync for your keys
- support for OAuth-like signin flow
How it works
------------
This is a web-based nostr signer app, it uses nip46 signer
running inside a service worker, if SW is not running -
a noauthd server sends a push message and wakes SW up. Also,
keys can be saved to server and fetched later in an end-to-end
encrypted way. Keys are encrypted with user-defined password,
a good key derivation function is used to resist brute force.
It works across devices, but that's unreliable, especially if
signer is on mobile - if your phone is locked then service worker might
not wake up. Thanks to cloud sync/recovery of keys users can import
their keys into this app on every device and then it works well.
How to self-host
----------------
This app is non-custodial, so there isn't much need for
self-hosting. However, if you'd like to run your own version of
it, here is how to do it:
Create web push keys (https://github.com/web-push-libs/web-push):
```
npm install web-push;
web-push generate-vapid-keys --json
```
Edit .end in noauth:
```
REACT_APP_WEB_PUSH_PUBKEY=web push public key,
REACT_APP_NOAUTHD_URL=address of the noauthd server (see below)
REACT_APP_DOMAIN=domain name of your bunker (i.e. nsec.app)
REACT_APP_RELAY=relay that you'll use, can use wss://relay.nsec.app - don't use public general-purpose relays, you'll hit rate limits very fast
```
Then do:
```
npm install;
npm run build;
```
The app is in the `build` folder.
To run the noauthd server (https://github.com/nostrband/noauthd),
edit .env in noauthd:
```
PUSH_PUBKEY=web push public key, same as above
PUSH_SECRET=web push private key that you generated above
ORIGIN=address of the server itself, like http://localhost:8000
DATABASE_URL="file:./prod.db"
BUNKER_NSEC=nsec of the bunker (needed for create_account methods)
BUNKER_RELAY="wss://relay.nsec.app" - same as above
BUNKER_DOMAIN="nsec.app" - same as above
BUNKER_ORIGIN=where noauth is hosted
```
Then init the database and launch:
```
npx prisma migrate deploy
node -r dotenv/config src/index.js dotenv_config_path=.env
```
TODO
----
- Show details of requested operations
- Publish a profile for new sign ups
- Sync processed reqs across devices
- Sync connected apps and perms across devices
- Sync app activity across devices
- Group apps by domain
- Encrypt local nsec in Safari
- Add WebAuthn to the mix
- Add LN address to new profiles
- Confirm relay/contact list pruning requests
- Transfer/change nip05 name
- Better notifs with activity summaries
- How to send auth_url to new device if all other devices are down?

View File

@ -5,8 +5,6 @@ import { useAppDispatch } from './store/hooks/redux'
import { setApps, setKeys, setPending, setPerms } from './store/reducers/content.slice'
import AppRoutes from './routes/AppRoutes'
import { fetchProfile, ndk } from './modules/nostr'
import { useModalSearchParams } from './hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from './types/modal'
import { ModalInitial } from './components/Modal/ModalInitial/ModalInitial'
import { ModalImportKeys } from './components/Modal/ModalImportKeys/ModalImportKeys'
import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp'
@ -14,7 +12,6 @@ import { ModalLogin } from './components/Modal/ModalLogin/ModalLogin'
function App() {
const [render, setRender] = useState(0)
const { handleOpen } = useModalSearchParams()
const dispatch = useAppDispatch()
const [isConnected, setIsConnected] = useState(false)

View File

@ -3,7 +3,7 @@ import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Autocomplete, CircularProgress, Stack, Typography } from '@mui/material'
import { Autocomplete, Stack, Typography } from '@mui/material'
import { StyledInput } from './styled'
import { FormEvent, useEffect, useState } from 'react'
import { isEmptyString } from '@/utils/helpers/helpers'
@ -13,6 +13,7 @@ import { selectApps } from '@/store'
import { dbi } from '@/modules/db'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { setApps } from '@/store/reducers/content.slice'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
export const ModalAppDetails = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
@ -118,7 +119,7 @@ export const ModalAppDetails = () => {
}
}
const isFormValid = !isEmptyString(url) && !isEmptyString(name) && !isEmptyString(icon)
const isFormValid = !isEmptyString(url) && !isEmptyString(name)
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
@ -165,7 +166,7 @@ export const ModalAppDetails = () => {
/>
<Button varianttype="secondary" type="submit" fullWidth disabled={!isFormValid || isLoading}>
Save changes {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
Save changes {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Modal>

View File

@ -57,7 +57,7 @@ export const ModalConfirmConnect = () => {
// App doesn't exist yet!
// const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
const isPendingReqIdExists = pendingReqId.trim().length && pending.some((p) => p.id === pendingReqId)
console.log("pending", {isModalOpened, isPendingReqIdExists, isNpubExists, /*isAppNpubExists,*/ pendingReqId, pending});
// console.log("pending", {isModalOpened, isPendingReqIdExists, isNpubExists, /*isAppNpubExists,*/ pendingReqId, pending});
if (!isPopup && isModalOpened && (!isNpubExists /*|| !isAppNpubExists*/ || (pendingReqId && !isPendingReqIdExists))) {
closeModalAfterRequest()
return null

View File

@ -61,10 +61,23 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
},
})
// FIXME: when opened directly to this modal using authUrl,
// we might not have pending requests visible yet bcs we haven't
// loaded them yet, which means this modal will be closed with
// the login below. It's fine if only one app has sent pending
// requests atm, bcs the modal would re-appear as soon as we load
// the requests. But if there are several pending reqs from other
// apps then popup might show a different one! Which is very
// contrary to what user expects. So:
// - if isPopup - dont close the modal with logic below
// - show some 'loading' indicator until we've got some requests
// for the specified appNpub
// FIXME is the same logic valid for Connect modal?
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
if (isModalOpened && (!isNpubExists || !isAppNpubExists)) {
// console.log("confirm event", { confirmEventReqs, isModalOpened, isNpubExists, isAppNpubExists });
if (isModalOpened && (!currentAppPendingReqs.length || !isNpubExists || !isAppNpubExists)) {
closeModalAfterRequest()
return null
}

View File

@ -5,7 +5,7 @@ import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { CircularProgress, Stack, Typography, useTheme } from '@mui/material'
import { Stack, Typography, useTheme } from '@mui/material'
import { StyledAppLogo } from './styled'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
@ -18,6 +18,7 @@ import { fetchNip05 } from '@/utils/helpers/helpers'
import { DOMAIN } from '@/utils/consts'
import { CheckmarkIcon } from '@/assets'
import { getPublicKey, nip19 } from 'nostr-tools'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
const FORM_DEFAULT_VALUES = {
username: '',
@ -69,12 +70,13 @@ export const ModalImportKeys = () => {
}
try {
const { type, data } = nip19.decode(debouncedNsec)
const ok = type === 'nsec';
const ok = type === 'nsec'
setIsBadNsec(!ok)
if (ok) {
const npub = nip19.npubEncode(
// @ts-ignore
getPublicKey(data))
getPublicKey(data)
)
setIsTakenByNsec(!!nameNpub && nameNpub === npub)
} else {
setIsTakenByNsec(false)
@ -84,7 +86,8 @@ export const ModalImportKeys = () => {
setIsTakenByNsec(false)
return
}
}, [debouncedNsec])
// eslint-disable-next-line
}, [debouncedNsec])
useEffect(() => {
checkNsecUsername()
@ -106,8 +109,8 @@ export const ModalImportKeys = () => {
if (isLoading) return undefined
try {
const { nsec, username } = values
if (!nsec || !username) throw new Error("Enter username and nsec")
if (nameNpub && !isTakenByNsec) throw new Error("Name taken")
if (!nsec || !username) throw new Error('Enter username and nsec')
if (nameNpub && !isTakenByNsec) throw new Error('Name taken')
setIsLoading(true)
const k: any = await swicCall('importKey', username, nsec)
notify('Key imported!', 'success')
@ -186,16 +189,14 @@ export const ModalImportKeys = () => {
helperTextProps={{
sx: {
'&.helper_text': {
color: isBadNsec
? theme.palette.error.main
: theme.palette.textSecondaryDecorate.main,
color: isBadNsec ? theme.palette.error.main : theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Button type="submit" disabled={isLoading}>
Import key {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
Import key {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Modal>

View File

@ -4,7 +4,7 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { swicCall } from '@/modules/swic'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { CircularProgress, Stack, Typography } from '@mui/material'
import { Stack, Typography } from '@mui/material'
import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button'
@ -16,6 +16,7 @@ import { DOMAIN } from '@/utils/consts'
import { fetchNip05 } from '@/utils/helpers/helpers'
import { usePassword } from '@/hooks/usePassword'
import { dbi } from '@/modules/db'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
const FORM_DEFAULT_VALUES = {
username: '',
@ -120,7 +121,7 @@ export const ModalLogin = () => {
error={!!errors.password}
/>
<Button type="submit" fullWidth disabled={isLoading}>
Add account {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
Add account {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Modal>

View File

@ -1,8 +1,7 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Button } from '@/shared/Button/Button'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Box, CircularProgress, Stack, Typography } from '@mui/material'
import { Box, Stack, Typography } from '@mui/material'
import { StyledButton, StyledSettingContainer, StyledSynchedText } from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { CheckmarkIcon } from '@/assets'
@ -17,6 +16,7 @@ import { usePassword } from '@/hooks/usePassword'
import { useAppSelector } from '@/store/hooks/redux'
import { selectKeys } from '@/store'
import { isValidPassphase, isWeakPassphase } from '@/modules/keys'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
type ModalSettingsProps = {
isSynced: boolean
@ -116,15 +116,7 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
{...inputProps}
onChange={handlePasswordChange}
value={enteredPassword}
// helperText={isPasswordInvalid ? 'Invalid password' : ''}
placeholder="Enter a password"
// helperTextProps={{
// sx: {
// '&.helper_text': {
// color: 'red',
// },
// },
// }}
disabled={!isChecked}
/>
{isPasswordInvalid ? (
@ -150,10 +142,9 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
</Typography>
)}
<StyledButton type="submit" fullWidth disabled={!isChecked}>
Sync {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
Sync {isLoading && <LoadingSpinner mode="secondary" />}
</StyledButton>
</StyledSettingContainer>
{/* <Button onClick={onClose}>Done</Button> */}
</Stack>
</Modal>
)

View File

@ -2,7 +2,7 @@ import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { CircularProgress, Stack, Typography, useTheme } from '@mui/material'
import { Stack, Typography, useTheme } from '@mui/material'
import React, { ChangeEvent, useEffect, useState } from 'react'
import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input'
@ -12,6 +12,7 @@ import { swicCall } from '@/modules/swic'
import { useNavigate } from 'react-router-dom'
import { DOMAIN } from '@/utils/consts'
import { fetchNip05 } from '@/utils/helpers/helpers'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
export const ModalSignUp = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
@ -110,7 +111,7 @@ export const ModalSignUp = () => {
}}
/>
<Button fullWidth type="submit" disabled={isLoading}>
Create account {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
Create account {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Modal>

View File

@ -23,7 +23,7 @@ export const Header = () => {
}
const isDarkMode = themeMode === 'dark'
const themeIcon = isDarkMode ? <DarkModeIcon htmlColor="#fff" /> : <LightModeIcon htmlColor="#000" />
const themeIcon = isDarkMode ? <LightModeIcon htmlColor="#fff" /> : <DarkModeIcon htmlColor="#000" />
const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))

View File

@ -743,9 +743,9 @@ export class NoauthBackend {
})
// OAuth flow
const confirmMethod = method === 'connect' ? 'confirm-connect' : 'confirm-event'
const isConnect = method === 'connect'
const confirmMethod = isConnect ? 'confirm-connect' : 'confirm-event'
const authUrl = `${self.swg.location.origin}/key/${npub}?${confirmMethod}=true&appNpub=${appNpub}&reqId=${id}&popup=true`
// const authUrl = `${self.swg.location.origin}/key/${npub}?popup=true`
console.log('sending authUrl', authUrl, 'for', req)
// NOTE: if you set 'Update on reload' in the Chrome SW console
// then this message will cause a new tab opened by the peer,

View File

@ -9,6 +9,7 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { useState } from 'react'
import { getReferrerAppUrl } from '@/utils/helpers/helpers'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
const CreatePage = () => {
const notify = useEnqueueSnackbar()
@ -17,6 +18,8 @@ const CreatePage = () => {
const [searchParams] = useSearchParams()
const [isLoading, setIsLoading] = useState(false)
const name = searchParams.get('name') || ''
const token = searchParams.get('token') || ''
const appNpub = searchParams.get('appNpub') || ''
@ -31,12 +34,14 @@ const CreatePage = () => {
const handleClickAddAccount = async () => {
try {
setIsLoading(true)
const key: any = await swicCall('generateKey', name)
const appUrl = getReferrerAppUrl();
const appUrl = getReferrerAppUrl()
console.log('Created', key.npub, 'app', appUrl)
setCreated(true)
setIsLoading(false)
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
search: {
@ -53,6 +58,7 @@ const CreatePage = () => {
})
} catch (error: any) {
notify(error.message || error.toString(), 'error')
setIsLoading(false)
}
}
@ -88,7 +94,9 @@ const CreatePage = () => {
<Typography textAlign={'left'} variant="h6" paddingTop="0.5em">
Chosen name: <b>{nip05}</b>
</Typography>
<GetStartedButton onClick={handleClickAddAccount}>Create account</GetStartedButton>
<GetStartedButton onClick={handleClickAddAccount}>
Create account {isLoading && <LoadingSpinner />}
</GetStartedButton>
<Typography textAlign={'left'} variant="h5" paddingTop="1em">
What you need to know:

View File

@ -3,6 +3,7 @@ import { DbPending, DbPerm } from '@/modules/db'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { ACTION_TYPE } from '@/utils/consts'
import { useCallback, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
export type IPendingsByAppNpub = {
[appNpub: string]: {
@ -18,6 +19,9 @@ type IShownConfirmModals = {
export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms: DbPerm[]) => {
const { handleOpen, getModalOpened } = useModalSearchParams()
const [searchParams] = useSearchParams()
const isPopup = searchParams.get('popup') === 'true'
const isConfirmConnectModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
const isConfirmEventModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
@ -66,11 +70,19 @@ export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms
search: {
appNpub: req.appNpub,
reqId: req.id,
popup: isPopup ? 'true' : '',
},
})
break
}
}, [connectPendings, filteredPendingReqs.length, handleOpen, isConfirmEventModalOpened, isConfirmConnectModalOpened])
}, [
connectPendings,
filteredPendingReqs.length,
handleOpen,
isConfirmEventModalOpened,
isConfirmConnectModalOpened,
isPopup,
])
const handleOpenConfirmEventModal = useCallback(() => {
if (!filteredPendingReqs.length || connectPendings.length) return undefined
@ -86,11 +98,12 @@ export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
search: {
appNpub,
popup: isPopup ? 'true' : '',
},
})
break
}
}, [connectPendings.length, filteredPendingReqs.length, handleOpen, prepareEventPendings])
}, [connectPendings.length, filteredPendingReqs.length, handleOpen, prepareEventPendings, isPopup])
useEffect(() => {
handleOpenConfirmEventModal()

View File

@ -48,6 +48,7 @@ export const StyledEmptyAppsBox = styled(Box)(({ theme }) => {
placeItems: 'center',
color: theme.palette.text.primary,
opacity: '0.6',
maxHeight: '100%',
},
}
})

View File

@ -28,20 +28,20 @@ const StyledButton = styled(
},
color: theme.palette.text.primary,
'&.disabled': {
opacity: 0.5,
background: `${theme.palette.backgroundSecondary.default}50`,
cursor: 'not-allowed',
},
}
}
return {
...commonStyles,
'&.button:is(:hover, :active, &, .disabled)': {
'&.button:is(:hover, :active, &)': {
background: theme.palette.primary.main,
},
color: theme.palette.text.secondary,
'&.disabled': {
color: theme.palette.text.secondary,
opacity: 0.5,
background: `${theme.palette.primary.main}50`,
cursor: 'not-allowed',
},
}

View File

@ -0,0 +1,17 @@
import { CircularProgress, CircularProgressProps, styled } from '@mui/material'
import { FC } from 'react'
type LoadingSpinnerProps = CircularProgressProps & {
mode?: 'default' | 'secondary'
}
export const LoadingSpinner: FC<LoadingSpinnerProps> = (props) => {
return <StyledCircularProgress {...props} />
}
export const StyledCircularProgress = styled((props: LoadingSpinnerProps) => (
<CircularProgress size={'1rem'} {...props} />
))(({ theme, mode = 'default' }) => ({
marginLeft: '0.5rem',
color: mode === 'default' ? theme.palette.text.secondary : theme.palette.text.primary,
}))

View File

@ -110,8 +110,7 @@ export const getReferrerAppUrl = () => {
if (!window.document.referrer) return ''
try {
const u = new URL(window.document.referrer.toLocaleLowerCase())
if (u.hostname != DOMAIN && !u.hostname.endsWith("."+DOMAIN))
return u.origin
if (u.hostname !== DOMAIN && !u.hostname.endsWith('.' + DOMAIN)) return u.origin
} catch {}
return ''
}