import { Box, Button, Typography } from '@mui/material' import axios from 'axios' import saveAs from 'file-saver' import JSZip from 'jszip' import _ from 'lodash' import { MuiFileInput } from 'mui-file-input' import { Event, verifyEvent } from 'nostr-tools' import { useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { NostrController } from '../../controllers' import { appPublicRoutes } from '../../routes' import { State } from '../../store/rootReducer' import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types' import { decryptArrayBuffer, encryptArrayBuffer, generateEncryptionKey, generateKeysFile, getHash, hexToNpub, isOnline, npubToHex, parseJson, readContentOfZipEntry, sendDM, signEventForMetaFile, uploadToFileStorage } from '../../utils' import { DisplayMeta } from './internal/displayMeta' import styles from './style.module.scss' enum SignedStatus { Fully_Signed, User_Is_Next_Signer, User_Is_Not_Next_Signer } export const SignPage = () => { const navigate = useNavigate() const location = useLocation() const { arrayBuffer: decryptedArrayBuffer } = location.state || {} const [searchParams] = useSearchParams() const [displayInput, setDisplayInput] = useState(false) const [selectedFile, setSelectedFile] = useState(null) const [zip, setZip] = useState() const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [meta, setMeta] = useState(null) const [signedStatus, setSignedStatus] = useState() const [submittedBy, setSubmittedBy] = useState() const [signers, setSigners] = useState<`npub1${string}`[]>([]) const [viewers, setViewers] = useState<`npub1${string}`[]>([]) const [creatorFileHashes, setCreatorFileHashes] = useState<{ [key: string]: string }>({}) const [currentFileHashes, setCurrentFileHashes] = useState<{ [key: string]: string | null }>({}) const [signedBy, setSignedBy] = useState<`npub1${string}`[]>([]) const [nextSinger, setNextSinger] = useState() // This state variable indicates whether the logged-in user is a signer, a creator, or neither. const [isSignerOrCreator, setIsSignerOrCreator] = useState(false) const usersPubkey = useSelector((state: State) => state.auth.usersPubkey) const [authUrl, setAuthUrl] = useState() const nostrController = NostrController.getInstance() useEffect(() => { if (zip) { const generateCurrentFileHashes = async () => { const fileHashes: { [key: string]: string | null } = {} const fileNames = Object.values(zip.files) .filter((entry) => entry.name.startsWith('files/') && !entry.dir) .map((entry) => entry.name) // 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 arrayBuffer = await readContentOfZipEntry( zip, fileName, 'arraybuffer' ) if (arrayBuffer) { const hash = await getHash(arrayBuffer) if (hash) { fileHashes[fileName.replace(/^files\//, '')] = hash } } else { fileHashes[fileName.replace(/^files\//, '')] = null } } setCurrentFileHashes(fileHashes) } generateCurrentFileHashes() } }, [zip]) useEffect(() => { if (signers.length > 0) { // check if all signers have signed then its fully signed if (signers.every((signer) => signedBy.includes(signer))) { setSignedStatus(SignedStatus.Fully_Signed) } else { for (const signer of signers) { if (!signedBy.includes(signer)) { // signers in meta.json are in npub1 format // so, convert it to hex before setting to nextSigner setNextSinger(npubToHex(signer)!) const usersNpub = hexToNpub(usersPubkey!) if (signer === usersNpub) { // logged in user is the next signer setSignedStatus(SignedStatus.User_Is_Next_Signer) } else { setSignedStatus(SignedStatus.User_Is_Not_Next_Signer) } break } } } } else { // there's no signer just viewers. So its fully signed setSignedStatus(SignedStatus.Fully_Signed) } // Determine and set the status of the user if (submittedBy && usersPubkey && submittedBy === usersPubkey) { // If the submission was made by the user, set the status to true setIsSignerOrCreator(true) } else if (usersPubkey) { // Convert the user's public key from hex to npub format const usersNpub = hexToNpub(usersPubkey) if (signers.includes(usersNpub)) { // If the user's npub is in the list of signers, set the status to true setIsSignerOrCreator(true) } } }, [signers, signedBy, usersPubkey, submittedBy]) useEffect(() => { const fileUrl = searchParams.get('file') if (fileUrl) { setIsLoading(true) setLoadingSpinnerDesc('Fetching file from file server') axios .get(fileUrl, { responseType: 'arraybuffer' }) .then((res) => { const fileName = fileUrl.split('/').pop() const file = new File([res.data], fileName!) decrypt(file).then((arrayBuffer) => { if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer) }) }) .catch((err) => { console.error(`error occurred in getting file from ${fileUrl}`, err) toast.error( err.message || `error occurred in getting file from ${fileUrl}` ) }) .finally(() => { setIsLoading(false) }) } else if (decryptedArrayBuffer) { handleDecryptedArrayBuffer(decryptedArrayBuffer).finally(() => setIsLoading(false) ) } else { setIsLoading(false) setDisplayInput(true) } }, [searchParams, decryptedArrayBuffer]) const parseKeysJson = async (zip: JSZip) => { const keysFileContent = await readContentOfZipEntry( zip, 'keys.json', 'string' ) if (!keysFileContent) return null const parsedJSON = await parseJson<{ sender: string; keys: string[] }>( keysFileContent ).catch((err) => { console.log(`Error parsing content of keys.json:`, err) toast.error(err.message || `Error parsing content of keys.json`) return null }) return parsedJSON } const decrypt = async (file: File) => { setLoadingSpinnerDesc('Decrypting file') const zip = await JSZip.loadAsync(file).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 const parsedKeysJson = await parseKeysJson(zip) if (!parsedKeysJson) return const encryptedArrayBuffer = await readContentOfZipEntry( zip, 'compressed.sigit', 'arraybuffer' ) if (!encryptedArrayBuffer) return const { keys, sender } = parsedKeysJson for (const key of keys) { // 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((_, reject) => { setTimeout(() => { reject(new Error('Timeout occurred')) }, 60000) // Timeout duration = 60 seconds }) // decrypt the encryptionKey, with timeout const encryptionKey = await Promise.race([ nostrController.nip04Decrypt(sender, key), timeoutPromise ]) .then((res) => { return res }) .catch((err) => { console.log('err :>> ', err) return null }) .finally(() => { setAuthUrl(undefined) // Clear authentication URL }) console.log('encryptionKey :>> ', encryptionKey) // Return if encryption failed if (!encryptionKey) continue const arrayBuffer = await decryptArrayBuffer( encryptedArrayBuffer, encryptionKey ) .catch((err) => { console.log('err in decryption:>> ', err) return null }) .finally(() => { setIsLoading(false) }) if (arrayBuffer) return arrayBuffer } return null } const handleDecryptedArrayBuffer = async (arrayBuffer: ArrayBuffer) => { const decryptedZipFile = new File([arrayBuffer], 'decrypted.zip') setLoadingSpinnerDesc('Parsing zip file') const zip = await JSZip.loadAsync(decryptedZipFile).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 setZip(zip) setDisplayInput(false) setLoadingSpinnerDesc('Parsing meta.json') const metaFileContent = await readContentOfZipEntry( zip, 'meta.json', 'string' ) if (!metaFileContent) { setIsLoading(false) return } const parsedMetaJson = 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 (!parsedMetaJson) return const createSignatureEvent = await parseJson( parsedMetaJson.createSignature ).catch((err) => { console.log('err in parsing the createSignature event:>> ', err) toast.error( err.message || 'error occurred in parsing the create signature event' ) setIsLoading(false) return null }) if (!createSignatureEvent) return const isValidCreateSignature = verifyEvent(createSignatureEvent) if (!isValidCreateSignature) { toast.error('Create signature is invalid') setIsLoading(false) return } const createSignatureContent = await parseJson( createSignatureEvent.content ).catch((err) => { console.log( `err in parsing the createSignature event's content :>> `, err ) toast.error( err.message || `error occurred in parsing the create signature event's content` ) setIsLoading(false) return null }) if (!createSignatureContent) return setSigners(createSignatureContent.signers) setViewers(createSignatureContent.viewers) setCreatorFileHashes(createSignatureContent.fileHashes) setSubmittedBy(createSignatureEvent.pubkey) setSignedBy(Object.keys(parsedMetaJson.docSignatures) as `npub1${string}`[]) setMeta(parsedMetaJson) } const handleDecrypt = async () => { if (!selectedFile) return setIsLoading(true) const arrayBuffer = await decrypt(selectedFile) if (!arrayBuffer) return handleDecryptedArrayBuffer(arrayBuffer) } const handleSign = async () => { if (!zip || !meta) return setIsLoading(true) setLoadingSpinnerDesc('parsing hashes.json file') const hashesFileContent = await readHashesFile() if (!hashesFileContent) return if (!hashesFileContent) { setIsLoading(false) return } const hashes = await parseHashes(hashesFileContent) if (!hashes) return setLoadingSpinnerDesc('Generating hashes for files') setLoadingSpinnerDesc('Signing nostr event') const prevSig = getPrevSignersSig(hexToNpub(usersPubkey!)) if (!prevSig) return const signedEvent = await signEventForMeta(prevSig) if (!signedEvent) return const updatedMeta = updateMetaSignatures(meta, signedEvent) const stringifiedMeta = JSON.stringify(updatedMeta, null, 2) zip.file('meta.json', stringifiedMeta) const metaHash = await getHash(stringifiedMeta) if (!metaHash) return const updatedHashes = updateHashes(hashes, metaHash) zip.file('hashes.json', JSON.stringify(updatedHashes, null, 2)) const arrayBuffer = await generateZipArrayBuffer(zip) if (!arrayBuffer) return const key = await generateEncryptionKey() setLoadingSpinnerDesc('Encrypting zip file') const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key) const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key) if (!finalZipFile) return if (await isOnline()) { await handleOnlineFlow(finalZipFile) } handleDecryptedArrayBuffer(arrayBuffer).finally(() => setIsLoading(false)) } // Read the content of the hashes.json file const readHashesFile = async (): Promise => { return await readContentOfZipEntry(zip!, 'hashes.json', 'string').catch( (err) => { console.log('Error reading hashes.json file:', err) setIsLoading(false) return null } ) } // Parse the JSON content of the hashes file const parseHashes = async ( hashesFileContent: string ): Promise | null> => { return await parseJson>(hashesFileContent).catch( (err) => { console.log('Error parsing hashes.json content:', err) toast.error(err.message || 'Error parsing hashes.json content') setIsLoading(false) return null } ) } // Sign the event for the meta file const signEventForMeta = async (prevSig: string) => { return await signEventForMetaFile( JSON.stringify({ prevSig }), nostrController, setIsLoading ) } // Update the meta signatures const updateMetaSignatures = (meta: Meta, signedEvent: SignedEvent): Meta => { const metaCopy = _.cloneDeep(meta) metaCopy.docSignatures = { ...metaCopy.docSignatures, [hexToNpub(signedEvent.pubkey)]: JSON.stringify(signedEvent, null, 2) } return metaCopy } // Update the hashes with the new meta hash const updateHashes = ( hashes: Record, metaHash: string ): Record => { return { ...hashes, [usersPubkey!]: metaHash } } // Generate the zip array buffer const generateZipArrayBuffer = async ( zip: JSZip ): Promise => { return await zip .generateAsync({ type: 'arraybuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } }) .catch((err) => { console.log('Error generating zip file:', err) setIsLoading(false) toast.error(err.message || 'Error generating zip file') return null }) } // create final zip file const createFinalZipFile = async ( encryptedArrayBuffer: ArrayBuffer, encryptionKey: string ): Promise => { // Get the current timestamp in seconds const unixNow = Math.floor(Date.now() / 1000) const blob = new Blob([encryptedArrayBuffer]) // Create a File object with the Blob data const file = new File([blob], `compressed.sigit`, { type: 'application/sigit' }) const isLastSigner = checkIsLastSigner(signers) const userSet = new Set() if (isLastSigner) { if (submittedBy) { userSet.add(submittedBy) } signers.forEach((signer) => { userSet.add(npubToHex(signer)!) }) viewers.forEach((viewer) => { userSet.add(npubToHex(viewer)!) }) } else { const usersNpub = hexToNpub(usersPubkey!) const signerIndex = signers.indexOf(usersNpub) const nextSigner = signers[signerIndex + 1] userSet.add(npubToHex(nextSigner)!) } const keysFileContent = await generateKeysFile( Array.from(userSet), encryptionKey ) if (!keysFileContent) return null const zip = new JSZip() zip.file(`compressed.sigit`, file) zip.file('keys.json', keysFileContent) const arraybuffer = await zip .generateAsync({ type: 'arraybuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } }) .catch(handleZipError) if (!arraybuffer) return null const finalZipFile = new File( [new Blob([arraybuffer])], `${unixNow}.sigit.zip`, { type: 'application/zip' } ) return finalZipFile } // Handle errors during zip file generation const handleZipError = (err: any) => { console.log('Error in zip:>> ', err) setIsLoading(false) toast.error(err.message || 'Error occurred in generating zip file') return null } // Handle the online flow: upload file and send DMs const handleOnlineFlow = async (file: File) => { const fileUrl = await uploadZipFile(file) if (!fileUrl) return const isLastSigner = checkIsLastSigner(signers) if (isLastSigner) { await sendDMToAllUsers(fileUrl) } else { await sendDMToNextSigner(fileUrl) } setIsLoading(false) } // Upload the zip file to file storage const uploadZipFile = async (file: File): Promise => { setLoadingSpinnerDesc('Uploading zip file to file storage.') const fileUrl = await uploadToFileStorage(file, nostrController) .then((url) => { toast.success('Zip file uploaded to file storage') return url }) .catch((err) => { console.log('Error uploading file:', err) setIsLoading(false) toast.error(err.message || 'Error uploading file') return null }) return fileUrl } // Check if the current user is the last signer const checkIsLastSigner = (signers: string[]): boolean => { const usersNpub = hexToNpub(usersPubkey!) const lastSignerIndex = signers.length - 1 const signerIndex = signers.indexOf(usersNpub) return signerIndex === lastSignerIndex } // Send DM to all users (signers and viewers) const sendDMToAllUsers = async (fileUrl: string) => { const userSet = new Set<`npub1${string}`>() if (submittedBy) { userSet.add(hexToNpub(submittedBy)) } signers.forEach((signer) => { userSet.add(signer) }) viewers.forEach((viewer) => { userSet.add(viewer) }) const users = Array.from(userSet) for (const user of users) { await sendDM( fileUrl, npubToHex(user)!, nostrController, false, setAuthUrl ) } } // Send DM to the next signer const sendDMToNextSigner = async (fileUrl: string) => { const usersNpub = hexToNpub(usersPubkey!) const signerIndex = signers.indexOf(usersNpub) const nextSigner = signers[signerIndex + 1] await sendDM( fileUrl, npubToHex(nextSigner)!, nostrController, true, setAuthUrl ) } const handleExport = async () => { if (!meta || !zip || !usersPubkey) return const usersNpub = hexToNpub(usersPubkey) if ( !signers.includes(usersNpub) && !viewers.includes(usersNpub) && submittedBy !== usersNpub ) return setIsLoading(true) setLoadingSpinnerDesc('Signing nostr event') const prevSig = await getLastSignersSig() if (!prevSig) return const signedEvent = await signEventForMetaFile( JSON.stringify({ prevSig }), nostrController, setIsLoading ) if (!signedEvent) return const exportSignature = JSON.stringify(signedEvent, null, 2) const stringifiedMeta = JSON.stringify( { ...meta, exportSignature }, 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 blob = new Blob([arrayBuffer]) saveAs(blob, 'exported.zip') setIsLoading(false) navigate(appPublicRoutes.verify) } const handleExportSigit = async () => { if (!zip) return 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 key = await generateEncryptionKey() setLoadingSpinnerDesc('Encrypting zip file') const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key) const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key) if (!finalZipFile) return saveAs(finalZipFile, 'exported.sigit.zip') } /** * This function accepts an npub of a signer and return the signature of its previous signer. * This prevSig will be used in the content of the provided signer's signedEvent */ const getPrevSignersSig = (npub: string) => { if (!meta) return null // if user is first signer then use creator's signature if (signers[0] === npub) { try { const createSignatureEvent: Event = JSON.parse(meta.createSignature) return createSignatureEvent.sig } catch (error) { return null } } // find the index of signer const currentSignerIndex = signers.findIndex((signer) => signer === npub) // return null if could not found user in signer's list if (currentSignerIndex === -1) return null // find prev signer const prevSigner = signers[currentSignerIndex - 1] // get the signature of prev signer try { const prevSignersEvent: Event = JSON.parse(meta.docSignatures[prevSigner]) return prevSignersEvent.sig } catch (error) { return null } } /** * This function returns the signature of last signer * It will be used in the content of export signature's signedEvent */ const getLastSignersSig = () => { if (!meta) return null // if there're no signers then use creator's signature if (signers.length === 0) { try { const createSignatureEvent: Event = JSON.parse(meta.createSignature) return createSignatureEvent.sig } catch (error) { return null } } // get last signer const lastSigner = signers[signers.length - 1] // get the signature of last signer try { const lastSignatureEvent: Event = JSON.parse( meta.docSignatures[lastSigner] ) return lastSignatureEvent.sig } catch (error) { return null } } if (authUrl) { return (