diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts index 049f99a..920d894 100644 --- a/src/controllers/NostrController.ts +++ b/src/controllers/NostrController.ts @@ -378,6 +378,54 @@ export class NostrController extends EventEmitter { throw new Error('Login method is undefined') } + nip04Decrypt = async (sender: 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 decrypted = await nostr.nip04.decrypt(sender, content) + return decrypted + } + + if (loginMethod === LoginMethods.privateKey) { + const keys = (store.getState().auth as AuthState).keyPair + + if (!keys) { + throw new Error( + `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.` + ) + } + + const { private: nsec } = keys + const privateKey = nip19.decode(nsec).data as Uint8Array + + const decrypted = await nip04.decrypt(privateKey, sender, content) + return decrypted + } + + if (loginMethod === LoginMethods.nsecBunker) { + const user = new NDKUser({ pubkey: sender }) + + this.remoteSigner?.on('authUrl', (authUrl) => { + this.emit('nsecbunker-auth', authUrl) + }) + + if (!this.remoteSigner) throw new Error('Remote signer is undefined.') + const decrypted = await this.remoteSigner.decrypt(user, content) + + return decrypted + } + + 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 diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 98525a4..c445936 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -33,6 +33,7 @@ import { Meta, ProfileMetadata, User, UserRole } from '../../types' import { encryptArrayBuffer, generateEncryptionKey, + generateKeysFile, getHash, hexToNpub, isOnline, @@ -49,15 +50,12 @@ import { HTML5Backend } from 'react-dnd-html5-backend' import type { Identifier, XYCoord } from 'dnd-core' import { useDrag, useDrop } from 'react-dnd' import saveAs from 'file-saver' -import CopyModal from '../../components/copyModal' import { Event, kinds } from 'nostr-tools' export const CreatePage = () => { const navigate = useNavigate() const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - const [openCopyModal, setOpenCopyModel] = useState(false) - const [textToCopy, setTextToCopy] = useState('') const [authUrl, setAuthUrl] = useState() @@ -374,26 +372,68 @@ export const CreatePage = () => { encryptionKey: string ): Promise => { setLoadingSpinnerDesc('Encrypting zip file') - return encryptArrayBuffer(arraybuffer, encryptionKey).finally(() => - setIsLoading(false) + return encryptArrayBuffer(arraybuffer, encryptionKey) + } + + // 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 firstSigner = users.filter((user) => user.role === UserRole.signer)[0] + + const keysFileContent = await generateKeysFile( + [firstSigner.pubkey], + 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 file upload and further actions based on online/offline status - const handleFileUpload = async (blob: Blob, encryptionKey: string) => { + const handleFileUpload = async (file: File, arrayBuffer: ArrayBuffer) => { if (await isOnline()) { - const fileUrl = await uploadFile(blob) + const fileUrl = await uploadFile(file) if (!fileUrl) return - await sendDMs(fileUrl, encryptionKey) + await sendDMs(fileUrl) setIsLoading(false) - navigate( - `${appPrivateRoutes.sign}?file=${encodeURIComponent(fileUrl)}&key=${encodeURIComponent(encryptionKey)}` - ) + navigate(appPrivateRoutes.sign, { state: { arrayBuffer } }) } else { - handleOffline(blob, encryptionKey) + handleOffline(file, arrayBuffer) } } @@ -406,11 +446,11 @@ export const CreatePage = () => { } // Upload the file to the storage and send DMs to signers/viewers - const uploadFile = async (blob: Blob): Promise => { + const uploadFile = async (file: File): Promise => { setIsLoading(true) setLoadingSpinnerDesc('Uploading zip file to file storage.') - const fileUrl = await uploadToFileStorage(blob, nostrController) + const fileUrl = await uploadToFileStorage(file, nostrController) .then((url) => { toast.success('zip file uploaded to file storage') return url @@ -420,8 +460,8 @@ export const CreatePage = () => { return fileUrl } - // Send DMs to signers and viewers with the file URL and encryption key - const sendDMs = async (fileUrl: string, encryptionKey: string) => { + // Send DMs to signers and viewers with the file URL + const sendDMs = async (fileUrl: string) => { setLoadingSpinnerDesc('Sending DM to signers/viewers') const signers = users.filter((user) => user.role === UserRole.signer) @@ -430,7 +470,6 @@ export const CreatePage = () => { if (signers.length > 0) { await sendDM( fileUrl, - encryptionKey, signers[0].pubkey, nostrController, true, @@ -438,34 +477,15 @@ export const CreatePage = () => { ) } else { for (const viewer of viewers) { - await sendDM( - fileUrl, - encryptionKey, - viewer.pubkey, - nostrController, - false, - setAuthUrl - ) + await sendDM(fileUrl, viewer.pubkey, nostrController, false, setAuthUrl) } } } // Manage offline scenarios for signing or viewing the file - const handleOffline = (blob: Blob, encryptionKey: string) => { - const signers = users.filter((user) => user.role === UserRole.signer) - - if (signers[0] && signers[0].pubkey === usersPubkey) { - // Create a File object with the Blob data for offline signing - const file = new File([blob], `compressed.sigit`, { - type: 'application/sigit' - }) - navigate(appPrivateRoutes.sign, { state: { file, encryptionKey } }) - } else { - // Save the file and show encryption key for offline viewing - saveAs(blob, 'request.sigit') - setTextToCopy(encryptionKey) - setOpenCopyModel(true) - } + const handleOffline = (file: File, arrayBuffer: ArrayBuffer) => { + saveAs(file, 'request.sigit.zip') + navigate(appPrivateRoutes.sign, { state: { arrayBuffer } }) } const handleCreate = async () => { @@ -497,9 +517,15 @@ export const CreatePage = () => { arraybuffer, encryptionKey ) - const blob = new Blob([encryptedArrayBuffer]) - return await handleFileUpload(blob, encryptionKey) + const finalZipFile = await createFinalZipFile( + encryptedArrayBuffer, + encryptionKey + ) + + if (!finalZipFile) return + + return await handleFileUpload(finalZipFile, arraybuffer) } if (authUrl) { @@ -596,15 +622,6 @@ export const CreatePage = () => { - { - setOpenCopyModel(false) - navigate(appPrivateRoutes.sign) - }} - title="Decryption key for Sigit file" - textToCopy={textToCopy} - /> ) } diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 7ffc1ec..715bdcc 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -10,7 +10,6 @@ import { TableCell, TableHead, TableRow, - TextField, Tooltip, Typography, useTheme @@ -52,7 +51,8 @@ import { shorten, signEventForMetaFile, uploadToFileStorage, - isOnline + isOnline, + generateKeysFile } from '../../utils' import styles from './style.module.scss' import { @@ -71,14 +71,13 @@ enum SignedStatus { export const SignPage = () => { const navigate = useNavigate() const location = useLocation() - const { file, encryptionKey: encKey } = location.state || {} + const { arrayBuffer: decryptedArrayBuffer } = location.state || {} - const [searchParams, setSearchParams] = useSearchParams() + const [searchParams] = useSearchParams() const [displayInput, setDisplayInput] = useState(false) const [selectedFile, setSelectedFile] = useState(null) - const [encryptionKey, setEncryptionKey] = useState('') const [zip, setZip] = useState() @@ -194,9 +193,8 @@ export const SignPage = () => { useEffect(() => { const fileUrl = searchParams.get('file') - const key = searchParams.get('key') - if (fileUrl && key) { + if (fileUrl) { setIsLoading(true) setLoadingSpinnerDesc('Fetching file from file server') @@ -208,7 +206,7 @@ export const SignPage = () => { const fileName = fileUrl.split('/').pop() const file = new File([res.data], fileName!) - decrypt(file, decodeURIComponent(key)).then((arrayBuffer) => { + decrypt(file).then((arrayBuffer) => { if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer) }) }) @@ -221,40 +219,109 @@ export const SignPage = () => { .finally(() => { setIsLoading(false) }) - } else if (file && encKey) { - decrypt(file, decodeURIComponent(encKey)) - .then((arrayBuffer) => { - if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer) - }) - .catch((err) => { - console.error(`error occurred in decryption`, err) - toast.error(err.message || `error occurred in decryption`) - }) - .finally(() => { - setIsLoading(false) - }) + } else if (decryptedArrayBuffer) { + handleDecryptedArrayBuffer(decryptedArrayBuffer).finally(() => + setIsLoading(false) + ) } else { setIsLoading(false) setDisplayInput(true) } - }, [searchParams, file, encKey]) + }, [searchParams, decryptedArrayBuffer]) - const decrypt = async (file: File, key: string) => { + 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 encryptedArrayBuffer = await file.arrayBuffer() + 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 arrayBuffer = await decryptArrayBuffer(encryptedArrayBuffer, key) - .catch((err) => { - console.log('err in decryption:>> ', err) - toast.error(err.message || 'An error occurred in decrypting file.') - return null - }) - .finally(() => { - setIsLoading(false) + 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) }) - return arrayBuffer + // 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) => { @@ -348,13 +415,10 @@ export const SignPage = () => { } const handleDecrypt = async () => { - if (!selectedFile || !encryptionKey) return + if (!selectedFile) return setIsLoading(true) - const arrayBuffer = await decrypt( - selectedFile, - decodeURIComponent(encryptionKey) - ) + const arrayBuffer = await decrypt(selectedFile) if (!arrayBuffer) return @@ -407,13 +471,15 @@ export const SignPage = () => { setLoadingSpinnerDesc('Encrypting zip file') const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key) - const blob = new Blob([encryptedArrayBuffer]) + const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key) + + if (!finalZipFile) return if (await isOnline()) { - await handleOnlineFlow(blob, key) - } else { - handleDecryptedArrayBuffer(arrayBuffer).finally(() => setIsLoading(false)) + await handleOnlineFlow(finalZipFile) } + + handleDecryptedArrayBuffer(arrayBuffer).finally(() => setIsLoading(false)) } // Read the content of the hashes.json file @@ -491,32 +557,101 @@ export const SignPage = () => { }) } + // 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 (blob: Blob, key: string) => { - const fileUrl = await uploadZipFile(blob) + const handleOnlineFlow = async (file: File) => { + const fileUrl = await uploadZipFile(file) if (!fileUrl) return const isLastSigner = checkIsLastSigner(signers) if (isLastSigner) { - await sendDMToAllUsers(fileUrl, key) + await sendDMToAllUsers(fileUrl) } else { - await sendDMToNextSigner(fileUrl, key) + await sendDMToNextSigner(fileUrl) } setIsLoading(false) - - // Update search params with updated file URL and encryption key - setSearchParams({ - file: fileUrl, - key: key - }) } // Upload the zip file to file storage - const uploadZipFile = async (blob: Blob): Promise => { + const uploadZipFile = async (file: File): Promise => { setLoadingSpinnerDesc('Uploading zip file to file storage.') - const fileUrl = await uploadToFileStorage(blob, nostrController) + const fileUrl = await uploadToFileStorage(file, nostrController) .then((url) => { toast.success('Zip file uploaded to file storage') return url @@ -540,7 +675,7 @@ export const SignPage = () => { } // Send DM to all users (signers and viewers) - const sendDMToAllUsers = async (fileUrl: string, key: string) => { + const sendDMToAllUsers = async (fileUrl: string) => { const userSet = new Set<`npub1${string}`>() if (submittedBy) { @@ -560,7 +695,6 @@ export const SignPage = () => { for (const user of users) { await sendDM( fileUrl, - key, npubToHex(user)!, nostrController, false, @@ -570,13 +704,12 @@ export const SignPage = () => { } // Send DM to the next signer - const sendDMToNextSigner = async (fileUrl: string, key: string) => { + const sendDMToNextSigner = async (fileUrl: string) => { const usersNpub = hexToNpub(usersPubkey!) const signerIndex = signers.indexOf(usersNpub) const nextSigner = signers[signerIndex + 1] await sendDM( fileUrl, - key, npubToHex(nextSigner)!, nostrController, true, @@ -771,18 +904,9 @@ export const SignPage = () => { value={selectedFile} onChange={(value) => setSelectedFile(value)} /> - - {selectedFile && ( - setEncryptionKey(e.target.value)} - /> - )} - {selectedFile && encryptionKey && ( + {selectedFile && (