diff --git a/src/components/username.tsx b/src/components/username.tsx index ff93494..2945126 100644 --- a/src/components/username.tsx +++ b/src/components/username.tsx @@ -65,7 +65,9 @@ export const UserComponent = ({ pubkey, name, image }: UserProps) => { const roboImage = `https://robohash.org/${npub}.png?set=set3` return ( - + User Image { const viewers = users.filter((user) => user.role === UserRole.viewer) setLoadingSpinnerDesc('Signing nostr event') - const signedEvent = await signEventForMetaFile( - fileHashes, + const createSignature = await signEventForMetaFile( + JSON.stringify({ + signers: signers.map((signer) => hexToNpub(signer.pubkey)), + viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)), + fileHashes + }), nostrController, setIsLoading ) - if (!signedEvent) return + if (!createSignature) return // create content for meta file const meta: Meta = { - signers: signers.map((signer) => hexToNpub(signer.pubkey)), - viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)), - fileHashes, - submittedBy: hexToNpub(usersPubkey!), - signedEvents: { - [hexToNpub(signedEvent.pubkey)]: JSON.stringify(signedEvent, null, 2) - } + createSignature: JSON.stringify(createSignature, null, 2), + docSignatures: {} } try { diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index b5bacee..c9404f7 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -1,6 +1,7 @@ import { Box, Button, + IconButton, List, ListItem, ListSubheader, @@ -10,6 +11,7 @@ import { TableHead, TableRow, TextField, + Tooltip, Typography, useTheme } from '@mui/material' @@ -18,7 +20,7 @@ import saveAs from 'file-saver' import JSZip from 'jszip' import _ from 'lodash' import { MuiFileInput } from 'mui-file-input' -import { EventTemplate } from 'nostr-tools' +import { Event, verifyEvent } from 'nostr-tools' import { useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { useNavigate, useSearchParams } from 'react-router-dom' @@ -28,7 +30,13 @@ import { UserComponent } from '../../components/username' import { MetadataController, NostrController } from '../../controllers' import { appPrivateRoutes } from '../../routes' import { State } from '../../store/rootReducer' -import { Meta, ProfileMetadata, User, UserRole } from '../../types' +import { + CreateSignatureEventContent, + Meta, + ProfileMetadata, + User, + UserRole +} from '../../types' import { decryptArrayBuffer, encryptArrayBuffer, @@ -44,6 +52,7 @@ import { uploadToFileStorage } from '../../utils' import styles from './style.module.scss' +import { Download } from '@mui/icons-material' enum SignedStatus { Fully_Signed, @@ -68,6 +77,19 @@ export const SignPage = () => { 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() const usersPubkey = useSelector((state: State) => state.auth.usersPubkey) @@ -76,42 +98,70 @@ export const SignPage = () => { const nostrController = NostrController.getInstance() useEffect(() => { - if (meta) { - setDisplayInput(false) + 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) - // get list of users who have signed - const signedBy = Object.keys(meta.signedEvents) + // 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 (meta.signers.length > 0) { - // check if all signers have signed then its fully signed - if (meta.signers.every((signer) => signedBy.includes(signer))) { - setSignedStatus(SignedStatus.Fully_Signed) - } else { - for (const signer of meta.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)!) + if (arrayBuffer) { + const hash = await getHash(arrayBuffer) - 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 + if (hash) { + fileHashes[fileName.replace(/^files\//, '')] = hash } + } else { + fileHashes[fileName.replace(/^files\//, '')] = null } } - } else { - // there's no signer just viewers. So its fully signed - setSignedStatus(SignedStatus.Fully_Signed) + + setCurrentFileHashes(fileHashes) } + + generateCurrentFileHashes() } - }, [meta, usersPubkey]) + }, [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) + } + }, [signers, signedBy, usersPubkey]) useEffect(() => { const fileUrl = searchParams.get('file') @@ -205,6 +255,53 @@ export const SignPage = () => { } ) + 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) } @@ -252,36 +349,11 @@ export const SignPage = () => { setLoadingSpinnerDesc('Generating hashes for files') - const fileHashes: { [key: string]: string } = {} - const fileNames = Object.keys(meta.fileHashes) - - // 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 - } - setLoadingSpinnerDesc('Signing nostr event') const signedEvent = await signEventForMetaFile( - fileHashes, + JSON.stringify({ + fileHashes: currentFileHashes + }), nostrController, setIsLoading ) @@ -290,8 +362,8 @@ export const SignPage = () => { const metaCopy = _.cloneDeep(meta) - metaCopy.signedEvents = { - ...metaCopy.signedEvents, + metaCopy.docSignatures = { + ...metaCopy.docSignatures, [hexToNpub(signedEvent.pubkey)]: JSON.stringify(signedEvent, null, 2) } @@ -349,21 +421,23 @@ export const SignPage = () => { // check if the current user is the last signer const usersNpub = hexToNpub(usersPubkey!) - const lastSignerIndex = meta.signers.length - 1 - const signerIndex = meta.signers.indexOf(usersNpub) + const lastSignerIndex = signers.length - 1 + const signerIndex = signers.indexOf(usersNpub) const isLastSigner = signerIndex === lastSignerIndex // if current user is the last signer, then send DMs to all signers and viewers if (isLastSigner) { const userSet = new Set<`npub1${string}`>() - userSet.add(meta.submittedBy) + if (submittedBy) { + userSet.add(hexToNpub(submittedBy)) + } - meta.signers.forEach((signer) => { + signers.forEach((signer) => { userSet.add(signer) }) - meta.viewers.forEach((viewer) => { + viewers.forEach((viewer) => { userSet.add(viewer) }) @@ -381,7 +455,7 @@ export const SignPage = () => { ) } } else { - const nextSigner = meta.signers[signerIndex + 1] + const nextSigner = signers[signerIndex + 1] await sendDM( fileUrl, key, @@ -406,28 +480,21 @@ export const SignPage = () => { const usersNpub = hexToNpub(usersPubkey) if ( - !meta.signers.includes(usersNpub) && - !meta.viewers.includes(usersNpub) && - meta.submittedBy !== usersNpub + !signers.includes(usersNpub) && + !viewers.includes(usersNpub) && + submittedBy !== usersNpub ) return setIsLoading(true) setLoadingSpinnerDesc('Signing nostr event') - const event: EventTemplate = { - kind: 1, - content: '', - created_at: Math.floor(Date.now() / 1000), // Current timestamp - tags: [] - } - - // 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 - }) + const signedEvent = await signEventForMetaFile( + JSON.stringify({ + fileHashes: currentFileHashes + }), + nostrController, + setIsLoading + ) if (!signedEvent) return @@ -516,29 +583,33 @@ export const SignPage = () => { )} - {meta && signedStatus === SignedStatus.Fully_Signed && ( + {submittedBy && zip && ( <> - - - - - - )} + + {signedStatus === SignedStatus.Fully_Signed && ( + + + + )} - {meta && signedStatus === SignedStatus.User_Is_Not_Next_Signer && ( - - )} - - {meta && signedStatus === SignedStatus.User_Is_Next_Signer && ( - <> - - - - + {signedStatus === SignedStatus.User_Is_Next_Signer && ( + + + + )} )} @@ -547,11 +618,26 @@ export const SignPage = () => { } type DisplayMetaProps = { - meta: Meta + zip: JSZip + submittedBy: string + signers: `npub1${string}`[] + viewers: `npub1${string}`[] + creatorFileHashes: { [key: string]: string } + currentFileHashes: { [key: string]: string | null } + signedBy: `npub1${string}`[] nextSigner?: string } -const DisplayMeta = ({ meta, nextSigner }: DisplayMetaProps) => { +const DisplayMeta = ({ + zip, + submittedBy, + signers, + viewers, + creatorFileHashes, + currentFileHashes, + signedBy, + nextSigner +}: DisplayMetaProps) => { const theme = useTheme() const textColor = theme.palette.getContrastText( @@ -564,7 +650,7 @@ const DisplayMeta = ({ meta, nextSigner }: DisplayMetaProps) => { const [users, setUsers] = useState([]) useEffect(() => { - meta.signers.forEach((signer) => { + signers.forEach((signer) => { const hexKey = npubToHex(signer) setUsers((prev) => { if (prev.findIndex((user) => user.pubkey === hexKey) !== -1) return prev @@ -579,7 +665,7 @@ const DisplayMeta = ({ meta, nextSigner }: DisplayMetaProps) => { }) }) - meta.viewers.forEach((viewer) => { + viewers.forEach((viewer) => { const hexKey = npubToHex(viewer) setUsers((prev) => { if (prev.findIndex((user) => user.pubkey === hexKey) !== -1) return prev @@ -593,13 +679,13 @@ const DisplayMeta = ({ meta, nextSigner }: DisplayMetaProps) => { ] }) }) - }, [meta]) + }, [signers, viewers]) useEffect(() => { const metadataController = new MetadataController() const hexKeys: string[] = [ - npubToHex(meta.submittedBy)!, + npubToHex(submittedBy)!, ...users.map((user) => user.pubkey) ] @@ -622,7 +708,19 @@ const DisplayMeta = ({ meta, nextSigner }: DisplayMetaProps) => { }) } }) - }, [users, meta.submittedBy]) + }, [users, submittedBy]) + + const downloadFile = async (filename: string) => { + const arrayBuffer = await readContentOfZipEntry( + zip, + `files/${filename}`, + 'arraybuffer' + ) + if (!arrayBuffer) return + + const blob = new Blob([arrayBuffer]) + saveAs(blob, filename) + } return ( { marginTop: 2 }} subheader={ - - Meta Info - + Meta Info } > { Submitted By {(function () { - const pubkey = npubToHex(meta.submittedBy) - const profile = metadata[pubkey!] + const profile = metadata[submittedBy] return ( ) })()} @@ -679,13 +764,40 @@ const DisplayMeta = ({ meta, nextSigner }: DisplayMetaProps) => { Files -
    - {Object.keys(meta.fileHashes).map((file, index) => ( -
  • - {file} -
  • - ))} -
+ + {Object.entries(currentFileHashes).map(([filename, hash], index) => { + const isValidHash = creatorFileHashes[filename] === hash + + return ( + + + downloadFile(filename)}> + + + + + {filename} + + + {isValidHash ? 'Valid' : 'Invalid'} hash + + + ) + })} +
@@ -705,7 +817,7 @@ const DisplayMeta = ({ meta, nextSigner }: DisplayMetaProps) => { if (user.role === UserRole.signer) { // check if user has signed the document const usersNpub = hexToNpub(user.pubkey) - if (usersNpub in meta.signedEvents) { + if (signedBy.includes(usersNpub)) { signedStatus = 'Signed' } // check if user is the next signer diff --git a/src/pages/sign/style.module.scss b/src/pages/sign/style.module.scss index 7414275..283f3d8 100644 --- a/src/pages/sign/style.module.scss +++ b/src/pages/sign/style.module.scss @@ -10,6 +10,25 @@ gap: 25px; } + .subHeader { + border-bottom: 0.5px solid; + padding: 8px 16px; + font-size: 1.5rem; + } + + .filesWrapper { + display: flex; + flex-direction: column; + gap: 10px; + margin-left: 15px; + + .file { + display: flex; + align-items: center; + gap: 15px; + } + } + .user { display: flex; align-items: center; diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 6f8b9e1..42724ee 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -15,8 +15,9 @@ import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { UserComponent } from '../../components/username' import { MetadataController } from '../../controllers' -import { Meta, ProfileMetadata } from '../../types' +import { CreateSignatureEventContent, Meta, ProfileMetadata } from '../../types' import { + getHash, hexToNpub, npubToHex, parseJson, @@ -36,17 +37,64 @@ export const VerifyPage = () => { const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [selectedFile, setSelectedFile] = useState(null) + const [zip, setZip] = useState() const [meta, setMeta] = useState(null) + 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 [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( {} ) useEffect(() => { - if (meta) { + 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 (submittedBy) { const metadataController = new MetadataController() - const users = [meta.submittedBy, ...meta.signers, ...meta.viewers] + const users = [submittedBy, ...signers, ...viewers] users.forEach((user) => { const pubkey = npubToHex(user)! @@ -72,7 +120,7 @@ export const VerifyPage = () => { } }) } - }, [meta]) + }, [submittedBy, signers, viewers]) const handleVerify = async () => { if (!selectedFile) return @@ -85,6 +133,7 @@ export const VerifyPage = () => { }) if (!zip) return + setZip(zip) setLoadingSpinnerDesc('Parsing meta.json') @@ -110,6 +159,51 @@ export const VerifyPage = () => { } ) + 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) + setMeta(parsedMetaJson) setIsLoading(false) } @@ -121,7 +215,7 @@ export const VerifyPage = () => { if (verifySignature) { const npub = hexToNpub(pubkey) - const signedEventString = meta ? meta.signedEvents[npub] : null + const signedEventString = meta ? meta.docSignatures[npub] : null if (signedEventString) { try { const signedEvent = JSON.parse(signedEventString) @@ -150,11 +244,11 @@ export const VerifyPage = () => { component="label" sx={{ color: isValidSignature - ? theme.palette.text.primary + ? theme.palette.success.light : theme.palette.error.main }} > - ({isValidSignature ? 'Valid' : 'Invalid'} Signature) + {isValidSignature ? 'Valid' : 'Invalid'} Signature )} @@ -229,17 +323,19 @@ export const VerifyPage = () => { } > - - - Submitted By - - {displayUser(npubToHex(meta.submittedBy)!)} - + {submittedBy && ( + + + Submitted By + + {displayUser(submittedBy)} + + )} { {displayExportedBy()} - {meta.signers.length > 0 && ( + {signers.length > 0 && ( { Signers
    - {meta.signers.map((signer) => ( + {signers.map((signer) => (
  • { )} - {meta.viewers.length > 0 && ( + {viewers.length > 0 && ( { Viewers
      - {meta.viewers.map((viewer) => ( + {viewers.map((viewer) => (
    • {displayUser(npubToHex(viewer)!)}
    • @@ -313,13 +409,37 @@ export const VerifyPage = () => { Files -
        - {Object.keys(meta.fileHashes).map((file, index) => ( -
      • - {file} -
      • - ))} -
      + + {Object.entries(currentFileHashes).map( + ([filename, hash], index) => { + const isValidHash = creatorFileHashes[filename] === hash + + return ( + + + {filename} + + + {isValidHash ? 'Valid' : 'Invalid'} hash + + + ) + } + )} + diff --git a/src/pages/verify/style.module.scss b/src/pages/verify/style.module.scss index 61fe63d..24dce80 100644 --- a/src/pages/verify/style.module.scss +++ b/src/pages/verify/style.module.scss @@ -10,6 +10,19 @@ font-size: 1.5rem; } + .filesWrapper { + display: flex; + flex-direction: column; + gap: 10px; + margin-left: 15px; + + .file { + display: flex; + align-items: center; + gap: 15px; + } + } + .usersList { display: flex; flex-direction: column; diff --git a/src/types/core.ts b/src/types/core.ts index 4ce1bcb..de8c2be 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -9,10 +9,13 @@ export interface User { } export interface Meta { + createSignature: string + docSignatures: { [key: `npub1${string}`]: string } + exportSignature?: string +} + +export interface CreateSignatureEventContent { signers: `npub1${string}`[] viewers: `npub1${string}`[] fileHashes: { [key: string]: string } - submittedBy: `npub1${string}` - signedEvents: { [key: `npub1${string}`]: string } - exportSignature?: string } diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 521b6b5..56ba1e6 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -177,22 +177,20 @@ export const sendDM = async ( /** * Signs an event for a meta.json file. - * @param fileHashes Object containing file hashes. + * @param content contains content for event. * @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 ( - fileHashes: { - [key: string]: string - }, + content: string, nostrController: NostrController, setIsLoading: (value: React.SetStateAction) => void ) => { // Construct the event metadata for the meta file const event: EventTemplate = { kind: 1, // Event type for meta file - content: JSON.stringify(fileHashes), // Convert file hashes to JSON string + content: content, // content for event created_at: Math.floor(Date.now() / 1000), // Current timestamp tags: [] }