feat: implemented the UI and logic for signing document
All checks were successful
Release / build_and_release (push) Successful in 55s

This commit is contained in:
SwiftHawk 2024-04-18 16:12:11 +05:00
parent c4ef090f3c
commit a32abaf9e7
13 changed files with 636 additions and 161 deletions

View File

@ -135,6 +135,12 @@ export const AppBar = () => {
to={appPrivateRoutes.decryptZip}
component={Link}
/>
<Tab
label='Sign Document'
value={appPrivateRoutes.sign}
to={appPrivateRoutes.sign}
component={Link}
/>
</Tabs>
)}
</Box>
@ -201,6 +207,18 @@ export const AppBar = () => {
Decrypt Zip
</Button>
</MenuItem>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
component={Link}
to={appPrivateRoutes.sign}
onClick={handleCloseNavMenu}
variant='contained'
color='primary'
>
Sign Document
</Button>
</MenuItem>
</Menu>
</>
)}

View File

@ -13,10 +13,8 @@ import {
TextField,
Typography
} from '@mui/material'
import axios from 'axios'
import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input'
import { EventTemplate } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
@ -30,10 +28,13 @@ import { ProfileMetadata } from '../../types'
import {
encryptArrayBuffer,
generateEncryptionKey,
getFileHash,
getHash,
pubToHex,
queryNip05,
shorten
sendDM,
shorten,
signEventForMetaFile,
uploadToFileStorage
} from '../../utils'
import styles from './style.module.scss'
@ -162,7 +163,25 @@ export const HomePage = () => {
const fileHashes: { [key: string]: string } = {}
for (const file of selectedFiles) {
const hash = await getFileHash(file)
const arraybuffer = await file.arrayBuffer().catch((err) => {
console.log(
`err while getting arrayBuffer of file ${file.name} :>> `,
err
)
toast.error(
err.message || `err while getting arrayBuffer of file ${file.name}`
)
return null
})
if (!arraybuffer) return
const hash = await getHash(arraybuffer)
if (!hash) {
setIsLoading(false)
return
}
fileHashes[file.name] = hash
}
@ -173,20 +192,13 @@ export const HomePage = () => {
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
})
const signedEvent = await signEventForMetaFile(
signers[0],
fileHashes,
nostrController,
setIsLoading
)
if (!signedEvent) return
@ -238,7 +250,7 @@ export const HomePage = () => {
const blob = new Blob([encryptedArrayBuffer])
setLoadingSpinnerDesc('Uploading zip file to file storage.')
const fileUrl = await uploadToFileStorage(blob)
const fileUrl = await uploadToFileStorage(blob, nostrController)
.then((url) => {
toast.success('zip file uploaded to file storage')
return url
@ -251,134 +263,19 @@ export const HomePage = () => {
if (!fileUrl) return
await sendDMToFirstSigner(fileUrl, encryptionKey, signers[0])
setLoadingSpinnerDesc('Sending DM to first signer')
await sendDM(
fileUrl,
encryptionKey,
signers[0],
nostrController,
true,
setAuthUrl
)
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)
// todo: use env variable
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')
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error('Timeout occurred'))
}, 15000) // timeout duration = 15 sec
})
const encrypted = await Promise.race([
nostrController.nip04Encrypt(pubkey, content),
timeoutPromise
])
.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
setLoadingSpinnerDesc('Publishing encrypted DM')
const metadataController = new MetadataController()
const relaySet = await metadataController
.findRelayListMetadata(pubkey)
.catch((err) => {
toast.error(
err.message || 'An error occurred while finding relay list metadata'
)
return null
})
if (!relaySet) return
// NOTE: according to Nip65
// DMs SHOULD only be broadcasted to the author's WRITE relays and to the receiver's READ relays to keep maximum privacy.
if (relaySet.read.length === 0) {
toast.error('No relay found for publishing encrypted DM')
}
await nostrController
.publishEvent(signedEvent, relaySet.read)
.then((relays) => {
toast.success(`Encrypted DM sent on: ${relays.join('\n')}`)
})
.catch((err) => {
console.log('err :>> ', err)
toast.error(err.message || 'An error occurred while publishing DM')
return null
})
}
if (authUrl) {
return (
<iframe

261
src/pages/sign/index.tsx Normal file
View File

@ -0,0 +1,261 @@
import { Box, Button, Typography } from '@mui/material'
import { MuiFileInput } from 'mui-file-input'
import { useState } from 'react'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import styles from './style.module.scss'
import JSZip from 'jszip'
import { toast } from 'react-toastify'
import {
encryptArrayBuffer,
generateEncryptionKey,
getHash,
parseJson,
readContentOfZipEntry,
sendDM,
signEventForMetaFile,
uploadToFileStorage
} from '../../utils'
import { useSelector } from 'react-redux'
import { State } from '../../store/rootReducer'
import { NostrController } from '../../controllers'
export const SignDocument = () => {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [isDraggingOver, setIsDraggingOver] = useState(false)
const [authUrl, setAuthUrl] = useState<string>()
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance()
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)
}
const handleSign = async () => {
if (!selectedFile) return
setIsLoading(true)
setLoadingSpinnerDesc('Parsing zip file')
const zip = await JSZip.loadAsync(selectedFile).catch((err) => {
console.log('err in loading zip file :>> ', err)
toast.error(err.message || 'An error occurred in loading zip file.')
return null
})
if (!zip) return
setLoadingSpinnerDesc('Parsing meta.json')
const metaFileContent = await readContentOfZipEntry(
zip,
'meta.json',
'string'
)
if (!metaFileContent) {
setIsLoading(false)
return
}
const meta = await parseJson(metaFileContent).catch((err) => {
console.log('err in parsing the content of meta.json :>> ', err)
toast.error(
err.message || 'error occurred in parsing the content of meta.json'
)
setIsLoading(false)
return null
})
if (!meta) return
setLoadingSpinnerDesc('Generating hashes for files')
const fileHashes: { [key: string]: string } = {}
const fileNames = Object.keys(meta.fileHashes)
// create generate hashes for all entries in files folder of zipArchive
// these hashes can be used to verify the originality of files
for (const fileName of fileNames) {
const filePath = `files/${fileName}`
const arrayBuffer = await readContentOfZipEntry(
zip,
filePath,
'arraybuffer'
)
if (!arrayBuffer) {
setIsLoading(false)
return
}
const hash = await getHash(arrayBuffer)
if (!hash) {
setIsLoading(false)
return
}
fileHashes[fileName] = hash
}
// check if the current user is the last signer
const lastSignerIndex = meta.signers.length - 1
const signerIndex = meta.signers.indexOf(usersPubkey)
const isLastSigner = signerIndex === lastSignerIndex
// if current user is the last signer, then send DMs to all viewers
if (isLastSigner) {
for (const viewer of meta.viewers) {
await signAndSendToNext(zip, meta, viewer, fileHashes, false)
}
} else {
const nextSigner = meta.signers[signerIndex + 1]
await signAndSendToNext(zip, meta, nextSigner, fileHashes, true)
}
setIsLoading(false)
}
const signAndSendToNext = async (
zip: JSZip,
meta: any,
receiver: string,
fileHashes: {
[key: string]: string
},
isNextSigner: boolean
) => {
setLoadingSpinnerDesc('Signing nostr event for meta file')
const signedEvent = await signEventForMetaFile(
receiver,
fileHashes,
nostrController,
setIsLoading
)
if (!signedEvent) return
meta.signedEvents = {
...meta.signedEvents,
[signedEvent.pubkey]: JSON.stringify(signedEvent, null, 2)
}
const stringifiedMeta = JSON.stringify(meta, null, 2)
zip.file('meta.json', stringifiedMeta)
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, nostrController)
.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
setLoadingSpinnerDesc(
`Sending DM to next ${isNextSigner ? 'signer' : 'viewer'}`
)
await sendDM(
fileUrl,
encryptionKey,
receiver,
nostrController,
isNextSigner,
setAuthUrl
)
}
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 decrypted 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'
}
}}
/>
</Box>
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleSign} variant='contained'>
Sign
</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;
}
}

