From 3d1bdece4d881f347e974506af9d01d9be01f4f7 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 6 Dec 2024 20:00:38 +0100 Subject: [PATCH 1/5] feat(meta): send notifications with blossom instead of meta.json --- src/hooks/useSigitMeta.tsx | 8 +-- src/pages/create/index.tsx | 16 +++-- src/pages/sign/index.tsx | 14 +++-- src/pages/verify/index.tsx | 13 ++++- src/types/core.ts | 9 +++ src/types/errors/MetaStorageError.ts | 26 +++++++++ src/utils/meta.ts | 87 +++++++++++++++++++++++++++- src/utils/nostr.ts | 66 ++++++++++++++++----- 8 files changed, 210 insertions(+), 29 deletions(-) create mode 100644 src/types/errors/MetaStorageError.ts diff --git a/src/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx index 85841f2..5c1159e 100644 --- a/src/hooks/useSigitMeta.tsx +++ b/src/hooks/useSigitMeta.tsx @@ -45,7 +45,7 @@ export interface FlatMeta isValid: boolean // Decryption - encryptionKey: string | null + encryptionKey: string | undefined // Parsed Document Signatures parsedSignatureEvents: { @@ -101,7 +101,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { [signer: `npub1${string}`]: SignStatus }>({}) - const [encryptionKey, setEncryptionKey] = useState(null) + const [encryptionKey, setEncryptionKey] = useState() useEffect(() => { if (!meta) return @@ -143,7 +143,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { setMarkConfig(markConfig) setZipUrl(zipUrl) - let encryptionKey: string | null = null + let encryptionKey: string | undefined if (meta.keys) { const { sender, keys } = meta.keys // Retrieve the user's public key from the state @@ -161,7 +161,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { 'An error occurred in decrypting encryption key', err ) - return null + return undefined }) encryptionKey = decrypted diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 212f7bf..f0c1f0c 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -31,6 +31,7 @@ import { KeyboardCode, Meta, ProfileMetadata, + SigitNotification, SignedEvent, User, UserRole @@ -52,7 +53,8 @@ import { updateUsersAppData, uploadToFileStorage, DEFAULT_TOOLBOX, - settleAllFullfilfedPromises + settleAllFullfilfedPromises, + uploadMetaToFileStorage } from '../../utils' import { Container } from '../../components/Container' import fileListStyles from '../../components/FileList/style.module.scss' @@ -782,7 +784,7 @@ export const CreatePage = () => { } // Send notifications to signers and viewers - const sendNotifications = (meta: Meta) => { + const sendNotifications = (notification: SigitNotification) => { // no need to send notification to self so remove it from the list const receivers = ( signers.length > 0 @@ -790,7 +792,7 @@ export const CreatePage = () => { : viewers.map((viewer) => viewer.pubkey) ).filter((receiver) => receiver !== usersPubkey) - return receivers.map((receiver) => sendNotification(receiver, meta)) + return receivers.map((receiver) => sendNotification(receiver, notification)) } const extractNostrId = (stringifiedEvent: string): string => { @@ -865,11 +867,17 @@ export const CreatePage = () => { } setLoadingSpinnerDesc('Updating user app data') + + const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + const event = await updateUsersAppData(meta) if (!event) return setLoadingSpinnerDesc('Sending notifications to counterparties') - const promises = sendNotifications(meta) + const promises = sendNotifications({ + metaUrl, + keys: meta.keys + }) await Promise.all(promises) .then(() => { diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index f30ecdd..346e226 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -34,7 +34,8 @@ import { updateUsersAppData, findOtherUserMarks, timeout, - processMarks + processMarks, + uploadMetaToFileStorage } from '../../utils' import { Container } from '../../components/Container' import { DisplayMeta } from './internal/displayMeta' @@ -635,7 +636,7 @@ export const SignPage = () => { } if (await isOnline()) { - await handleOnlineFlow(updatedMeta) + await handleOnlineFlow(updatedMeta, encryptionKey) } else { setMeta(updatedMeta) setIsLoading(false) @@ -741,7 +742,10 @@ export const SignPage = () => { } // Handle the online flow: update users app data and send notifications - const handleOnlineFlow = async (meta: Meta) => { + const handleOnlineFlow = async ( + meta: Meta, + encryptionKey: string | undefined + ) => { setLoadingSpinnerDesc('Updating users app data') const updatedEvent = await updateUsersAppData(meta) if (!updatedEvent) { @@ -749,6 +753,8 @@ export const SignPage = () => { return } + const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + const userSet = new Set<`npub1${string}`>() if (submittedBy && submittedBy !== usersPubkey) { userSet.add(hexToNpub(submittedBy)) @@ -781,7 +787,7 @@ export const SignPage = () => { setLoadingSpinnerDesc('Sending notifications') const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, meta) + sendNotification(npubToHex(user)!, { metaUrl, keys: meta.keys }) ) await Promise.all(promises) .then(() => { diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 515a257..e870a23 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -23,7 +23,8 @@ import { getCurrentUserFiles, updateUsersAppData, npubToHex, - sendNotification + sendNotification, + uploadMetaToFileStorage } from '../../utils' import styles from './style.module.scss' import { useLocation, useParams } from 'react-router-dom' @@ -351,6 +352,11 @@ export const VerifyPage = () => { const updatedEvent = await updateUsersAppData(updatedMeta) if (!updatedEvent) return + const metaUrl = await uploadMetaToFileStorage( + updatedMeta, + encryptionKey + ) + const userSet = new Set<`npub1${string}`>() signers.forEach((signer) => { if (signer !== usersPubkey) { @@ -364,7 +370,10 @@ export const VerifyPage = () => { const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, updatedMeta) + sendNotification(npubToHex(user)!, { + metaUrl, + keys: meta.keys! + }) ) await Promise.all(promises) diff --git a/src/types/core.ts b/src/types/core.ts index df55a07..f07dbf7 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -83,3 +83,12 @@ export interface UserAppData { export interface DocSignatureEvent extends Event { parsedContent?: SignedEventContent } + +export interface SigitNotification { + metaUrl: string + keys?: { sender: string; keys: { [user: `npub1${string}`]: string } } +} + +export function isSigitNotification(obj: unknown): obj is SigitNotification { + return typeof (obj as SigitNotification).metaUrl === 'string' +} diff --git a/src/types/errors/MetaStorageError.ts b/src/types/errors/MetaStorageError.ts new file mode 100644 index 0000000..a5bc2cd --- /dev/null +++ b/src/types/errors/MetaStorageError.ts @@ -0,0 +1,26 @@ +import { Jsonable } from '.' + +export enum MetaStorageErrorType { + 'ENCRYPTION_KEY_REQUIRED' = 'Encryption key is required.', + 'HASHING_FAILED' = "Can't get encrypted file hash.", + 'FETCH_FAILED' = 'Fetching meta.json requires an encryption key.', + 'HASH_VERIFICATION_FAILED' = 'Unable to verify meta.json.', + 'DECRYPTION_FAILED' = 'Error decryping meta.json.', + 'UNHANDLED_FETCH_ERROR' = 'Unable to fetch meta.json. Something went wrong.' +} + +export class MetaStorageError extends Error { + public readonly context?: Jsonable + + constructor( + message: MetaStorageErrorType, + options: { cause?: Error; context?: Jsonable } = {} + ) { + const { cause, context } = options + + super(message, { cause }) + this.name = this.constructor.name + + this.context = context + } +} diff --git a/src/utils/meta.ts b/src/utils/meta.ts index c915f66..8052abf 100644 --- a/src/utils/meta.ts +++ b/src/utils/meta.ts @@ -1,5 +1,12 @@ import { CreateSignatureEventContent, Meta } from '../types' -import { fromUnixTimestamp, parseJson } from '.' +import { + decryptArrayBuffer, + encryptArrayBuffer, + fromUnixTimestamp, + getHash, + parseJson, + uploadToFileStorage +} from '.' import { Event, verifyEvent } from 'nostr-tools' import { toast } from 'react-toastify' import { extractFileExtensions } from './file' @@ -8,6 +15,11 @@ import { MetaParseError, MetaParseErrorType } from '../types/errors/MetaParseError' +import axios from 'axios' +import { + MetaStorageError, + MetaStorageErrorType +} from '../types/errors/MetaStorageError' export enum SignStatus { Signed = 'Signed', @@ -126,3 +138,76 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => { } } } + +export const uploadMetaToFileStorage = async ( + meta: Meta, + encryptionKey: string | undefined +) => { + // Value is the stringified meta object + const value = JSON.stringify(meta) + const encoder = new TextEncoder() + + // Encode it to the arrayBuffer + const uint8Array = encoder.encode(value) + + if (!encryptionKey) { + throw new MetaStorageError(MetaStorageErrorType.ENCRYPTION_KEY_REQUIRED) + } + + // Encrypt the file contents with the same encryption key from the create signature + const encryptedArrayBuffer = await encryptArrayBuffer( + uint8Array, + encryptionKey + ) + + const hash = await getHash(encryptedArrayBuffer) + if (!hash) { + throw new MetaStorageError(MetaStorageErrorType.HASHING_FAILED) + } + + // Create the encrypted json file from array buffer and hash + const file = new File([encryptedArrayBuffer], `${hash}.json`) + const url = await uploadToFileStorage(file) + + return url +} + +export const fetchMetaFromFileStorage = async ( + url: string, + encryptionKey: string | undefined +) => { + if (!encryptionKey) { + throw new MetaStorageError(MetaStorageErrorType.ENCRYPTION_KEY_REQUIRED) + } + + const encryptedArrayBuffer = await axios.get(url, { + responseType: 'arraybuffer' + }) + + // Verify hash + const parts = url.split('/') + const urlHash = parts[parts.length - 1] + const hash = await getHash(encryptedArrayBuffer.data) + if (hash !== urlHash) { + throw new MetaStorageError(MetaStorageErrorType.HASH_VERIFICATION_FAILED) + } + + const arrayBuffer = await decryptArrayBuffer( + encryptedArrayBuffer.data, + encryptionKey + ).catch((err) => { + throw new MetaStorageError(MetaStorageErrorType.DECRYPTION_FAILED, { + cause: err + }) + }) + + if (arrayBuffer) { + // Decode meta.json and parse + const decoder = new TextDecoder() + const json = decoder.decode(arrayBuffer) + const meta = await parseJson(json) + return meta + } + + throw new MetaStorageError(MetaStorageErrorType.UNHANDLED_FETCH_ERROR) +} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index ec8c97e..ed51fac 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -29,12 +29,20 @@ import { } from '../store/actions' import { Keys } from '../store/auth/types' import store from '../store/store' -import { Meta, ProfileMetadata, SignedEvent, UserAppData } from '../types' +import { + isSigitNotification, + Meta, + ProfileMetadata, + SigitNotification, + SignedEvent, + UserAppData +} from '../types' import { getDefaultRelayMap } from './relays' import { parseJson, removeLeadingSlash } from './string' import { timeout } from './utils' import { getHash } from './hash' import { SIGIT_BLOSSOM } from './const.ts' +import { fetchMetaFromFileStorage } from './meta.ts' /** * Generates a `d` tag for userAppData @@ -908,17 +916,44 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => { if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return - const meta = await parseJson(internalUnsignedEvent.content).catch( - (err) => { - console.log( - 'An error occurred in parsing the internal unsigned event', - err - ) - return null - } - ) + const parsedContent = await parseJson( + internalUnsignedEvent.content + ).catch((err) => { + console.log('An error occurred in parsing the internal unsigned event', err) + return null + }) - if (!meta) return + if (!parsedContent) return + let meta: Meta + if (isSigitNotification(parsedContent)) { + const notification = parsedContent + let encryptionKey: string | undefined + if (!notification.keys) return + + const { sender, keys } = notification.keys + + // Retrieve the user's public key from the state + const usersPubkey = store.getState().auth.usersPubkey! + const usersNpub = hexToNpub(usersPubkey) + + // Check if the user's public key is in the keys object + if (usersNpub in keys) { + // Instantiate the NostrController to decrypt the encryption key + const nostrController = NostrController.getInstance() + const decrypted = await nostrController + .nip04Decrypt(sender, keys[usersNpub]) + .catch((err) => { + console.log('An error occurred in decrypting encryption key', err) + return undefined + }) + + encryptionKey = decrypted + } + + meta = await fetchMetaFromFileStorage(notification.metaUrl, encryptionKey) + } else { + meta = parsedContent + } await updateUsersAppData(meta) } @@ -926,9 +961,12 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => { /** * Function to send a notification to a specified receiver. * @param receiver - The recipient's public key. - * @param meta - Metadata associated with the notification. + * @param notification - Url pointing to metadata associated with the notification on blossom and keys to decrypt. */ -export const sendNotification = async (receiver: string, meta: Meta) => { +export const sendNotification = async ( + receiver: string, + notification: SigitNotification +) => { // Retrieve the user's public key from the state const usersPubkey = store.getState().auth.usersPubkey! @@ -936,7 +974,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => { const unsignedEvent: UnsignedEvent = { kind: 938, pubkey: usersPubkey, - content: JSON.stringify(meta), + content: JSON.stringify(notification), tags: [], created_at: unixNow() } -- 2.34.1 From 7007492a0d1e9d21f505a300aa6b2ca24cf0b585 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 13 Dec 2024 12:39:00 +0100 Subject: [PATCH 2/5] feat(meta): add error handling for meta.json blossom operations --- src/pages/create/index.tsx | 4 ++-- src/pages/sign/index.tsx | 12 +++++++++++- src/utils/nostr.ts | 8 ++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index f0c1f0c..63140bf 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -868,11 +868,11 @@ export const CreatePage = () => { setLoadingSpinnerDesc('Updating user app data') - const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) - const event = await updateUsersAppData(meta) if (!event) return + const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + setLoadingSpinnerDesc('Sending notifications to counterparties') const promises = sendNotifications({ metaUrl, diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 346e226..01d5738 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -753,7 +753,17 @@ export const SignPage = () => { return } - const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + let metaUrl: string | undefined + try { + metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + } catch (error) { + if (error instanceof Error) { + toast.error(error.message) + } + console.error(error) + setIsLoading(false) + return + } const userSet = new Set<`npub1${string}`>() if (submittedBy && submittedBy !== usersPubkey) { diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index ed51fac..0ed4054 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -949,8 +949,12 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => { encryptionKey = decrypted } - - meta = await fetchMetaFromFileStorage(notification.metaUrl, encryptionKey) + try { + meta = await fetchMetaFromFileStorage(notification.metaUrl, encryptionKey) + } catch (error) { + console.error(`An error occured fetching meta file from storage`, error) + return + } } else { meta = parsedContent } -- 2.34.1 From e1e5ae7f1aaa55d9ecd9568bbcb8515f9b3e1d4d Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 13 Dec 2024 12:59:50 +0100 Subject: [PATCH 3/5] build(vulnerabilities): bump dependencies with audit fix --- package-lock.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index ebeec88..9e14451 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3587,10 +3587,11 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -6254,9 +6255,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "dev": true, "funding": [ { @@ -6264,6 +6265,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, -- 2.34.1 From 2a23912c08cf1d7f2665e7b4a74179597cabe957 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 13 Dec 2024 13:36:44 +0100 Subject: [PATCH 4/5] refactor(sign): autoFocus sign button, use mui/button for focus ripple effect --- src/components/MarkFormField/index.tsx | 15 ++++++++++----- src/components/MarkFormField/style.module.scss | 3 ++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/MarkFormField/index.tsx b/src/components/MarkFormField/index.tsx index 718a119..5f49d27 100644 --- a/src/components/MarkFormField/index.tsx +++ b/src/components/MarkFormField/index.tsx @@ -1,5 +1,4 @@ import { CurrentUserMark } from '../../types/mark.ts' -import styles from './style.module.scss' import { findNextIncompleteCurrentUserMark, getToolboxLabelByMarkType, @@ -10,6 +9,8 @@ import React, { useState } from 'react' import { MarkInput } from '../MarkTypeStrategy/MarkInput.tsx' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCheck } from '@fortawesome/free-solid-svg-icons' +import { Button } from '@mui/material' +import styles from './style.module.scss' interface MarkFormFieldProps { currentUserMarks: CurrentUserMark[] @@ -123,22 +124,23 @@ const MarkFormField = ({ userMark={selectedMark} />
- +
)} {complete && (
- +
)} @@ -148,6 +150,7 @@ const MarkFormField = ({ return (