feat: create signing request and send a DM to first signer with zip file url and encryption key
All checks were successful
Release / build_and_release (push) Successful in 1m2s

This commit is contained in:
SwiftHawk 2024-04-08 17:45:51 +05:00
parent d4b095c5ca
commit bd1e8417c1
21 changed files with 1331 additions and 59 deletions

146
package-lock.json generated
View File

@ -16,16 +16,22 @@
"@nostr-dev-kit/ndk": "2.5.0",
"@reduxjs/toolkit": "2.2.1",
"axios": "1.6.7",
"crypto-hash": "3.0.0",
"file-saver": "2.0.5",
"jszip": "3.10.1",
"lodash": "4.17.21",
"mui-file-input": "4.0.4",
"nostr-tools": "2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "9.1.0",
"react-router-dom": "6.22.1",
"react-toastify": "10.0.4",
"redux": "5.0.1"
"redux": "5.0.1",
"tseep": "1.2.1"
},
"devDependencies": {
"@types/file-saver": "2.0.7",
"@types/lodash": "4.14.202",
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
@ -1938,6 +1944,12 @@
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true
},
"node_modules/@types/file-saver": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
"dev": true
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -2567,6 +2579,11 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@ -2602,6 +2619,17 @@
"node": ">= 8"
}
},
"node_modules/crypto-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/crypto-hash/-/crypto-hash-3.0.0.tgz",
"integrity": "sha512-5l5xGtzuvGTU28GXxGV1JYVFou68buZWpkV1Fx5hIDRPnfbQ8KzabTlNIuDIeSCYGVPFehupzDqlnbXm2IXmdQ==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@ -3211,6 +3239,11 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@ -3474,6 +3507,11 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"node_modules/immer": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.0.3.tgz",
@ -3526,8 +3564,7 @@
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/is-arrayish": {
"version": "0.2.1",
@ -3601,6 +3638,11 @@
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -3671,6 +3713,17 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -3693,6 +3746,14 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/light-bolt11-decoder": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.0.0.tgz",
@ -3819,6 +3880,27 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/mui-file-input": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/mui-file-input/-/mui-file-input-4.0.4.tgz",
"integrity": "sha512-WYzPqKg4lahGyuUIt7674vwbgW6WS1CO066ujuvCwcvr7mw7IDPPhCCJ/rD6i36OhVN2f+1hlTMX5dmsCyYxrQ==",
"dependencies": {
"pretty-bytes": "^6.1.1"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@mui/material": "^5.0.0",
"@types/react": "^18.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
@ -4002,6 +4084,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -4144,6 +4231,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-bytes": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
"integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
"engines": {
"node": "^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -4313,6 +4416,20 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -4452,6 +4569,11 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/sass": {
"version": "1.71.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.71.1.tgz",
@ -4510,6 +4632,11 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -4557,6 +4684,14 @@
"node": ">=0.10.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@ -4839,6 +4974,11 @@
"node": ">=8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",

View File

@ -18,16 +18,22 @@
"@nostr-dev-kit/ndk": "2.5.0",
"@reduxjs/toolkit": "2.2.1",
"axios": "1.6.7",
"crypto-hash": "3.0.0",
"file-saver": "2.0.5",
"jszip": "3.10.1",
"lodash": "4.17.21",
"mui-file-input": "4.0.4",
"nostr-tools": "2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "9.1.0",
"react-router-dom": "6.22.1",
"react-toastify": "10.0.4",
"redux": "5.0.1"
"redux": "5.0.1",
"tseep": "1.2.1"
},
"devDependencies": {
"@types/file-saver": "2.0.7",
"@types/lodash": "4.14.202",
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",

View File

@ -1,10 +1,14 @@
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { Route, Routes } from 'react-router-dom'
import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthController, NostrController } from './controllers'
import { MainLayout } from './layouts/Main'
import { LandingPage } from './pages/landing/LandingPage'
import { privateRoutes, publicRoutes } from './routes'
import {
appPrivateRoutes,
appPublicRoutes,
privateRoutes,
publicRoutes
} from './routes'
import { State } from './store/rootReducer'
import { getNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey } from './utils'
@ -32,7 +36,6 @@ const App = () => {
return (
<Routes>
<Route element={<MainLayout />}>
{authState?.loggedIn && <Route path='/' element={<LandingPage />} />}
{authState?.loggedIn &&
privateRoutes.map((route, index) => (
<Route
@ -62,8 +65,19 @@ const App = () => {
)
}
})}
{!authState ||
(!authState.loggedIn && <Route path='*' element={<LandingPage />} />)}
<Route
path='*'
element={
<Navigate
to={
authState.loggedIn
? appPrivateRoutes.homePage
: appPublicRoutes.login
}
/>
}
/>
</Route>
</Routes>
)

View File

@ -1,12 +1,17 @@
import { Menu as MenuIcon } from '@mui/icons-material'
import {
AppBar as AppBarMui,
Box,
Button,
IconButton,
Menu,
MenuItem,
Tab,
Tabs,
Toolbar,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { setAuthState } from '../../store/actions'
@ -14,11 +19,15 @@ import { State } from '../../store/rootReducer'
import { Dispatch } from '../../store/store'
import Username from '../username'
import { Link, useNavigate } from 'react-router-dom'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import nostrichAvatar from '../../assets/images/avatar.png'
import nostrichLogo from '../../assets/images/nostr-logo.jpg'
import { NostrController } from '../../controllers'
import { appPublicRoutes, getProfileRoute } from '../../routes'
import {
appPrivateRoutes,
appPublicRoutes,
getProfileRoute
} from '../../routes'
import {
clearAuthToken,
saveNsecBunkerDelegatedKey,
@ -26,13 +35,20 @@ import {
} from '../../utils'
import styles from './style.module.scss'
const validTabs = [appPrivateRoutes.homePage, appPrivateRoutes.decryptZip]
export const AppBar = () => {
const navigate = useNavigate()
const { pathname } = useLocation()
const [tabValue, setTabValue] = useState(
validTabs.includes(pathname) ? pathname : '/'
)
const dispatch: Dispatch = useDispatch()
const [username, setUsername] = useState('')
const [userAvatar, setUserAvatar] = useState(nostrichAvatar)
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
const [anchorElNav, setAnchorElNav] = useState<null | HTMLElement>(null)
const authState = useSelector((state: State) => state.auth)
const metadataState = useSelector((state: State) => state.metadata)
@ -51,10 +67,18 @@ export const AppBar = () => {
setAnchorElUser(event.currentTarget)
}
const handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElNav(event.currentTarget)
}
const handleCloseUserMenu = () => {
setAnchorElUser(null)
}
const handleCloseNavMenu = () => {
setAnchorElNav(null)
}
const handleProfile = () => {
const hexKey = authState?.usersPubkey
if (hexKey) navigate(getProfileRoute(hexKey))
@ -63,6 +87,7 @@ export const AppBar = () => {
}
const handleLogout = () => {
handleCloseUserMenu()
dispatch(
setAuthState({
loggedIn: false,
@ -87,10 +112,100 @@ export const AppBar = () => {
return (
<AppBarMui position='fixed' className={styles.AppBar}>
<Toolbar className={styles.toolbar}>
<Box sx={{ display: { xs: 'none', md: 'flex' } }}>
<Box className={styles.logoWrapper}>
<img src={nostrichLogo} alt='Logo' onClick={() => navigate('/')} />
</Box>
{isAuthenticated && (
<Tabs
indicatorColor='secondary'
value={tabValue}
onChange={(_, value) => setTabValue(value)}
>
<Tab
label='Home'
value={appPrivateRoutes.homePage}
to={appPrivateRoutes.homePage}
component={Link}
/>
<Tab
label='Decrypt Zip'
value={appPrivateRoutes.decryptZip}
to={appPrivateRoutes.decryptZip}
component={Link}
/>
</Tabs>
)}
</Box>
<Box sx={{ flexGrow: 1, display: { xs: 'flex', md: 'none' } }}>
{!isAuthenticated && (
<Box className={styles.logoWrapper}>
<img
src={nostrichLogo}
alt='Logo'
onClick={() => navigate('/')}
/>
</Box>
)}
{isAuthenticated && (
<>
<IconButton
size='large'
onClick={handleOpenNavMenu}
color='primary'
>
<MenuIcon />
</IconButton>
<Menu
id='menu-appbar'
anchorEl={anchorElNav}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left'
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'left'
}}
open={!!anchorElNav}
onClose={handleCloseNavMenu}
sx={{
display: { xs: 'block', md: 'none' }
}}
>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
component={Link}
to={appPrivateRoutes.homePage}
onClick={handleCloseNavMenu}
variant='contained'
color='primary'
>
Home
</Button>
</MenuItem>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
component={Link}
to={appPrivateRoutes.decryptZip}
onClick={handleCloseNavMenu}
variant='contained'
color='primary'
>
Decrypt Zip
</Button>
</MenuItem>
</Menu>
</>
)}
</Box>
<Box className={styles.rightSideBox}>
{!isAuthenticated && (
<Button

View File

@ -1,7 +1,9 @@
import { EventEmitter } from 'tseep'
import NDK, {
NDKEvent,
NDKNip46Signer,
NDKPrivateKeySigner,
NDKUser,
NostrEvent
} from '@nostr-dev-kit/ndk'
import {
@ -10,6 +12,7 @@ import {
Relay,
UnsignedEvent,
finalizeEvent,
nip04,
nip19
} from 'nostr-tools'
import { updateNsecbunkerPubkey } from '../store/actions'
@ -18,13 +21,23 @@ import store from '../store/store'
import { SignedEvent } from '../types'
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
export class NostrController {
export class NostrController extends EventEmitter {
private static instance: NostrController
private bunkerNDK: NDK | undefined
private remoteSigner: NDKNip46Signer | undefined
private constructor() {}
private constructor() {
super()
}
private getNostrObject = () => {
if (window.nostr) return window.nostr
throw new Error(
`window.nostr object not present. Make sure you have an nostr extension installed/working properly.`
)
}
public nsecBunkerInit = async (relays: string[]) => {
// Don't reinstantiate bunker NDK if exists with same relays
@ -248,15 +261,9 @@ export class NostrController {
return Promise.resolve(signedEvent)
} else if (loginMethod === LoginMethods.extension) {
if (!window.nostr) {
return Promise.reject(
`Login method is ${loginMethod} but window.nostr is not present. Make sure extension is working properly`
)
}
const nostr = this.getNostrObject()
return (await window.nostr
.signEvent(event as NostrEvent)
.catch((err: any) => {
return (await nostr.signEvent(event as NostrEvent).catch((err: any) => {
console.log('Error while signing event: ', err)
throw err
@ -268,13 +275,58 @@ export class NostrController {
}
}
nip04Encrypt = async (receiver: string, content: string) => {
const loginMethod = (store.getState().auth as AuthState).loginMethod
if (loginMethod === LoginMethods.extension) {
const nostr = this.getNostrObject()
if (!nostr.nip04) {
throw new Error(
`Your nostr extension does not support nip04 encryption & decryption`
)
}
const encrypted = await nostr.nip04.encrypt(receiver, content)
return encrypted
}
if (loginMethod === LoginMethods.privateKey) {
const keyPair = (store.getState().auth as AuthState).keyPair
if (!keyPair) {
throw new Error(
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
)
}
const encrypted = await nip04.encrypt(keyPair.private, receiver, content)
return encrypted
}
if (loginMethod === LoginMethods.nsecBunker) {
const user = new NDKUser({ pubkey: receiver })
this.remoteSigner?.on('authUrl', (authUrl) => {
this.emit('nsecbunker-auth', authUrl)
})
if (!this.remoteSigner) throw new Error('Remote signer is undefined.')
const encrypted = await this.remoteSigner.encrypt(user, content)
return encrypted
}
throw new Error('Login method is undefined')
}
/**
* Function will capture the public key from the nostr extension or if no extension present
* function wil capture the public key from the local storage
*/
capturePublicKey = async (): Promise<string> => {
if (window.nostr) {
const pubKey = await window.nostr.getPublicKey().catch((err: any) => {
const nostr = this.getNostrObject()
const pubKey = await nostr.getPublicKey().catch((err: any) => {
return Promise.reject(err.message)
})
@ -285,11 +337,6 @@ export class NostrController {
return Promise.resolve(pubKey)
}
return Promise.reject(
'window.nostr object not present. Make sure you have an nostr extension installed.'
)
}
/**
* Generates NDK Private Signer
* @returns nSecBunker delegated key

View File

@ -1,20 +1,66 @@
import { Box } from '@mui/material'
import Container from '@mui/material/Container'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { Outlet } from 'react-router-dom'
import { AppBar } from '../components/AppBar/AppBar'
import { restoreState } from '../store/actions'
import { loadState } from '../utils'
import { restoreState, setAuthState } from '../store/actions'
import { clearAuthToken, loadState, saveNsecBunkerDelegatedKey } from '../utils'
import { LoadingSpinner } from '../components/LoadingSpinner'
import { Dispatch } from '../store/store'
import { NostrController } from '../controllers'
import { LoginMethods } from '../store/auth/types'
export const MainLayout = () => {
const dispatch = useDispatch()
const dispatch: Dispatch = useDispatch()
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const logout = () => {
dispatch(
setAuthState({
loggedIn: false,
usersPubkey: undefined,
loginMethod: undefined,
nsecBunkerPubkey: undefined
})
)
// clear authToken saved in local storage
clearAuthToken()
// update nsecBunker delegated key
const newDelegatedKey =
NostrController.getInstance().generateDelegatedKey()
saveNsecBunkerDelegatedKey(newDelegatedKey)
}
const restoredState = loadState()
if (restoredState) dispatch(restoreState(restoredState))
if (restoredState) {
dispatch(restoreState(restoredState))
const { loggedIn, loginMethod, usersPubkey, nsecBunkerRelays } =
restoredState.auth
if (loggedIn) {
if (!loginMethod || !usersPubkey) return logout()
if (loginMethod === LoginMethods.nsecBunker) {
if (!nsecBunkerRelays) return logout()
const nostrController = NostrController.getInstance()
nostrController.nsecBunkerInit(nsecBunkerRelays).then(() => {
nostrController.createNsecBunkerSigner(usersPubkey)
})
}
}
}
setIsLoading(false)
}, [dispatch])
if (isLoading) return <LoadingSpinner desc='Loading App' />
return (
<>
<AppBar />
@ -22,7 +68,10 @@ export const MainLayout = () => {
<Box className='main'>
<Container
sx={{
position: 'relative'
position: 'relative',
maxWidth: {
xs: '550px'
}
}}
>
<Outlet />

View File

@ -0,0 +1,93 @@
import { Box, Button, TextField, Typography } from '@mui/material'
import saveAs from 'file-saver'
import { MuiFileInput } from 'mui-file-input'
import { useState } from 'react'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { decryptArrayBuffer } from '../../utils'
import styles from './style.module.scss'
export const DecryptZip = () => {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [encryptionKey, setEncryptionKey] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [isDraggingOver, setIsDraggingOver] = useState(false)
const handleDecrypt = async () => {
if (!selectedFile || !encryptionKey) return
setIsLoading(true)
setLoadingSpinnerDesc('Decrypting zip file')
const encryptedArrayBuffer = await selectedFile.arrayBuffer()
const arrayBuffer = await decryptArrayBuffer(
encryptedArrayBuffer,
encryptionKey
)
const blob = new Blob([arrayBuffer])
saveAs(blob, 'decrypted.zip')
setIsLoading(false)
}
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault()
setIsDraggingOver(false)
const file = event.dataTransfer.files[0]
if (file.type === 'application/zip') setSelectedFile(file)
}
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault()
setIsDraggingOver(true)
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Box className={styles.container}>
<Typography component='label' variant='h6'>
Select encrypted zip file
</Typography>
<Box
className={styles.inputBlock}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{isDraggingOver && (
<Box className={styles.fileDragOver}>
<Typography variant='body1'>Drop file here</Typography>
</Box>
)}
<MuiFileInput
placeholder='Drop file here, or click to select'
value={selectedFile}
onChange={(value) => setSelectedFile(value)}
InputProps={{
inputProps: {
accept: '.zip'
}
}}
/>
<TextField
label='Encryption Key'
variant='outlined'
value={encryptionKey}
onChange={(e) => setEncryptionKey(e.target.value)}
/>
</Box>
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleDecrypt} variant='contained'>
Decrypt
</Button>
</Box>
</Box>
</>
)
}

View File

@ -0,0 +1,27 @@
@import '../../colors.scss';
.container {
display: flex;
flex-direction: column;
color: $text-color;
.inputBlock {
position: relative;
display: flex;
flex-direction: column;
gap: 25px;
}
.fileDragOver {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.8);
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
}
}

589
src/pages/home/index.tsx Normal file
View File

@ -0,0 +1,589 @@
import {
Box,
Button,
FormControl,
IconButton,
InputLabel,
List,
ListItem,
ListSubheader,
MenuItem,
Select,
TextField,
Typography
} from '@mui/material'
import { MuiFileInput } from 'mui-file-input'
import styles from './style.module.scss'
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
import placeholderAvatar from '../../assets/images/nostr-logo.jpg'
import { ProfileMetadata } from '../../types'
import { MetadataController, NostrController } from '../../controllers'
import { Link } from 'react-router-dom'
import {
encryptArrayBuffer,
generateEncryptionKey,
getFileHash,
pubToHex,
queryNip05,
shorten
} from '../../utils'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { getProfileRoute } from '../../routes'
import { Clear } from '@mui/icons-material'
import JSZip from 'jszip'
import { toast } from 'react-toastify'
import { useSelector } from 'react-redux'
import { State } from '../../store/rootReducer'
import { EventTemplate } from 'nostr-tools'
import axios from 'axios'
enum SelectionType {
signer = 'Signer',
viewer = 'Viewer'
}
type MetadataMap = {
[key: string]: ProfileMetadata
}
export const HomePage = () => {
const [inputValue, setInputValue] = useState('')
const [type, setType] = useState<SelectionType>(SelectionType.signer)
const [error, setError] = useState<string>()
const [signers, setSigners] = useState<string[]>([])
const [viewers, setViewers] = useState<string[]>([])
const [metadataMap, setMetadataMap] = useState<MetadataMap>({})
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [authUrl, setAuthUrl] = useState<string>()
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance()
const handleAddClick = async () => {
setError(undefined)
const addPubkey = (pubkey: string) => {
const addElement = (prev: string[]) => {
// if key is already in the list just return that
if (prev.includes(pubkey)) return prev
return [...prev, pubkey]
}
if (type === SelectionType.signer) {
setSigners(addElement)
} else {
setViewers(addElement)
}
}
if (inputValue.startsWith('npub')) {
const pubkey = await pubToHex(inputValue)
if (pubkey) {
addPubkey(pubkey)
setInputValue('')
} else {
setError('Provided npub is not valid. Please enter correct npub.')
}
return
}
if (inputValue.includes('@')) {
setIsLoading(true)
setLoadingSpinnerDesc('Querying for nip05')
const nip05Profile = await queryNip05(inputValue)
.catch((err) => {
console.error(`error occurred in querying nip05: ${inputValue}`, err)
return null
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
if (nip05Profile) {
const pubkey = nip05Profile.pubkey
addPubkey(pubkey)
setInputValue('')
} else {
setError('Provided nip05 is not valid. Please enter correct nip05.')
}
return
}
setError('Invalid input! Make sure to provide correct npub or nip05.')
}
const handleRemove = (pubkey: string, selectionType: SelectionType) => {
if (selectionType === SelectionType.signer) {
setSigners((prev) => prev.filter((signer) => signer !== pubkey))
} else {
setViewers((prev) => prev.filter((viewer) => viewer !== pubkey))
}
}
const handleSelectFiles = (files: File[]) => {
setSelectedFiles((prev) => {
const prevFileNames = prev.map((file) => file.name)
const newFiles = files.filter(
(file) => !prevFileNames.includes(file.name)
)
return [...prev, ...newFiles]
})
}
const handleRemoveFile = (fileToRemove: File) => {
setSelectedFiles((prevFiles) =>
prevFiles.filter((file) => file.name !== fileToRemove.name)
)
}
const handleSubmit = async () => {
if (signers.length === 0) {
toast.error('No signer is provided. At least provide one signer.')
return
}
if (viewers.length === 0) {
toast.error('No viewer is provided. At least provide one viewer.')
return
}
if (selectedFiles.length === 0) {
toast.error('No file is provided. At least provide one file.')
return
}
setIsLoading(true)
setLoadingSpinnerDesc('Generating hashes for files')
const fileHashes: { [key: string]: string } = {}
for (const file of selectedFiles) {
const hash = await getFileHash(file)
fileHashes[file.name] = hash
}
const zip = new JSZip()
selectedFiles.forEach((file) => {
zip.file(`files/${file.name}`, file)
})
const event: EventTemplate = {
kind: 1,
tags: [['r', signers[0]]],
content: JSON.stringify(fileHashes),
created_at: Math.floor(Date.now() / 1000)
}
setLoadingSpinnerDesc('Signing nostr event')
const signedEvent = await nostrController.signEvent(event).catch((err) => {
console.error(err)
toast.error(err.message || 'Error occurred in signing nostr event')
setIsLoading(false)
return null
})
if (!signedEvent) return
const meta = {
signers,
viewers,
fileHashes,
submittedBy: usersPubkey,
signedEvents: {
[signedEvent.pubkey]: JSON.stringify(signedEvent, null, 2)
}
}
try {
const stringifiedMeta = JSON.stringify(meta, null, 2)
zip.file('meta.json', stringifiedMeta)
} catch (err) {
toast.error('An error occurred in converting meta json to string')
}
setLoadingSpinnerDesc('Generating zip file')
const arraybuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: {
level: 6
}
})
.catch((err) => {
console.log('err in zip:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in generating zip file')
return null
})
if (!arraybuffer) return
const encryptionKey = await generateEncryptionKey()
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptArrayBuffer(
arraybuffer,
encryptionKey
)
const blob = new Blob([encryptedArrayBuffer])
setLoadingSpinnerDesc('Uploading zip file to file storage.')
const fileUrl = await uploadToFileStorage(blob)
.then((url) => {
toast.success('zip file uploaded to file storage')
return url
})
.catch((err) => {
console.log('err in upload:>> ', err)
toast.error(err.message || 'Error occurred in uploading zip file')
return null
})
if (!fileUrl) return
await sendDMToFirstSigner(fileUrl, encryptionKey, signers[0])
setIsLoading(false)
}
const uploadToFileStorage = async (blob: Blob) => {
const unixNow = Math.floor(Date.now() / 1000)
const file = new File([blob], `zipped-${unixNow}.zip`, {
type: 'application/zip'
})
const event: EventTemplate = {
kind: 24242,
content: 'Authorize Upload',
created_at: Math.floor(Date.now() / 1000),
tags: [
['t', 'upload'],
['expiration', String(unixNow + 60 * 5)],
['name', file.name],
['size', String(file.size)]
]
}
setLoadingSpinnerDesc('Signing auth event for uploading zip')
const authEvent = await nostrController.signEvent(event)
const FILE_STORAGE_URL = 'https://blossom.sigit.io'
const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, {
headers: {
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)),
'Content-Type': 'application/zip'
}
})
return response.data.url as string
}
const sendDMToFirstSigner = async (
fileUrl: string,
encryptionKey: string,
pubkey: string
) => {
const content = `You have been requested for a signature.\nHere is the url for zip file that you can download.\n
${fileUrl}\nHowever this zip file is encrypted and you need to decrypt it using https://app.sigit.io\n Encryption key: ${encryptionKey}`
nostrController.on('nsecbunker-auth', (url) => {
setAuthUrl(url)
})
setLoadingSpinnerDesc('encrypting content for DM')
// todo: add timeout
const encrypted = await nostrController
.nip04Encrypt(pubkey, content)
.then((res) => {
return res
})
.catch((err) => {
console.log('err :>> ', err)
toast.error(
err.message || 'An error occurred while encrypting DM content'
)
return null
})
.finally(() => {
setAuthUrl(undefined)
})
if (!encrypted) return
const event: EventTemplate = {
kind: 4,
content: encrypted,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', signers[0]]]
}
setLoadingSpinnerDesc('signing event for DM')
const signedEvent = await nostrController.signEvent(event).catch((err) => {
console.log('err :>> ', err)
toast.error(err.message || 'An error occurred while signing event for DM')
return null
})
if (!signedEvent) return
// const metadata = metadataMap[pubkey]
setLoadingSpinnerDesc('Publishing encrypted DM')
// todo: do not use hardcoded relay
await nostrController
.publishEvent(signedEvent, 'wss://relayable.org')
.then(() => {
toast.success('DM sent to first signer')
})
.catch((err) => {
console.log('err :>> ', err)
toast.error(err.message || 'An error occurred while publishing DM')
return null
})
}
if (authUrl) {
return (
<iframe
title='Nsecbunker auth'
src={authUrl}
width='100%'
height='500px'
/>
)
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Box className={styles.container}>
<Typography component='label' variant='h6'>
Select signers and viewers
</Typography>
<Box className={styles.inputBlock}>
<TextField
label='nip05 / npub'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
helperText={error}
error={!!error}
/>
<FormControl fullWidth>
<InputLabel id='select-type-label'>Type</InputLabel>
<Select
labelId='select-type-label'
id='demo-simple-select'
value={type}
label='Type'
onChange={(e) => setType(e.target.value as SelectionType)}
>
<MenuItem value={SelectionType.signer}>
{SelectionType.signer}
</MenuItem>
<MenuItem value={SelectionType.viewer}>
{SelectionType.viewer}
</MenuItem>
</Select>
</FormControl>
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button
disabled={!inputValue}
onClick={handleAddClick}
variant='contained'
>
Add
</Button>
</Box>
</Box>
<Typography component='label' variant='h6'>
Select files
</Typography>
<MuiFileInput
multiple
placeholder='Choose Files'
value={selectedFiles}
onChange={(value) => handleSelectFiles(value)}
/>
<ul>
{selectedFiles.map((file, index) => (
<li key={index}>
<Typography component='label'>{file.name}</Typography>
<IconButton onClick={() => handleRemoveFile(file)}>
<Clear style={{ color: 'red' }} />{' '}
</IconButton>
</li>
))}
</ul>
{signers.length > 0 && (
<List
sx={{
bgcolor: 'background.paper',
marginTop: 2
}}
subheader={
<ListSubheader
sx={{
paddingBottom: 1,
paddingTop: 1,
fontSize: '1.5rem'
}}
className={styles.subHeader}
>
Signers
</ListSubheader>
}
>
{signers.map((signer, index) => (
<DisplaySignerOrViewer
key={`signer-${index}`}
pubkey={signer}
metadataMap={metadataMap}
setMetadataMap={setMetadataMap}
remove={() => handleRemove(signer, SelectionType.signer)}
/>
))}
</List>
)}
{viewers.length > 0 && (
<List
sx={{
bgcolor: 'background.paper',
marginTop: 2
}}
subheader={
<ListSubheader
sx={{
paddingBottom: 1,
paddingTop: 1,
fontSize: '1.5rem'
}}
className={styles.subHeader}
>
Viewers
</ListSubheader>
}
>
{viewers.map((viewer, index) => (
<DisplaySignerOrViewer
key={`viewer-${index}`}
pubkey={viewer}
metadataMap={metadataMap}
setMetadataMap={setMetadataMap}
remove={() => handleRemove(viewer, SelectionType.viewer)}
/>
))}
</List>
)}
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleSubmit} variant='contained'>
Submit
</Button>
</Box>
</Box>
</>
)
}
type DisplaySignerOrViewerProps = {
pubkey: string
metadataMap: MetadataMap
setMetadataMap: Dispatch<SetStateAction<MetadataMap>>
remove: () => void
}
const DisplaySignerOrViewer = ({
pubkey,
metadataMap,
setMetadataMap,
remove
}: DisplaySignerOrViewerProps) => {
const [metadata, setMetadata] = useState<ProfileMetadata>()
useEffect(() => {
const getMetadata = async (pubkey: string) => {
console.log('1 :>> ', 1)
const metadataController = new MetadataController()
const metadataEvent = await metadataController
.findMetadata(pubkey)
.catch((err) => {
console.error(
`error occurred in finding metadata for: ${pubkey}`,
err
)
return null
})
if (metadataEvent) {
const metadataContent =
metadataController.extractProfileMetadataContent(metadataEvent)
if (metadataContent) {
setMetadata(metadataContent)
setMetadataMap((prev) => ({
...prev,
[pubkey]: metadataContent
}))
}
}
}
const existingMetadata = metadataMap[pubkey]
console.log('metadataMap :>> ', metadataMap)
if (existingMetadata) {
setMetadata(existingMetadata)
} else {
getMetadata(pubkey)
}
}, [pubkey, metadataMap])
const imageLoadError = (event: any) => {
event.target.src = placeholderAvatar
}
return (
<ListItem sx={{ marginTop: 1 }} className={styles.listItem}>
<img
onError={imageLoadError}
src={metadata?.picture || placeholderAvatar}
alt='Profile Image'
className={styles.img}
/>
<Link to={getProfileRoute(pubkey)}>
<Typography component='label' className={styles.name}>
{metadata?.display_name || metadata?.name || shorten(pubkey)}
</Typography>
</Link>
<IconButton onClick={remove}>
<Clear style={{ color: 'red' }} />
</IconButton>
</ListItem>
)
}

View File

@ -0,0 +1,33 @@
@import '../../colors.scss';
.container {
display: flex;
flex-direction: column;
color: $text-color;
.inputBlock {
display: flex;
flex-direction: column;
gap: 25px;
margin-top: 10px;
}
}
.subHeader {
border-bottom: 0.5px solid;
}
.listItem {
display: flex;
align-items: center;
justify-content: space-between !important;
.img {
max-width: 100px;
}
.name {
text-align: center;
cursor: pointer;
}
}

View File

@ -13,7 +13,8 @@ import {
import {
updateKeyPair,
updateLoginMethod,
updateNsecbunkerPubkey
updateNsecbunkerPubkey,
updateNsecbunkerRelays
} from '../../store/actions'
import { LoginMethods } from '../../store/auth/types'
import { Dispatch } from '../../store/store'
@ -183,6 +184,7 @@ export const Login = () => {
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
dispatch(updateNsecbunkerPubkey(pubkey))
dispatch(updateNsecbunkerRelays(relays))
setLoadingSpinnerDesc('Authenticating and finding metadata')
@ -242,6 +244,7 @@ export const Login = () => {
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
dispatch(updateNsecbunkerPubkey(pubkey))
dispatch(updateNsecbunkerRelays([relay]))
setLoadingSpinnerDesc('Authenticating and finding metadata')

View File

@ -1,14 +1,18 @@
import { DecryptZip } from '../pages/decrypt'
import { HomePage } from '../pages/home'
import { LandingPage } from '../pages/landing/LandingPage'
import { Login } from '../pages/login'
import { ProfilePage } from '../pages/profile'
import { hexToNpub } from '../utils'
export const appPrivateRoutes = {
homePage: '/'
homePage: '/',
decryptZip: '/decrypt-zip'
}
export const appPublicRoutes = {
profile: '/profile/:npub',
landingPage: '/',
login: '/login',
help: 'https://help.sigit.io'
}
@ -17,6 +21,11 @@ export const getProfileRoute = (hexKey: string) =>
appPublicRoutes.profile.replace(':npub', hexToNpub(hexKey))
export const publicRoutes = [
{
path: appPublicRoutes.landingPage,
hiddenWhenLoggedIn: true,
element: <LandingPage />
},
{
path: appPublicRoutes.login,
hiddenWhenLoggedIn: true,
@ -31,6 +40,10 @@ export const publicRoutes = [
export const privateRoutes = [
{
path: appPrivateRoutes.homePage,
element: <LandingPage />
element: <HomePage />
},
{
path: appPrivateRoutes.decryptZip,
element: <DecryptZip />
}
]

View File

@ -4,5 +4,6 @@ export const SET_AUTH_STATE = 'SET_AUTH_STATE'
export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD'
export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR'
export const UPDATE_NSECBUNKER_PUBKEY = 'UPDATE_NSECBUNKER_PUBKEY'
export const UPDATE_NSECBUNKER_RELAYS = 'UPDATE_NSECBUNKER_RELAYS'
export const SET_METADATA_EVENT = 'SET_METADATA_EVENT'

View File

@ -6,7 +6,8 @@ import {
SetAuthState,
UpdateKeyPair,
UpdateLoginMethod,
UpdateNsecBunkerPubkey
UpdateNsecBunkerPubkey,
UpdateNsecBunkerRelays
} from './types'
export const setAuthState = (payload: AuthState): SetAuthState => ({
@ -32,3 +33,10 @@ export const updateNsecbunkerPubkey = (
type: ActionTypes.UPDATE_NSECBUNKER_PUBKEY,
payload
})
export const updateNsecbunkerRelays = (
payload: string[] | undefined
): UpdateNsecBunkerRelays => ({
type: ActionTypes.UPDATE_NSECBUNKER_RELAYS,
payload
})

View File

@ -11,12 +11,13 @@ const reducer = (
): AuthState | null => {
switch (action.type) {
case ActionTypes.SET_AUTH_STATE: {
const { loginMethod, keyPair, nsecBunkerPubkey } = state
const { loginMethod, keyPair, nsecBunkerPubkey, nsecBunkerRelays } = state
return {
loginMethod,
keyPair,
nsecBunkerPubkey,
nsecBunkerRelays,
...action.payload
}
}
@ -47,6 +48,18 @@ const reducer = (
}
}
case ActionTypes.UPDATE_NSECBUNKER_RELAYS: {
const { payload } = action
return {
...state,
nsecBunkerRelays: payload
}
}
case ActionTypes.RESTORE_STATE:
return action.payload.auth
default:
return state
}

View File

@ -1,4 +1,5 @@
import * as ActionTypes from '../actionTypes'
import { RestoreState } from '../actions'
export enum LoginMethods {
extension = 'extension',
@ -18,6 +19,7 @@ export interface AuthState {
loginMethod?: LoginMethods
keyPair?: Keys
nsecBunkerPubkey?: string
nsecBunkerRelays?: string[]
}
export interface SetAuthState {
@ -40,8 +42,15 @@ export interface UpdateNsecBunkerPubkey {
payload: string | undefined
}
export interface UpdateNsecBunkerRelays {
type: typeof ActionTypes.UPDATE_NSECBUNKER_RELAYS
payload: string[] | undefined
}
export type AuthDispatchTypes =
| RestoreState
| SetAuthState
| UpdateLoginMethod
| UpdateKeyPair
| UpdateNsecBunkerPubkey
| UpdateNsecBunkerRelays

71
src/utils/crypto.ts Normal file
View File

@ -0,0 +1,71 @@
import { hexToString, stringToHex } from '.'
const ENCRYPTION_ALGO_NAME = 'AES-GCM'
export const generateEncryptionKey = async () => {
const cryptoKey = await window.crypto.subtle.generateKey(
{ name: ENCRYPTION_ALGO_NAME, length: 128 },
true,
['encrypt', 'decrypt']
)
const key = await window.crypto.subtle.exportKey('jwk', cryptoKey)
const jsonString = JSON.stringify(key)
const hexKey = stringToHex(jsonString)
const iv = new TextDecoder().decode(
window.crypto.getRandomValues(new Uint8Array(16))
)
return `${hexKey}?iv=${iv}`
}
export const importKey = async (key: string) => {
const splittedKey = key.split('?iv=')
const keyString = hexToString(splittedKey[0])
const jsonWebKey = JSON.parse(keyString)
const iv = new TextEncoder().encode(splittedKey[1])
const cryptoKey = await window.crypto.subtle.importKey(
'jwk',
jsonWebKey,
{ name: ENCRYPTION_ALGO_NAME },
true,
['encrypt', 'decrypt']
)
return { cryptoKey, iv }
}
export const encryptArrayBuffer = async (
arrayBuffer: ArrayBuffer,
key: string
) => {
const { cryptoKey, iv } = await importKey(key)
// Encrypt the data
const encryptedData = await window.crypto.subtle.encrypt(
{ name: ENCRYPTION_ALGO_NAME, iv },
cryptoKey,
arrayBuffer
)
return encryptedData
}
export const decryptArrayBuffer = async (
encryptedData: ArrayBuffer,
key: string
) => {
const { cryptoKey, iv } = await importKey(key)
// Decrypt the data
const decryptedData = await window.crypto.subtle.decrypt(
{ name: ENCRYPTION_ALGO_NAME, iv },
cryptoKey,
encryptedData
)
return decryptedData
}

16
src/utils/file.ts Normal file
View File

@ -0,0 +1,16 @@
import { sha256 } from 'crypto-hash'
export const getFileHash = (file: File) => {
return new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = async () => {
if (reader.result) {
const hash = await sha256(reader.result)
resolve(hash)
}
}
reader.readAsBinaryString(file)
})
}

View File

@ -1,3 +1,5 @@
export * from './crypto'
export * from './file'
export * from './localStorage'
export * from './nostr'
export * from './string'

View File

@ -1,4 +1,4 @@
import { nip05, nip19, verifyEvent } from 'nostr-tools'
import { nip19, verifyEvent } from 'nostr-tools'
import { SignedEvent } from '../types'
import axios from 'axios'
@ -12,19 +12,12 @@ const validateHex = (hexKey: string) => {
/**
* NPUB provided - it will convert NPUB to HEX
* NIP-05 provided - it will query NIP-05 profile and return HEX key if found
* HEX provided - it will return HEX
*
* @param pubKey in NPUB, NIP-05 or HEX format
* @param pubKey in NPUB, HEX format
* @returns HEX format
*/
export const pubToHex = async (pubKey: string): Promise<string | null> => {
// If key is NIP-05
if (pubKey.indexOf('@') !== -1)
return Promise.resolve(
(await nip05.queryProfile(pubKey).then((res) => res?.pubkey)) || null
)
// If key is NPUB
if (pubKey.startsWith('npub')) {
try {

View File

@ -7,3 +7,33 @@ export const shorten = (str: string, offset = 9) => {
str.length
)}`
}
export const stringToHex = (str: string) => {
// Convert the string to an array of UTF-16 code units using the spread operator
const codeUnits = [...str]
// Map each code unit to its hexadecimal representation
const hexChars = codeUnits.map((codeUnit) => {
// Convert the code unit to its hexadecimal representation with leading zeros
const hex = codeUnit.charCodeAt(0).toString(16).padStart(2, '0')
return hex
})
// Join the hexadecimal characters into a single string
const hexString = hexChars.join('')
// Return the resulting hexadecimal string
return hexString
}
export const hexToString = (hex: string) => {
// Split the hex string into pairs of two characters
const pairs = hex.match(/.{1,2}/g) || []
// Convert each pair from hexadecimal to its decimal equivalent,
// then convert each decimal value to its character representation
const chars = pairs.map((pair) => String.fromCharCode(parseInt(pair, 16)))
// Join the resulting characters into a single string
return chars.join('')
}