View File

@ -4,10 +4,12 @@ import { LandingPage } from '../pages/landing/LandingPage'
import { Login } from '../pages/login'
import { ProfilePage } from '../pages/profile'
import { hexToNpub } from '../utils'
import { SignDocument } from '../pages/sign'
export const appPrivateRoutes = {
homePage: '/',
decryptZip: '/decrypt-zip'
decryptZip: '/decrypt-zip',
sign: '/sign'
}
export const appPublicRoutes = {
@ -45,5 +47,9 @@ export const privateRoutes = [
{
path: appPrivateRoutes.decryptZip,
element: <DecryptZip />
},
{
path: appPrivateRoutes.sign,
element: <SignDocument />
}
]

View File

@ -1,2 +1,3 @@
export * from './nostr'
export * from './profile'
export * from './zip'

13
src/types/zip.ts Normal file
View File

@ -0,0 +1,13 @@
export interface OutputByType {
base64: string
string: string
text: string
binarystring: string
array: number[]
uint8array: Uint8Array
arraybuffer: ArrayBuffer
blob: Blob
nodebuffer: Buffer
}
export type OutputType = keyof OutputByType

View File

@ -1,16 +0,0 @@
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)
})
}

19
src/utils/hash.ts Normal file
View File

@ -0,0 +1,19 @@
import { sha256 } from 'crypto-hash'
import { toast } from 'react-toastify'
/**
* Computes the SHA-256 hash of an ArrayBuffer.
* @param arrayBuffer The ArrayBuffer to hash.
* @returns A Promise resolving to the SHA-256 hash as a hexadecimal string, or null if hashing fails.
*/
export const getHash = async (arrayBuffer: ArrayBuffer) => {
// Compute the SHA-256 hash of the array buffer
const hash = await sha256(arrayBuffer).catch((err) => {
// Handle error if hashing fails
console.log(`error occurred in hashing arrayBuffer :>> `, err)
toast.error(err.message || `error occurred in hashing arrayBuffer`)
return null // Return null if hashing fails
})
return hash // Return the SHA-256 hash
}

View File

@ -1,5 +1,7 @@
export * from './crypto'
export * from './file'
export * from './hash'
export * from './localStorage'
export * from './misc'
export * from './nostr'
export * from './string'
export * from './zip'

196
src/utils/misc.ts Normal file
View File

@ -0,0 +1,196 @@
import axios from 'axios'
import { EventTemplate } from 'nostr-tools'
import { MetadataController, NostrController } from '../controllers'
import { toast } from 'react-toastify'
/**
* Uploads a file to a file storage service.
* @param blob The Blob object representing the file to upload.
* @param nostrController The NostrController instance for handling authentication.
* @returns The URL of the uploaded file.
*/
export const uploadToFileStorage = async (
blob: Blob,
nostrController: NostrController
) => {
// Get the current timestamp in seconds
const unixNow = Math.floor(Date.now() / 1000)
// Create a File object with the Blob data
const file = new File([blob], `zipped-${unixNow}.zip`, {
type: 'application/zip'
})
// Define event metadata for authorization
const event: EventTemplate = {
kind: 24242,
content: 'Authorize Upload',
created_at: Math.floor(Date.now() / 1000),
tags: [
['t', 'upload'],
['expiration', String(unixNow + 60 * 5)], // Set expiration time to 5 minutes from now
['name', file.name],
['size', String(file.size)]
]
}
// Sign the authorization event using the NostrController
const authEvent = await nostrController.signEvent(event)
// URL of the file storage service
const FILE_STORAGE_URL = 'https://blossom.sigit.io'
// Upload the file to the file storage service using Axios
const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, {
headers: {
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)), // Set authorization header
'Content-Type': 'application/zip' // Set content type header
}
})
// Return the URL of the uploaded file
return response.data.url as string
}
/**
* Sends a Direct Message (DM) to a recipient, encrypting the content and handling authentication.
* @param fileUrl The URL of the encrypted zip file to be included in the DM.
* @param encryptionKey The encryption key used to decrypt the zip file to be included in the DM.
* @param pubkey The public key of the recipient.
* @param nostrController The NostrController instance for handling authentication and encryption.
* @param isSigner Boolean indicating whether the recipient is a signer or viewer.
* @param setAuthUrl Function to set the authentication URL in the component state.
*/
export const sendDM = async (
fileUrl: string,
encryptionKey: string,
pubkey: string,
nostrController: NostrController,
isSigner: boolean,
setAuthUrl: (value: React.SetStateAction<string | undefined>) => void
) => {
// Construct the content of the DM
const initialLine = isSigner
? 'You have been requested for a signature.'
: 'You have received a signed document.'
const content = `${initialLine}\nHere is the URL for the 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 \nEncryption key: ${encryptionKey}`
// Set up event listener for authentication event
nostrController.on('nsecbunker-auth', (url) => {
setAuthUrl(url)
})
// Set up timeout promise to handle encryption timeout
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error('Timeout occurred'))
}, 15000) // Timeout duration = 15 seconds
})
// Encrypt the DM content, with timeout
const encrypted = await Promise.race([
nostrController.nip04Encrypt(pubkey, content),
timeoutPromise
])
.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) // Clear authentication URL
})
// Return if encryption failed
if (!encrypted) return
// Construct event metadata for the DM
const event: EventTemplate = {
kind: 4, // DM event type
content: encrypted, // Encrypted DM content
created_at: Math.floor(Date.now() / 1000), // Current timestamp
tags: [['p', pubkey]] // Tag with recipient's public key
}
// Sign the DM event
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
})
// Return if event signing failed
if (!signedEvent) return
// Get relay list metadata
const metadataController = new MetadataController()
const relaySet = await metadataController
.findRelayListMetadata(pubkey)
.catch((err) => {
toast.error(
err.message || 'An error occurred while finding relay list metadata'
)
return null
})
// Return if metadata retrieval failed
if (!relaySet) return
// Ensure relay list is not empty
if (relaySet.read.length === 0) {
toast.error('No relay found for publishing encrypted DM')
return
}
// Publish the signed DM event to the recipient's read relays
await nostrController
.publishEvent(signedEvent, relaySet.read)
.then((relays) => {
toast.success(`Encrypted DM sent on: ${relays.join('\n')}`)
})
.catch((err) => {
console.log('err :>> ', err)
toast.error(err.message || 'An error occurred while publishing DM')
return null
})
}
/**
* Signs an event for a meta.json file.
* @param receiver The recipient's public key.
* @param fileHashes Object containing file hashes.
* @param nostrController The NostrController instance for signing the event.
* @param setIsLoading Function to set loading state in the component.
* @returns A Promise resolving to the signed event, or null if signing fails.
*/
export const signEventForMetaFile = async (
receiver: string,
fileHashes: {
[key: string]: string
},
nostrController: NostrController,
setIsLoading: (value: React.SetStateAction<boolean>) => void
) => {
// Construct the event metadata for the meta file
const event: EventTemplate = {
kind: 1, // Event type for meta file
tags: [['r', receiver]], // Tag with recipient's public key
content: JSON.stringify(fileHashes), // Convert file hashes to JSON string
created_at: Math.floor(Date.now() / 1000) // Current timestamp
}
// Sign the event
const signedEvent = await nostrController.signEvent(event).catch((err) => {
console.error(err)
toast.error(err.message || 'Error occurred in signing nostr event')
setIsLoading(false) // Set loading state to false
return null
})
return signedEvent // Return the signed event
}

View File

@ -59,3 +59,22 @@ export const hexStringToUint8Array = (hexString: string) => {
// Return the resulting Uint8Array
return uint8Array
}
/**
* Parses JSON content asynchronously.
* @param content The JSON content to parse.
* @returns A Promise that resolves to the parsed JSON object.
*/
export const parseJson = (content: string): Promise<any> => {
return new Promise((resolve, reject) => {
try {
// Attempt to parse the JSON content
const json = JSON.parse(content)
// Resolve the promise with the parsed JSON object
resolve(json)
} catch (error) {
// If parsing fails, reject the promise with the error
reject(error)
}
})
}

32
src/utils/zip.ts Normal file
View File

@ -0,0 +1,32 @@
import JSZip from 'jszip'
import { toast } from 'react-toastify'
import { OutputByType, OutputType } from '../types'
/**
* Read the content of a file within a zip archive.
* @param zip The JSZip object representing the zip archive.
* @param filePath The path of the file within the zip archive.
* @param outputType The type of output to return (e.g., 'string', 'arraybuffer', 'uint8array', etc.).
* @returns A Promise resolving to the content of the file, or null if an error occurs.
*/
export const readContentOfZipEntry = async <T extends OutputType>(
zip: JSZip,
filePath: string,
outputType: T
): Promise<OutputByType[T] | null> => {
// Get the zip entry corresponding to the specified file path
const zipEntry = zip.files[filePath]
// Read the content of the zip entry asynchronously
const fileContent = await zipEntry.async(outputType).catch((err) => {
// Handle any errors that occur during the read operation
console.log(`Error reading content of ${filePath}:`, err)
toast.error(
err.message || `Error occurred in reading content of ${filePath}`
)
return null
})
// Return the file content or null if an error occurred
return fileContent
}