From 40e03c1ed12897d5c43c00a5a124e07cdfb6ced6 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 19 Jan 2024 12:29:07 +0100 Subject: [PATCH] document title permissions --- .../CreateWorkspaceForm.tsx | 3 ++ apps/app/components/page/Page.tsx | 53 ++++++++++++++++--- .../sidebarFolder/SidebarFolder.tsx | 6 +++ apps/app/generated/graphql.ts | 23 ++++++-- .../mutations/updateDocumentName.graphql | 3 ++ apps/app/graphql/queries/document.graphql | 3 ++ apps/app/graphql/queries/documents.graphql | 3 ++ apps/app/store/documentStore.ts | 27 ++++++++++ apps/app/utils/document/updateDocumentName.ts | 11 ++++ .../migration.sql | 10 ++++ .../migration.sql | 11 ++++ apps/backend/prisma/schema.prisma | 43 ++++++++------- apps/backend/src/database/createSnapshot.ts | 36 +++++++++++++ .../src/database/document/createDocument.ts | 31 +++++++++++ .../database/document/updateDocumentName.ts | 33 ++++++++++++ .../testHelpers/createUserWithWorkspace.ts | 18 +++++-- .../createInitialWorkspaceStructure.ts | 17 ++++++ .../mutations/document/createDocument.ts | 21 +++++++- .../document/updateDocumentName.test.ts | 14 +++++ .../mutations/document/updateDocumentName.ts | 18 +++++++ .../createInitialWorkspaceStructure.test.ts | 12 +++++ .../createInitialWorkspaceStructure.ts | 1 + .../graphql/queries/document/document.test.ts | 14 +++++ apps/backend/src/graphql/types/document.ts | 3 ++ .../test/helpers/document/createDocument.ts | 17 ++++++ .../test/helpers/document/getDocument.ts | 3 ++ .../helpers/document/updateDocumentName.ts | 29 ++++++++++ .../createInitialWorkspaceStructure.ts | 25 +++++---- .../decryptDocumentTitle.ts | 14 +++++ .../decryptDocumentTitleBasedOnSnapshotKey.ts | 33 +++++++++++- .../encryptDocumentTitle.ts | 44 +++++++++++++-- packages/common/src/index.ts | 1 + .../verifyDocumentNameSignature.ts | 27 ++++++++++ packages/common/src/zodTypes.ts | 2 + 34 files changed, 560 insertions(+), 49 deletions(-) create mode 100644 apps/backend/prisma/migrations/20240119110115_document_name_permissions/migration.sql create mode 100644 apps/backend/prisma/migrations/20240119162434_add_creator_device/migration.sql create mode 100644 packages/common/src/verifyDocumentNameSignature/verifyDocumentNameSignature.ts diff --git a/apps/app/components/createWorkspaceForm/CreateWorkspaceForm.tsx b/apps/app/components/createWorkspaceForm/CreateWorkspaceForm.tsx index 51b3d90fc..6da2de774 100644 --- a/apps/app/components/createWorkspaceForm/CreateWorkspaceForm.tsx +++ b/apps/app/components/createWorkspaceForm/CreateWorkspaceForm.tsx @@ -218,6 +218,8 @@ export function CreateWorkspaceForm(props: CreateWorkspaceFormProps) { }, workspaceId: event.transaction.id, workspaceKeyId, + workspaceMemberDevicesProof, + documentId: createDocumentChainEvent.transaction.id, }); const encryptedWorkspaceInfo = encryptWorkspaceInfo({ @@ -253,6 +255,7 @@ export function CreateWorkspaceForm(props: CreateWorkspaceFormProps) { document: { nameCiphertext: encryptedDocumentTitle.ciphertext, nameNonce: encryptedDocumentTitle.nonce, + nameSignature: encryptedDocumentTitle.signature, subkeyId: encryptedDocumentTitle.subkeyId, snapshot, serializedDocumentChainEvent: JSON.stringify( diff --git a/apps/app/components/page/Page.tsx b/apps/app/components/page/Page.tsx index 90af18560..41a78f0b2 100644 --- a/apps/app/components/page/Page.tsx +++ b/apps/app/components/page/Page.tsx @@ -175,15 +175,17 @@ export default function Page({ snapshotId, activeDevice, }); + + const workspaceMemberDevicesProof = + await loadRemoteWorkspaceMemberDevicesProofQuery({ workspaceId }); + snapshotInFlightDataRef.current = { snapshotKey: { keyDerivationTrace: snapshotKeyData.keyDerivationTrace, key: sodium.from_base64(snapshotKeyData.key), }, documentChainState: latestDocumentChainState, - workspaceMemberDevicesProofEntry: getLastWorkspaceMemberDevicesProof({ - workspaceId, - }), + workspaceMemberDevicesProofEntry: workspaceMemberDevicesProof, }; const workspace = await getWorkspace({ @@ -195,16 +197,44 @@ export default function Page({ throw new Error("Workspace or workspaceKeys not found"); } + const documentTitleWorkspaceMemberDevicesProof = + await getLocalOrLoadRemoteWorkspaceMemberDevicesProofQueryByHash({ + workspaceId, + hash: document.nameWorkspaceMemberDevicesProofHash, + }); + if (!documentTitleWorkspaceMemberDevicesProof) { + throw new Error( + "documentTitleWorkspaceMemberDevicesProof not found for document" + ); + } + + const isValid = isValidDeviceSigningPublicKey({ + signingPublicKey: document.nameCreatorDeviceSigningPublicKey, + workspaceMemberDevicesProofEntry: + documentTitleWorkspaceMemberDevicesProof, + workspaceId, + minimumRole: "EDITOR", + }); + if (!isValid) { + throw new Error( + "Invalid signing public key for the workspaceMemberDevicesProof for decryptDocumentTitleBasedOnSnapshotKey" + ); + } + const documentTitle = decryptDocumentTitleBasedOnSnapshotKey({ snapshotKey: sodium.to_base64(snapshotKeyRef.current!.key), ciphertext: document.nameCiphertext, nonce: document.nameNonce, subkeyId: document.subkeyId, + documentId: docId, + workspaceId, + workspaceMemberDevicesProof: + documentTitleWorkspaceMemberDevicesProof.proof, + signature: document.nameSignature, + creatorDeviceSigningPublicKey: + document.nameCreatorDeviceSigningPublicKey, }); - const workspaceMemberDevicesProof = - await loadRemoteWorkspaceMemberDevicesProofQuery({ workspaceId }); - const documentTitleData = encryptDocumentTitle({ title: documentTitle, activeDevice, @@ -214,6 +244,8 @@ export default function Page({ workspaceKeyBox: workspace.currentWorkspaceKey.workspaceKeyBox!, workspaceId, workspaceKeyId: workspace.currentWorkspaceKey.id, + workspaceMemberDevicesProof: workspaceMemberDevicesProof.proof, + documentId: docId, }); let documentShareLinkDeviceBoxes: DocumentShareLinkDeviceBox[] = []; @@ -257,7 +289,14 @@ export default function Page({ documentChainEventHash: latestDocumentChainState.eventHash, }, additionalServerData: { - documentTitleData, + documentTitleData: { + ciphertext: documentTitleData.ciphertext, + nonce: documentTitleData.nonce, + subkeyId: documentTitleData.subkeyId, + signature: documentTitleData.signature, + workspaceMemberDevicesProofHash: + workspaceMemberDevicesProof.proof.hash, + }, documentShareLinkDeviceBoxes, }, }; diff --git a/apps/app/components/sidebarFolder/SidebarFolder.tsx b/apps/app/components/sidebarFolder/SidebarFolder.tsx index a8ba2988b..96ba0cfb8 100644 --- a/apps/app/components/sidebarFolder/SidebarFolder.tsx +++ b/apps/app/components/sidebarFolder/SidebarFolder.tsx @@ -412,6 +412,7 @@ export default function SidebarFolder(props: Props) { signatureKeyPair, sodium ); + const documentNameData = encryptDocumentTitle({ title: documentName, activeDevice, @@ -421,12 +422,17 @@ export default function SidebarFolder(props: Props) { workspaceKeyBox: workspace.currentWorkspaceKey.workspaceKeyBox!, workspaceId: workspace.id, workspaceKeyId: workspace.currentWorkspaceKey.id, + documentId, + workspaceMemberDevicesProof: workspaceMemberDevicesProof.proof, }); const result = await runCreateDocumentMutation( { input: { nameCiphertext: documentNameData.ciphertext, nameNonce: documentNameData.nonce, + nameSignature: documentNameData.signature, + nameWorkspaceMemberDevicesProofHash: + workspaceMemberDevicesProof.proof.hash, subkeyId: documentNameData.subkeyId, workspaceId: props.workspaceId, parentFolderId: props.folderId, diff --git a/apps/app/generated/graphql.ts b/apps/app/generated/graphql.ts index edb698f77..6b28fadab 100644 --- a/apps/app/generated/graphql.ts +++ b/apps/app/generated/graphql.ts @@ -164,6 +164,8 @@ export type CreateCommentResult = { export type CreateDocumentInput = { nameCiphertext: Scalars['String']['input']; nameNonce: Scalars['String']['input']; + nameSignature: Scalars['String']['input']; + nameWorkspaceMemberDevicesProofHash: Scalars['String']['input']; parentFolderId: Scalars['String']['input']; serializedDocumentChainEvent: Scalars['String']['input']; snapshot: DocumentSnapshotInput; @@ -210,6 +212,7 @@ export type CreateFolderResult = { export type CreateInitialDocumentInput = { nameCiphertext: Scalars['String']['input']; nameNonce: Scalars['String']['input']; + nameSignature: Scalars['String']['input']; serializedDocumentChainEvent: Scalars['String']['input']; snapshot: DocumentSnapshotInput; subkeyId: Scalars['String']['input']; @@ -371,7 +374,10 @@ export type Document = { __typename?: 'Document'; id: Scalars['String']['output']; nameCiphertext: Scalars['String']['output']; + nameCreatorDeviceSigningPublicKey: Scalars['String']['output']; nameNonce: Scalars['String']['output']; + nameSignature: Scalars['String']['output']; + nameWorkspaceMemberDevicesProofHash: Scalars['String']['output']; parentFolderId?: Maybe; rootFolderId?: Maybe; subkeyId: Scalars['String']['output']; @@ -1192,6 +1198,8 @@ export type UpdateDocumentNameInput = { id: Scalars['String']['input']; nameCiphertext: Scalars['String']['input']; nameNonce: Scalars['String']['input']; + nameSignature: Scalars['String']['input']; + nameWorkspaceMemberDevicesProofHash: Scalars['String']['input']; subkeyId: Scalars['String']['input']; workspaceKeyId: Scalars['String']['input']; }; @@ -1691,7 +1699,7 @@ export type UpdateDocumentNameMutationVariables = Exact<{ }>; -export type UpdateDocumentNameMutation = { __typename?: 'Mutation', updateDocumentName?: { __typename?: 'UpdateDocumentNameResult', document?: { __typename?: 'Document', id: string, nameCiphertext: string, nameNonce: string, parentFolderId?: string | null, workspaceId: string, subkeyId: string } | null } | null }; +export type UpdateDocumentNameMutation = { __typename?: 'Mutation', updateDocumentName?: { __typename?: 'UpdateDocumentNameResult', document?: { __typename?: 'Document', id: string, nameCiphertext: string, nameNonce: string, nameSignature: string, nameWorkspaceMemberDevicesProofHash: string, nameCreatorDeviceSigningPublicKey: string, parentFolderId?: string | null, workspaceId: string, subkeyId: string } | null } | null }; export type UpdateFolderNameMutationVariables = Exact<{ input: UpdateFolderNameInput; @@ -1745,7 +1753,7 @@ export type DocumentQueryVariables = Exact<{ }>; -export type DocumentQuery = { __typename?: 'Query', document?: { __typename?: 'Document', id: string, nameCiphertext: string, nameNonce: string, parentFolderId?: string | null, workspaceId: string, subkeyId: string } | null }; +export type DocumentQuery = { __typename?: 'Query', document?: { __typename?: 'Document', id: string, nameCiphertext: string, nameNonce: string, nameSignature: string, nameWorkspaceMemberDevicesProofHash: string, nameCreatorDeviceSigningPublicKey: string, parentFolderId?: string | null, workspaceId: string, subkeyId: string } | null }; export type DocumentChainQueryVariables = Exact<{ documentId: Scalars['ID']['input']; @@ -1793,7 +1801,7 @@ export type DocumentsQueryVariables = Exact<{ }>; -export type DocumentsQuery = { __typename?: 'Query', documents?: { __typename?: 'DocumentConnection', nodes?: Array<{ __typename?: 'Document', id: string, nameCiphertext: string, nameNonce: string, parentFolderId?: string | null, rootFolderId?: string | null, workspaceId: string, subkeyId: string } | null> | null, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } | null }; +export type DocumentsQuery = { __typename?: 'Query', documents?: { __typename?: 'DocumentConnection', nodes?: Array<{ __typename?: 'Document', id: string, nameCiphertext: string, nameNonce: string, nameSignature: string, nameWorkspaceMemberDevicesProofHash: string, nameCreatorDeviceSigningPublicKey: string, parentFolderId?: string | null, rootFolderId?: string | null, workspaceId: string, subkeyId: string } | null> | null, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } | null }; export type EncryptedWebDeviceQueryVariables = Exact<{ accessToken: Scalars['String']['input']; @@ -2416,6 +2424,9 @@ export const UpdateDocumentNameDocument = gql` id nameCiphertext nameNonce + nameSignature + nameWorkspaceMemberDevicesProofHash + nameCreatorDeviceSigningPublicKey parentFolderId workspaceId subkeyId @@ -2570,6 +2581,9 @@ export const DocumentDocument = gql` id nameCiphertext nameNonce + nameSignature + nameWorkspaceMemberDevicesProofHash + nameCreatorDeviceSigningPublicKey parentFolderId workspaceId subkeyId @@ -2691,6 +2705,9 @@ export const DocumentsDocument = gql` id nameCiphertext nameNonce + nameSignature + nameWorkspaceMemberDevicesProofHash + nameCreatorDeviceSigningPublicKey parentFolderId rootFolderId workspaceId diff --git a/apps/app/graphql/mutations/updateDocumentName.graphql b/apps/app/graphql/mutations/updateDocumentName.graphql index 53c9c0d3e..3a7a10e86 100644 --- a/apps/app/graphql/mutations/updateDocumentName.graphql +++ b/apps/app/graphql/mutations/updateDocumentName.graphql @@ -4,6 +4,9 @@ mutation updateDocumentName($input: UpdateDocumentNameInput!) { id nameCiphertext nameNonce + nameSignature + nameWorkspaceMemberDevicesProofHash + nameCreatorDeviceSigningPublicKey parentFolderId workspaceId subkeyId diff --git a/apps/app/graphql/queries/document.graphql b/apps/app/graphql/queries/document.graphql index fb69ca6ac..c7483ce55 100644 --- a/apps/app/graphql/queries/document.graphql +++ b/apps/app/graphql/queries/document.graphql @@ -3,6 +3,9 @@ query document($id: ID!) { id nameCiphertext nameNonce + nameSignature + nameWorkspaceMemberDevicesProofHash + nameCreatorDeviceSigningPublicKey parentFolderId workspaceId subkeyId diff --git a/apps/app/graphql/queries/documents.graphql b/apps/app/graphql/queries/documents.graphql index 06e32c363..ba7f85857 100644 --- a/apps/app/graphql/queries/documents.graphql +++ b/apps/app/graphql/queries/documents.graphql @@ -4,6 +4,9 @@ query documents($parentFolderId: ID!, $first: Int! = 100, $after: String) { id nameCiphertext nameNonce + nameSignature + nameWorkspaceMemberDevicesProofHash + nameCreatorDeviceSigningPublicKey parentFolderId rootFolderId workspaceId diff --git a/apps/app/store/documentStore.ts b/apps/app/store/documentStore.ts index 919c91215..fcf852ff2 100644 --- a/apps/app/store/documentStore.ts +++ b/apps/app/store/documentStore.ts @@ -9,7 +9,9 @@ import { runSnapshotQuery, runWorkspaceQuery, } from "../generated/graphql"; +import { isValidDeviceSigningPublicKey } from "../utils/isValidDeviceSigningPublicKey/isValidDeviceSigningPublicKey"; import * as sql from "./sql/sql"; +import { getLocalOrLoadRemoteWorkspaceMemberDevicesProofQueryByHash } from "./workspaceMemberDevicesProofStore"; export const table = "document_v2"; @@ -162,6 +164,27 @@ export const loadRemoteDocumentName = async ({ } } + const workspaceMemberDevicesProof = + await getLocalOrLoadRemoteWorkspaceMemberDevicesProofQueryByHash({ + workspaceId, + hash: document.nameWorkspaceMemberDevicesProofHash, + }); + if (!workspaceMemberDevicesProof) { + throw new Error("workspaceMemberDevicesProof not found"); + } + + const isValid = isValidDeviceSigningPublicKey({ + signingPublicKey: document.nameCreatorDeviceSigningPublicKey, + workspaceMemberDevicesProofEntry: workspaceMemberDevicesProof, + workspaceId, + minimumRole: "EDITOR", + }); + if (!isValid) { + throw new Error( + "Invalid signing public key for the workspaceMemberDevicesProof for decryptDocumentTitle" + ); + } + name = decryptDocumentTitle({ ciphertext: document.nameCiphertext, nonce: document.nameNonce, @@ -173,6 +196,10 @@ export const loadRemoteDocumentName = async ({ workspaceKeyBox: documentWorkspaceKey.workspaceKeyBox, workspaceId, workspaceKeyId: documentWorkspaceKey.id, + documentId, + workspaceMemberDevicesProof: workspaceMemberDevicesProof.proof, + signature: document.nameSignature, + creatorDeviceSigningPublicKey: document.nameCreatorDeviceSigningPublicKey, }); createOrReplaceDocument({ documentId, name }); } diff --git a/apps/app/utils/document/updateDocumentName.ts b/apps/app/utils/document/updateDocumentName.ts index 638c5295d..bde521d56 100644 --- a/apps/app/utils/document/updateDocumentName.ts +++ b/apps/app/utils/document/updateDocumentName.ts @@ -4,6 +4,7 @@ import { runSnapshotQuery, runUpdateDocumentNameMutation, } from "../../generated/graphql"; +import { loadRemoteWorkspaceMemberDevicesProofQuery } from "../../store/workspaceMemberDevicesProofStore"; import { getWorkspace } from "../workspace/getWorkspace"; export type Props = { @@ -32,6 +33,11 @@ export const updateDocumentName = async ({ throw new Error(snapshotResult.error?.message || "Could not get snapshot"); } + const workspaceMemberDevicesProof = + await loadRemoteWorkspaceMemberDevicesProofQuery({ + workspaceId, + }); + const encryptedDocumentTitle = encryptDocumentTitle({ title: name, activeDevice, @@ -39,6 +45,8 @@ export const updateDocumentName = async ({ snapshot: snapshotResult.data.snapshot, workspaceId, workspaceKeyId: workspace.currentWorkspaceKey.id, + documentId, + workspaceMemberDevicesProof: workspaceMemberDevicesProof.proof, }); const updateDocumentNameResult = await runUpdateDocumentNameMutation( { @@ -48,6 +56,9 @@ export const updateDocumentName = async ({ nameNonce: encryptedDocumentTitle.nonce, workspaceKeyId: workspace?.currentWorkspaceKey?.id!, subkeyId: encryptedDocumentTitle.subkeyId, + nameSignature: encryptedDocumentTitle.signature, + nameWorkspaceMemberDevicesProofHash: + workspaceMemberDevicesProof.proof.hash, }, }, {} diff --git a/apps/backend/prisma/migrations/20240119110115_document_name_permissions/migration.sql b/apps/backend/prisma/migrations/20240119110115_document_name_permissions/migration.sql new file mode 100644 index 000000000..67b0477bb --- /dev/null +++ b/apps/backend/prisma/migrations/20240119110115_document_name_permissions/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - Added the required column `nameSignature` to the `Document` table without a default value. This is not possible if the table is not empty. + - Added the required column `nameWorkspaceMemberDevicesProofHash` to the `Document` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "nameSignature" TEXT NOT NULL, +ADD COLUMN "nameWorkspaceMemberDevicesProofHash" TEXT NOT NULL; diff --git a/apps/backend/prisma/migrations/20240119162434_add_creator_device/migration.sql b/apps/backend/prisma/migrations/20240119162434_add_creator_device/migration.sql new file mode 100644 index 000000000..d7139dcf1 --- /dev/null +++ b/apps/backend/prisma/migrations/20240119162434_add_creator_device/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `nameCreatorDeviceSigningPublicKey` to the `Document` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "nameCreatorDeviceSigningPublicKey" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_nameCreatorDeviceSigningPublicKey_fkey" FOREIGN KEY ("nameCreatorDeviceSigningPublicKey") REFERENCES "CreatorDevice"("signingPublicKey") ON DELETE SET DEFAULT ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index d0d50ce2c..06d6ae314 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -23,27 +23,31 @@ model DocumentChainEvent { } model Document { - id String @id + id String @id // can be optional because a document is created without a snapshot at first // TODO check if this is still true since on workspace and document create a snapshot is provided - activeSnapshot Snapshot? @relation(name: "activeSnapshot", fields: [activeSnapshotId], references: [id], onDelete: Cascade) - activeSnapshotId String? @unique - snapshots Snapshot[] - createdAt DateTime @default(now()) - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - workspaceId String - workspaceKey WorkspaceKey? @relation(fields: [workspaceKeyId], references: [id], onDelete: SetNull) - workspaceKeyId String? - parentFolder Folder @relation(fields: [parentFolderId], references: [id], onDelete: Cascade) - parentFolderId String - nameCiphertext String - nameNonce String - subkeyId String - documentShareLinks DocumentShareLink[] - requiresSnapshot Boolean @default(false) - comments Comment[] - commentReplies CommentReply[] - chain DocumentChainEvent[] + activeSnapshot Snapshot? @relation(name: "activeSnapshot", fields: [activeSnapshotId], references: [id], onDelete: Cascade) + activeSnapshotId String? @unique + snapshots Snapshot[] + createdAt DateTime @default(now()) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspaceId String + workspaceKey WorkspaceKey? @relation(fields: [workspaceKeyId], references: [id], onDelete: SetNull) + workspaceKeyId String? + parentFolder Folder @relation(fields: [parentFolderId], references: [id], onDelete: Cascade) + parentFolderId String + nameCiphertext String + nameNonce String + nameSignature String + nameWorkspaceMemberDevicesProofHash String + nameCreatorDeviceSigningPublicKey String + nameCreatorDevice CreatorDevice @relation(fields: [nameCreatorDeviceSigningPublicKey], references: [signingPublicKey], onDelete: SetDefault) + subkeyId String + documentShareLinks DocumentShareLink[] + requiresSnapshot Boolean @default(false) + comments Comment[] + commentReplies CommentReply[] + chain DocumentChainEvent[] @@unique([subkeyId, workspaceId]) } @@ -211,6 +215,7 @@ model CreatorDevice { commentReplies CommentReply[] Folder Folder[] Workspace Workspace[] + Document Document[] } model Workspace { diff --git a/apps/backend/src/database/createSnapshot.ts b/apps/backend/src/database/createSnapshot.ts index cbf15725b..d3257cba7 100644 --- a/apps/backend/src/database/createSnapshot.ts +++ b/apps/backend/src/database/createSnapshot.ts @@ -12,14 +12,18 @@ import { compareUpdateClocks, } from "@serenity-tools/secsync"; import { Prisma } from "../../prisma/generated/output"; +import { getOrCreateCreatorDevice } from "../utils/device/getOrCreateCreatorDevice"; import { serializeSnapshot } from "../utils/serialize"; import { prisma } from "./prisma"; +import { getWorkspaceMemberDevicesProof } from "./workspace/getWorkspaceMemberDevicesProof"; export type CreateSnapshotDocumentTitleData = { ciphertext: string; nonce: string; workspaceKeyId: string; subkeyId: string; + signature: string; + workspaceMemberDevicesProofHash: string; }; type Params = { @@ -124,11 +128,43 @@ export async function createSnapshot({ } if (documentTitleData) { + const device = await prisma.device.findUniqueOrThrow({ + where: { signingPublicKey: snapshot.publicData.pubKey }, + select: { userId: true }, + }); + if (!device.userId) { + throw new Error("Device has no userId"); + } + + const workspaceMemberDevicesProof = await getWorkspaceMemberDevicesProof({ + workspaceId: document.workspaceId, + userId: device.userId, + }); + if ( + workspaceMemberDevicesProof.proof.hash !== + documentTitleData.workspaceMemberDevicesProofHash + ) { + throw new Error( + "Outdated workspace member devices proof hash for updating the document name on snapshot creation" + ); + } + + // convert the user's device into a creatorDevice + const creatorDevice = await getOrCreateCreatorDevice({ + prisma, + signingPublicKey: snapshot.publicData.pubKey, + userId: device.userId, + }); + await prisma.document.update({ where: { id: snapshot.publicData.docId }, data: { nameCiphertext: documentTitleData.ciphertext, nameNonce: documentTitleData.nonce, + nameSignature: documentTitleData.signature, + nameWorkspaceMemberDevicesProofHash: + documentTitleData.workspaceMemberDevicesProofHash, + nameCreatorDeviceSigningPublicKey: creatorDevice.signingPublicKey, workspaceKeyId: documentTitleData.workspaceKeyId, subkeyId: documentTitleData.subkeyId, }, diff --git a/apps/backend/src/database/document/createDocument.ts b/apps/backend/src/database/document/createDocument.ts index 54a3b839a..dd61f07f8 100644 --- a/apps/backend/src/database/document/createDocument.ts +++ b/apps/backend/src/database/document/createDocument.ts @@ -2,13 +2,18 @@ import * as documentChain from "@serenity-kit/document-chain"; import { SerenitySnapshotWithClientData } from "@serenity-tools/common"; import { ForbiddenError } from "apollo-server-express"; import { Prisma, Role } from "../../../prisma/generated/output"; +import { getOrCreateCreatorDevice } from "../../utils/device/getOrCreateCreatorDevice"; import { createSnapshot } from "../createSnapshot"; import { prisma } from "../prisma"; +import { getWorkspaceMemberDevicesProof } from "../workspace/getWorkspaceMemberDevicesProof"; type Params = { userId: string; nameCiphertext: string; nameNonce: string; + nameSignature: string; + nameWorkspaceMemberDevicesProofHash: string; + nameCreatorDeviceSigningPublicKey: string; workspaceKeyId?: string | null; subkeyId: string; // name/title subkey id parentFolderId: string; @@ -21,6 +26,9 @@ export async function createDocument({ userId, nameCiphertext, nameNonce, + nameSignature, + nameWorkspaceMemberDevicesProofHash, + nameCreatorDeviceSigningPublicKey, workspaceKeyId, subkeyId, parentFolderId, @@ -65,11 +73,34 @@ export async function createDocument({ knownVersion: documentChain.version, }); + const workspaceMemberDevicesProof = await getWorkspaceMemberDevicesProof({ + workspaceId, + userId, + }); + if ( + workspaceMemberDevicesProof.proof.hash !== + nameWorkspaceMemberDevicesProofHash + ) { + throw new Error( + "Outdated workspace member devices proof hash for creating a document" + ); + } + + // convert the user's device into a creatorDevice + const creatorDevice = await getOrCreateCreatorDevice({ + prisma, + userId, + signingPublicKey: nameCreatorDeviceSigningPublicKey, + }); + const document = await prisma.document.create({ data: { id: documentChainState.currentState.id, nameCiphertext, nameNonce, + nameSignature, + nameWorkspaceMemberDevicesProofHash, + nameCreatorDeviceSigningPublicKey: creatorDevice.signingPublicKey, subkeyId, workspaceKeyId, parentFolderId, diff --git a/apps/backend/src/database/document/updateDocumentName.ts b/apps/backend/src/database/document/updateDocumentName.ts index 876151b33..96a3973e2 100644 --- a/apps/backend/src/database/document/updateDocumentName.ts +++ b/apps/backend/src/database/document/updateDocumentName.ts @@ -1,11 +1,16 @@ import { ForbiddenError } from "apollo-server-express"; import { Prisma, Role } from "../../../prisma/generated/output"; +import { getOrCreateCreatorDevice } from "../../utils/device/getOrCreateCreatorDevice"; import { prisma } from "../prisma"; +import { getWorkspaceMemberDevicesProof } from "../workspace/getWorkspaceMemberDevicesProof"; type Params = { id: string; nameCiphertext: string; nameNonce: string; + nameSignature: string; + nameWorkspaceMemberDevicesProofHash: string; + nameCreatorDeviceSigningPublicKey: string; workspaceKeyId: string; subkeyId: string; userId: string; @@ -15,6 +20,9 @@ export async function updateDocumentName({ id, nameCiphertext, nameNonce, + nameSignature, + nameWorkspaceMemberDevicesProofHash, + nameCreatorDeviceSigningPublicKey, workspaceKeyId, subkeyId, userId, @@ -44,11 +52,36 @@ export async function updateDocumentName({ ) { throw new ForbiddenError("Unauthorized"); } + + const workspaceMemberDevicesProof = + await getWorkspaceMemberDevicesProof({ + workspaceId: document.workspaceId, + userId, + }); + if ( + workspaceMemberDevicesProof.proof.hash !== + nameWorkspaceMemberDevicesProofHash + ) { + throw new Error( + "Outdated workspace member devices proof hash for updating the document name" + ); + } + + // convert the user's device into a creatorDevice + const creatorDevice = await getOrCreateCreatorDevice({ + prisma, + userId, + signingPublicKey: nameCreatorDeviceSigningPublicKey, + }); + const updatedDocument = await prisma.document.update({ where: { id }, data: { nameCiphertext, nameNonce, + nameSignature, + nameWorkspaceMemberDevicesProofHash, + nameCreatorDeviceSigningPublicKey: creatorDevice.signingPublicKey, workspaceKeyId, subkeyId, }, diff --git a/apps/backend/src/database/testHelpers/createUserWithWorkspace.ts b/apps/backend/src/database/testHelpers/createUserWithWorkspace.ts index 764f4cdc4..38da6fe41 100644 --- a/apps/backend/src/database/testHelpers/createUserWithWorkspace.ts +++ b/apps/backend/src/database/testHelpers/createUserWithWorkspace.ts @@ -188,11 +188,6 @@ export default async function createUserWithWorkspace({ snapshotKey: snapshotKey.key, }); const documentTitleKey = documentTitleKeyResult.key; - const encryptedDocumentTitleResult = encryptDocumentTitleByKey({ - title: documentName, - key: documentTitleKey, - }); - const snapshotId = generateId(); const createDocumentChainEvent = documentChain.createDocumentChain({ authorKeyPair: { @@ -200,6 +195,18 @@ export default async function createUserWithWorkspace({ publicKey: mainDevice.signingPublicKey, }, }); + + const encryptedDocumentTitleResult = encryptDocumentTitleByKey({ + title: documentName, + key: documentTitleKey, + documentId: createDocumentChainEvent.transaction.id, + workspaceId: createWorkspaceChainEvent.transaction.id, + workspaceMemberDevicesProof, + activeDevice: mainDevice, + }); + + const snapshotId = generateId(); + const documentChainState = documentChain.resolveState({ events: [createDocumentChainEvent], knownVersion: documentChain.version, @@ -277,6 +284,7 @@ export default async function createUserWithWorkspace({ document: { nameCiphertext: encryptedDocumentTitleResult.ciphertext, nameNonce: encryptedDocumentTitleResult.publicNonce, + nameSignature: encryptedDocumentTitleResult.signature, subkeyId: documentTitleKeyResult.subkeyId, // @ts-expect-error due the documentTitleData missing in additionalServerData snapshot, diff --git a/apps/backend/src/database/workspace/createInitialWorkspaceStructure.ts b/apps/backend/src/database/workspace/createInitialWorkspaceStructure.ts index a5f08981e..0fe9353df 100644 --- a/apps/backend/src/database/workspace/createInitialWorkspaceStructure.ts +++ b/apps/backend/src/database/workspace/createInitialWorkspaceStructure.ts @@ -4,6 +4,7 @@ import * as workspaceMemberDevicesProofUtil from "@serenity-kit/workspace-member import { KeyDerivationTrace, SerenitySnapshotWithClientData, + verifyDocumentNameSignature, verifyFolderNameSignature, } from "@serenity-tools/common"; import { verifyWorkspaceInfoSignature } from "@serenity-tools/common/src/verifyWorkspaceInfoSignature/verifyWorkspaceInfoSignature"; @@ -35,6 +36,7 @@ export type FolderParams = { export type DocumentParams = { nameCiphertext: string; nameNonce: string; + nameSignature: string; subkeyId: string; snapshot: SerenitySnapshotWithClientData; }; @@ -90,6 +92,18 @@ export async function createInitialWorkspaceStructure({ throw new Error("Invalid folder signature"); } + const isValidDocumentNameSignature = verifyDocumentNameSignature({ + authorSigningPublicKey: creatorDeviceSigningPublicKey, + ciphertext: document.nameCiphertext, + nonce: document.nameNonce, + signature: document.nameSignature, + }); + if (!isValidDocumentNameSignature) { + throw new Error( + "Invalid document name signature on createInitialWorkspaceStructure" + ); + } + const createdWorkspace = await createWorkspace({ id: workspace.id, infoCiphertext: workspace.infoCiphertext, @@ -123,6 +137,9 @@ export async function createInitialWorkspaceStructure({ userId, nameCiphertext: document.nameCiphertext, nameNonce: document.nameNonce, + nameSignature: document.nameSignature, + nameWorkspaceMemberDevicesProofHash: workspaceMemberDevicesProof.hash, + nameCreatorDeviceSigningPublicKey: creatorDeviceSigningPublicKey, workspaceKeyId: createdWorkspace.currentWorkspaceKey?.id, subkeyId: document.subkeyId, parentFolderId: folder.id, diff --git a/apps/backend/src/graphql/mutations/document/createDocument.ts b/apps/backend/src/graphql/mutations/document/createDocument.ts index 03f2a0024..b72372580 100644 --- a/apps/backend/src/graphql/mutations/document/createDocument.ts +++ b/apps/backend/src/graphql/mutations/document/createDocument.ts @@ -1,5 +1,8 @@ import * as documentChain from "@serenity-kit/document-chain"; -import { SerenitySnapshotWithClientData } from "@serenity-tools/common"; +import { + SerenitySnapshotWithClientData, + verifyDocumentNameSignature, +} from "@serenity-tools/common"; import { AuthenticationError, UserInputError } from "apollo-server-express"; import { arg, @@ -16,6 +19,8 @@ export const CreateDocumentInput = inputObjectType({ definition(t) { t.nonNull.string("nameCiphertext"); t.nonNull.string("nameNonce"); + t.nonNull.string("nameSignature"); + t.nonNull.string("nameWorkspaceMemberDevicesProofHash"); t.nonNull.string("subkeyId"); t.nonNull.string("parentFolderId"); t.nonNull.string("workspaceId"); @@ -48,6 +53,16 @@ export const createDocumentMutation = mutationField("createDocument", { throw new UserInputError("Invalid input: snapshotId cannot be null"); } + const isValid = verifyDocumentNameSignature({ + authorSigningPublicKey: context.session.deviceSigningPublicKey, + ciphertext: args.input.nameCiphertext, + nonce: args.input.nameNonce, + signature: args.input.nameSignature, + }); + if (!isValid) { + throw new Error("Invalid document name signature on document create"); + } + const documentChainEvent = documentChain.CreateDocumentChainEvent.parse( JSON.parse(args.input.serializedDocumentChainEvent) ); @@ -57,6 +72,10 @@ export const createDocumentMutation = mutationField("createDocument", { userId: context.user.id, nameCiphertext: args.input.nameCiphertext, nameNonce: args.input.nameNonce, + nameSignature: args.input.nameSignature, + nameWorkspaceMemberDevicesProofHash: + args.input.nameWorkspaceMemberDevicesProofHash, + nameCreatorDeviceSigningPublicKey: context.session.deviceSigningPublicKey, workspaceKeyId: null, subkeyId: args.input.subkeyId, parentFolderId: args.input.parentFolderId, diff --git a/apps/backend/src/graphql/mutations/document/updateDocumentName.test.ts b/apps/backend/src/graphql/mutations/document/updateDocumentName.test.ts index df5367773..56ee25480 100644 --- a/apps/backend/src/graphql/mutations/document/updateDocumentName.test.ts +++ b/apps/backend/src/graphql/mutations/document/updateDocumentName.test.ts @@ -17,6 +17,7 @@ import setupGraphql from "../../../../test/helpers/setupGraphql"; import { getSnapshot } from "../../../../test/helpers/snapshot/getSnapshot"; import { prisma } from "../../../database/prisma"; import createUserWithWorkspace from "../../../database/testHelpers/createUserWithWorkspace"; +import { getWorkspaceMemberDevicesProof } from "../../../database/workspace/getWorkspaceMemberDevicesProof"; const graphql = setupGraphql(); let userData1: any = undefined; @@ -103,11 +104,24 @@ test("user should be able to change a document name", async () => { expect(typeof updatedDocument.nameCiphertext).toBe("string"); expect(typeof updatedDocument.nameNonce).toBe("string"); + const documentNameWorkspaceMemberDevicesProof = + await getWorkspaceMemberDevicesProof({ + userId: userData1.user.id, + workspaceId: updatedDocument.workspaceId, + hash: updatedDocument.nameWorkspaceMemberDevicesProofHash, + }); + const decryptedName = decryptDocumentTitleBasedOnSnapshotKey({ snapshotKey, subkeyId: updatedDocument.subkeyId, ciphertext: updatedDocument.nameCiphertext, nonce: updatedDocument.nameNonce, + documentId: updatedDocument.id, + workspaceId: updatedDocument.workspaceId, + workspaceMemberDevicesProof: documentNameWorkspaceMemberDevicesProof.proof, + creatorDeviceSigningPublicKey: + updatedDocument.nameCreatorDeviceSigningPublicKey, + signature: updatedDocument.nameSignature, }); expect(decryptedName).toBe(name); }); diff --git a/apps/backend/src/graphql/mutations/document/updateDocumentName.ts b/apps/backend/src/graphql/mutations/document/updateDocumentName.ts index 66f2597ba..cb58d5d5d 100644 --- a/apps/backend/src/graphql/mutations/document/updateDocumentName.ts +++ b/apps/backend/src/graphql/mutations/document/updateDocumentName.ts @@ -1,3 +1,4 @@ +import { verifyDocumentNameSignature } from "@serenity-tools/common"; import { AuthenticationError } from "apollo-server-express"; import { arg, @@ -15,6 +16,8 @@ export const UpdateDocumentNameInput = inputObjectType({ t.nonNull.string("id"); t.nonNull.string("nameCiphertext"); t.nonNull.string("nameNonce"); + t.nonNull.string("nameSignature"); + t.nonNull.string("nameWorkspaceMemberDevicesProofHash"); t.nonNull.string("workspaceKeyId"); t.nonNull.string("subkeyId"); }, @@ -40,10 +43,25 @@ export const updateDocumentNameMutation = mutationField("updateDocumentName", { if (!context.user) { throw new AuthenticationError("Not authenticated"); } + + const isValid = verifyDocumentNameSignature({ + authorSigningPublicKey: context.session.deviceSigningPublicKey, + ciphertext: args.input.nameCiphertext, + nonce: args.input.nameNonce, + signature: args.input.nameSignature, + }); + if (!isValid) { + throw new Error("Invalid document name signature on update name"); + } + const document = await updateDocumentName({ id: args.input.id, nameCiphertext: args.input.nameCiphertext, nameNonce: args.input.nameNonce, + nameSignature: args.input.nameSignature, + nameWorkspaceMemberDevicesProofHash: + args.input.nameWorkspaceMemberDevicesProofHash, + nameCreatorDeviceSigningPublicKey: context.session.deviceSigningPublicKey, workspaceKeyId: args.input.workspaceKeyId, subkeyId: args.input.subkeyId, userId: context.user.id, diff --git a/apps/backend/src/graphql/mutations/workspace/createInitialWorkspaceStructure.test.ts b/apps/backend/src/graphql/mutations/workspace/createInitialWorkspaceStructure.test.ts index 1d5004dc8..6a361e9cd 100644 --- a/apps/backend/src/graphql/mutations/workspace/createInitialWorkspaceStructure.test.ts +++ b/apps/backend/src/graphql/mutations/workspace/createInitialWorkspaceStructure.test.ts @@ -128,11 +128,23 @@ test("create initial workspace structure", async () => { const snapshotKey = snapshotKeyTrace.trace[snapshotKeyTrace.trace.length - 1].key; + const documentNameWorkspaceMemberDevicesProof = + await getWorkspaceMemberDevicesProof({ + userId: userData1.userId, + workspaceId: workspace.id, + hash: document.nameWorkspaceMemberDevicesProofHash, + }); + const decryptedDocumentName = decryptDocumentTitleBasedOnSnapshotKey({ snapshotKey, subkeyId: document.subkeyId, ciphertext: document.nameCiphertext, nonce: document.nameNonce, + documentId: document.id, + workspaceId: document.workspaceId, + workspaceMemberDevicesProof: documentNameWorkspaceMemberDevicesProof.proof, + signature: document.nameSignature, + creatorDeviceSigningPublicKey: document.nameCreatorDeviceSigningPublicKey, }); expect(decryptedDocumentName).toBe("Introduction"); }); diff --git a/apps/backend/src/graphql/mutations/workspace/createInitialWorkspaceStructure.ts b/apps/backend/src/graphql/mutations/workspace/createInitialWorkspaceStructure.ts index 0b465b402..0f34268c2 100644 --- a/apps/backend/src/graphql/mutations/workspace/createInitialWorkspaceStructure.ts +++ b/apps/backend/src/graphql/mutations/workspace/createInitialWorkspaceStructure.ts @@ -55,6 +55,7 @@ export const CreateInitialDocumentInput = inputObjectType({ definition(t) { t.nonNull.string("nameCiphertext"); t.nonNull.string("nameNonce"); + t.nonNull.string("nameSignature"); t.nonNull.string("subkeyId"); t.nonNull.field("snapshot", { type: DocumentSnapshotInput }); t.nonNull.string("serializedDocumentChainEvent"); diff --git a/apps/backend/src/graphql/queries/document/document.test.ts b/apps/backend/src/graphql/queries/document/document.test.ts index 7043e8111..c20b99d1e 100644 --- a/apps/backend/src/graphql/queries/document/document.test.ts +++ b/apps/backend/src/graphql/queries/document/document.test.ts @@ -12,6 +12,7 @@ import { updateDocumentName } from "../../../../test/helpers/document/updateDocu import setupGraphql from "../../../../test/helpers/setupGraphql"; import { getSnapshot } from "../../../../test/helpers/snapshot/getSnapshot"; import createUserWithWorkspace from "../../../database/testHelpers/createUserWithWorkspace"; +import { getWorkspaceMemberDevicesProof } from "../../../database/workspace/getWorkspaceMemberDevicesProof"; const graphql = setupGraphql(); let userData1: any = null; @@ -101,11 +102,24 @@ test("user should be retrieve a document", async () => { const snapshotKey = snapshotKeyTrace.trace[snapshotKeyTrace.trace.length - 1].key; + const documentNameWorkspaceMemberDevicesProof = + await getWorkspaceMemberDevicesProof({ + userId: userData1.user.id, + workspaceId: userData1.workspace.id, + hash: retrievedDocument.nameWorkspaceMemberDevicesProofHash, + }); + const decryptedName = decryptDocumentTitleBasedOnSnapshotKey({ ciphertext: retrievedDocument.nameCiphertext, nonce: retrievedDocument.nameNonce, snapshotKey, subkeyId: retrievedDocument.subkeyId, + documentId: retrievedDocument.id, + workspaceId: retrievedDocument.workspaceId, + workspaceMemberDevicesProof: documentNameWorkspaceMemberDevicesProof.proof, + signature: retrievedDocument.nameSignature, + creatorDeviceSigningPublicKey: + retrievedDocument.nameCreatorDeviceSigningPublicKey, }); expect(decryptedName).toBe(documentName); }); diff --git a/apps/backend/src/graphql/types/document.ts b/apps/backend/src/graphql/types/document.ts index d7a0ea705..b13239bba 100644 --- a/apps/backend/src/graphql/types/document.ts +++ b/apps/backend/src/graphql/types/document.ts @@ -9,6 +9,9 @@ export const Document = objectType({ t.nonNull.string("id"); t.nonNull.string("nameCiphertext"); t.nonNull.string("nameNonce"); + t.nonNull.string("nameSignature"); + t.nonNull.string("nameWorkspaceMemberDevicesProofHash"); + t.nonNull.string("nameCreatorDeviceSigningPublicKey"); t.nonNull.string("subkeyId"); t.string("parentFolderId"); t.string("rootFolderId"); diff --git a/apps/backend/test/helpers/document/createDocument.ts b/apps/backend/test/helpers/document/createDocument.ts index e37e677fb..7ab9eec76 100644 --- a/apps/backend/test/helpers/document/createDocument.ts +++ b/apps/backend/test/helpers/document/createDocument.ts @@ -26,6 +26,8 @@ type RunCreateDocumentMutationParams = { graphql: any; nameCiphertext: string; nameNonce: string; + nameSignature: string; + nameWorkspaceMemberDevicesProofHash: string; subkeyId: string; parentFolderId: string | null; workspaceId: string; @@ -37,6 +39,8 @@ const runCreateDocumentMutation = async ({ graphql, nameCiphertext, nameNonce, + nameSignature, + nameWorkspaceMemberDevicesProofHash, subkeyId, parentFolderId, workspaceId, @@ -60,6 +64,8 @@ const runCreateDocumentMutation = async ({ input: { nameCiphertext, nameNonce, + nameSignature, + nameWorkspaceMemberDevicesProofHash, subkeyId, parentFolderId, workspaceId, @@ -126,6 +132,8 @@ export const createDocument = async ({ graphql, nameCiphertext: "", nameNonce: "", + nameSignature: "", + nameWorkspaceMemberDevicesProofHash: "", subkeyId: "AAAAAAAAAAAAAAAAAAAAAA", parentFolderId, workspaceId, @@ -148,6 +156,8 @@ export const createDocument = async ({ graphql, nameCiphertext: "", nameNonce: "", + nameSignature: "", + nameWorkspaceMemberDevicesProofHash: "", subkeyId: "AAAAAAAAAAAAAAAAAAAAAA", parentFolderId, workspaceId, @@ -222,12 +232,19 @@ export const createDocument = async ({ const documentNameData = encryptDocumentTitleByKey({ title: useName, key: documentNameKey.key, + documentId: id, + workspaceId, + workspaceMemberDevicesProof: workspaceMemberDevicesProofEntry.proof, + activeDevice, }); return runCreateDocumentMutation({ graphql, nameCiphertext: documentNameData.ciphertext, nameNonce: documentNameData.publicNonce, + nameSignature: documentNameData.signature, + nameWorkspaceMemberDevicesProofHash: + workspaceMemberDevicesProofEntry.proof.hash, subkeyId: documentNameKey.subkeyId, parentFolderId, workspaceId, diff --git a/apps/backend/test/helpers/document/getDocument.ts b/apps/backend/test/helpers/document/getDocument.ts index 602ba6c44..dff507e19 100644 --- a/apps/backend/test/helpers/document/getDocument.ts +++ b/apps/backend/test/helpers/document/getDocument.ts @@ -20,6 +20,9 @@ export const getDocument = async ({ id nameCiphertext nameNonce + nameSignature + nameWorkspaceMemberDevicesProofHash + nameCreatorDeviceSigningPublicKey parentFolderId workspaceId subkeyId diff --git a/apps/backend/test/helpers/document/updateDocumentName.ts b/apps/backend/test/helpers/document/updateDocumentName.ts index b21dac7d5..6e64de246 100644 --- a/apps/backend/test/helpers/document/updateDocumentName.ts +++ b/apps/backend/test/helpers/document/updateDocumentName.ts @@ -6,6 +6,7 @@ import { } from "@serenity-tools/common"; import { gql } from "graphql-request"; import { prisma } from "../../../src/database/prisma"; +import { getWorkspaceMemberDevicesProofByWorkspaceId } from "../../../src/database/workspace/getWorkspaceMemberDevicesProofByWorkspaceId"; import { getSnapshot } from "../snapshot/getSnapshot"; type RunUpdateDocumentNameMutationParams = { @@ -13,6 +14,8 @@ type RunUpdateDocumentNameMutationParams = { id: string; nameCiphertext: string; nameNonce: string; + nameSignature: string; + nameWorkspaceMemberDevicesProofHash: string; subkeyId: string; workspaceKeyId: string; authorizationHeader: string; @@ -22,6 +25,8 @@ const runUpdateDocumentNameMutation = async ({ id, nameCiphertext, nameNonce, + nameSignature, + nameWorkspaceMemberDevicesProofHash, subkeyId, workspaceKeyId, authorizationHeader, @@ -35,6 +40,8 @@ const runUpdateDocumentNameMutation = async ({ document { nameCiphertext nameNonce + nameCreatorDeviceSigningPublicKey + nameSignature id parentFolderId workspaceId @@ -50,6 +57,8 @@ const runUpdateDocumentNameMutation = async ({ id, nameCiphertext, nameNonce, + nameSignature, + nameWorkspaceMemberDevicesProofHash, workspaceKeyId, subkeyId, }, @@ -85,6 +94,8 @@ export const updateDocumentName = async ({ id, nameCiphertext: "", nameNonce: "", + nameSignature: "", + nameWorkspaceMemberDevicesProofHash: "", subkeyId: "AAAAAAAAAAAAAAAAAAAAAA", workspaceKeyId, authorizationHeader, @@ -114,6 +125,8 @@ export const updateDocumentName = async ({ id, nameCiphertext: "", nameNonce: "", + nameSignature: "", + nameWorkspaceMemberDevicesProofHash: "", subkeyId: "AAAAAAAAAAAAAAAAAAAAAA", workspaceKeyId, authorizationHeader, @@ -134,6 +147,8 @@ export const updateDocumentName = async ({ id, nameCiphertext: "", nameNonce: "", + nameSignature: "", + nameWorkspaceMemberDevicesProofHash: "", subkeyId: "AAAAAAAAAAAAAAAAAAAAAA", workspaceKeyId, authorizationHeader, @@ -152,9 +167,20 @@ export const updateDocumentName = async ({ const documentTitleKeyData = createDocumentTitleKey({ snapshotKey, }); + + const workspaceMemberDevicesProofEntry = + await getWorkspaceMemberDevicesProofByWorkspaceId({ + prisma, + workspaceId: workspace.id, + }); + const encryptedDocumentResult = encryptDocumentTitleByKey({ title: name, key: documentTitleKeyData.key, + documentId: id, + workspaceId: workspace.id, + workspaceMemberDevicesProof: workspaceMemberDevicesProofEntry.proof, + activeDevice, }); return runUpdateDocumentNameMutation({ @@ -162,6 +188,9 @@ export const updateDocumentName = async ({ id, nameCiphertext: encryptedDocumentResult.ciphertext, nameNonce: encryptedDocumentResult.publicNonce, + nameSignature: encryptedDocumentResult.signature, + nameWorkspaceMemberDevicesProofHash: + workspaceMemberDevicesProofEntry.proof.hash, subkeyId: documentTitleKeyData.subkeyId, workspaceKeyId, authorizationHeader, diff --git a/apps/backend/test/helpers/workspace/createInitialWorkspaceStructure.ts b/apps/backend/test/helpers/workspace/createInitialWorkspaceStructure.ts index a3103b190..978afd5d1 100644 --- a/apps/backend/test/helpers/workspace/createInitialWorkspaceStructure.ts +++ b/apps/backend/test/helpers/workspace/createInitialWorkspaceStructure.ts @@ -88,6 +88,9 @@ const query = gql` id nameCiphertext nameNonce + nameSignature + nameWorkspaceMemberDevicesProofHash + nameCreatorDeviceSigningPublicKey parentFolderId workspaceId subkeyId @@ -238,13 +241,6 @@ export const createInitialWorkspaceStructure = async ({ const documentTitleKey = documentTitleKeyResult.key; const documentSubkeyId = documentTitleKeyResult.subkeyId; - const encryptedDocumentTitleResult = encryptDocumentTitleByKey({ - title: documentName, - key: documentTitleKey, - }); - const encryptedDocumentName = encryptedDocumentTitleResult.ciphertext; - const encryptedDocumentNameNonce = encryptedDocumentTitleResult.publicNonce; - const createDocumentChainEvent = documentChain.createDocumentChain({ authorKeyPair: { // devices[0] is the main device, devices[1] is the web device @@ -252,6 +248,16 @@ export const createInitialWorkspaceStructure = async ({ publicKey: devices[1].signingPublicKey, }, }); + + const encryptedDocumentTitleResult = encryptDocumentTitleByKey({ + title: documentName, + key: documentTitleKey, + documentId: createDocumentChainEvent.transaction.id, + workspaceId: event.transaction.id, + workspaceMemberDevicesProof, + activeDevice: creatorDevice, + }); + const documentChainState = documentChain.resolveState({ events: [createDocumentChainEvent], knownVersion: documentChain.version, @@ -285,8 +291,9 @@ export const createInitialWorkspaceStructure = async ({ // prepare the document const readyDocument = { - nameCiphertext: encryptedDocumentName, - nameNonce: encryptedDocumentNameNonce, + nameCiphertext: encryptedDocumentTitleResult.ciphertext, + nameNonce: encryptedDocumentTitleResult.publicNonce, + nameSignature: encryptedDocumentTitleResult.signature, subkeyId: documentSubkeyId, snapshot, serializedDocumentChainEvent: JSON.stringify(createDocumentChainEvent), diff --git a/packages/common/src/decryptDocumentTitle/decryptDocumentTitle.ts b/packages/common/src/decryptDocumentTitle/decryptDocumentTitle.ts index ed5e45a2c..6a90619c7 100644 --- a/packages/common/src/decryptDocumentTitle/decryptDocumentTitle.ts +++ b/packages/common/src/decryptDocumentTitle/decryptDocumentTitle.ts @@ -1,3 +1,4 @@ +import * as workspaceMemberDevicesProofUtil from "@serenity-kit/workspace-member-devices-proof"; import { decryptDocumentTitleBasedOnSnapshotKey } from "../decryptDocumentTitleBasedOnSnapshotKey/decryptDocumentTitleBasedOnSnapshotKey"; import { deriveKeysFromKeyDerivationTrace } from "../deriveKeysFromKeyDerivationTrace/deriveKeysFromKeyDerivationTrace"; import { Device, LocalDevice } from "../types"; @@ -6,6 +7,8 @@ import { KeyDerivationTrace } from "../zodTypes"; type Params = { ciphertext: string; nonce: string; + signature: string; + creatorDeviceSigningPublicKey: string; activeDevice: LocalDevice; snapshot: { keyDerivationTrace: KeyDerivationTrace; @@ -18,6 +21,8 @@ type Params = { }; workspaceId: string; workspaceKeyId: string; + documentId: string; + workspaceMemberDevicesProof: workspaceMemberDevicesProofUtil.WorkspaceMemberDevicesProof; }; export const decryptDocumentTitle = ({ @@ -29,6 +34,10 @@ export const decryptDocumentTitle = ({ nonce, workspaceId, workspaceKeyId, + documentId, + workspaceMemberDevicesProof, + signature, + creatorDeviceSigningPublicKey, }: Params) => { const snapshotFolderKeyData = deriveKeysFromKeyDerivationTrace({ keyDerivationTrace: snapshot.keyDerivationTrace, @@ -50,5 +59,10 @@ export const decryptDocumentTitle = ({ subkeyId, ciphertext, nonce, + documentId, + workspaceMemberDevicesProof, + workspaceId, + signature, + creatorDeviceSigningPublicKey, }); }; diff --git a/packages/common/src/decryptDocumentTitleBasedOnSnapshotKey/decryptDocumentTitleBasedOnSnapshotKey.ts b/packages/common/src/decryptDocumentTitleBasedOnSnapshotKey/decryptDocumentTitleBasedOnSnapshotKey.ts index 11f319707..5bad02299 100644 --- a/packages/common/src/decryptDocumentTitleBasedOnSnapshotKey/decryptDocumentTitleBasedOnSnapshotKey.ts +++ b/packages/common/src/decryptDocumentTitleBasedOnSnapshotKey/decryptDocumentTitleBasedOnSnapshotKey.ts @@ -1,13 +1,20 @@ +import * as workspaceMemberDevicesProofUtil from "@serenity-kit/workspace-member-devices-proof"; import { canonicalizeAndToBase64 } from "@serenity-tools/secsync/src/utils/canonicalizeAndToBase64"; import sodium from "react-native-libsodium"; import { decryptAead } from "../decryptAead/decryptAead"; import { recreateDocumentTitleKey } from "../recreateDocumentTitleKey/recreateDocumentTitleKey"; +import { verifyDocumentNameSignature } from "../verifyDocumentNameSignature/verifyDocumentNameSignature"; type Params = { snapshotKey: string; subkeyId: string; ciphertext: string; nonce: string; + workspaceId: string; + documentId: string; + workspaceMemberDevicesProof: workspaceMemberDevicesProofUtil.WorkspaceMemberDevicesProof; + signature: string; + creatorDeviceSigningPublicKey: string; }; export const decryptDocumentTitleBasedOnSnapshotKey = ({ @@ -15,13 +22,37 @@ export const decryptDocumentTitleBasedOnSnapshotKey = ({ subkeyId, ciphertext, nonce, + workspaceId, + documentId, + workspaceMemberDevicesProof, + signature, + creatorDeviceSigningPublicKey, }: Params) => { + const isValidDocumentNameSignature = verifyDocumentNameSignature({ + authorSigningPublicKey: creatorDeviceSigningPublicKey, + ciphertext, + nonce, + signature, + }); + if (!isValidDocumentNameSignature) { + throw new Error( + "Invalid document name signature on createInitialWorkspaceStructure" + ); + } + const documentTitleKeyData = recreateDocumentTitleKey({ snapshotKey: snapshotKey, subkeyId: subkeyId, }); - const publicDataAsBase64 = canonicalizeAndToBase64({}, sodium); + const publicDataAsBase64 = canonicalizeAndToBase64( + { + workspaceId, + documentId, + workspaceMemberDevicesProof, + }, + sodium + ); const result = decryptAead( sodium.from_base64(ciphertext), publicDataAsBase64, diff --git a/packages/common/src/encryptDocumentTitle/encryptDocumentTitle.ts b/packages/common/src/encryptDocumentTitle/encryptDocumentTitle.ts index 85b6ba9e0..6c645dcd7 100644 --- a/packages/common/src/encryptDocumentTitle/encryptDocumentTitle.ts +++ b/packages/common/src/encryptDocumentTitle/encryptDocumentTitle.ts @@ -1,3 +1,5 @@ +import * as workspaceMemberDevicesProofUtil from "@serenity-kit/workspace-member-devices-proof"; +import { sign } from "@serenity-tools/secsync"; import { canonicalizeAndToBase64 } from "@serenity-tools/secsync/src/utils/canonicalizeAndToBase64"; import sodium from "react-native-libsodium"; import { createDocumentTitleKey } from "../createDocumentTitleKey/createDocumentTitleKey"; @@ -21,22 +23,52 @@ type Params = { title: string; workspaceId: string; workspaceKeyId: string; + documentId: string; + workspaceMemberDevicesProof: workspaceMemberDevicesProofUtil.WorkspaceMemberDevicesProof; }; type EncryptDocumentTitleByKeyParams = { title: string; key: string; + workspaceId: string; + documentId: string; + workspaceMemberDevicesProof: workspaceMemberDevicesProofUtil.WorkspaceMemberDevicesProof; + activeDevice: LocalDevice; }; export const encryptDocumentTitleByKey = ( params: EncryptDocumentTitleByKeyParams ) => { - const publicDataAsBase64 = canonicalizeAndToBase64({}, sodium); - return encryptAead( + const publicDataAsBase64 = canonicalizeAndToBase64( + { + documentId: params.documentId, + workspaceId: params.workspaceId, + workspaceMemberDevicesProof: params.workspaceMemberDevicesProof, + }, + sodium + ); + + const result = encryptAead( params.title, publicDataAsBase64, sodium.from_base64(params.key) ); + + const signature = sign( + { + nonce: result.publicNonce, + ciphertext: result.ciphertext, + }, + "document_name", + sodium.from_base64(params.activeDevice.signingPrivateKey), + sodium + ); + + return { + ciphertext: result.ciphertext, + publicNonce: result.publicNonce, + signature, + }; }; export const encryptDocumentTitle = (params: Params) => { @@ -59,15 +91,19 @@ export const encryptDocumentTitle = (params: Params) => { const documentTitleKeyData = createDocumentTitleKey({ snapshotKey: snapshotKeyData.key, }); - const publicData = {}; const result = encryptDocumentTitleByKey({ title: params.title, key: documentTitleKeyData.key, + documentId: params.documentId, + workspaceId: params.workspaceId, + workspaceMemberDevicesProof: params.workspaceMemberDevicesProof, + activeDevice, }); + return { ciphertext: result.ciphertext, nonce: result.publicNonce, - publicData, + signature: result.signature, subkeyId: documentTitleKeyData.subkeyId, workspaceKeyId: params.snapshot.keyDerivationTrace.workspaceKeyId, }; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 7e9100e0d..a1ad5314f 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -42,5 +42,6 @@ export * from "./verifyAndDecryptComment/verifyAndDecryptComment"; export * from "./verifyAndDecryptCommentReply/verifyAndDecryptCommentReply"; export * from "./verifyCommentReplySignature/verifyCommentReplySignature"; export * from "./verifyCommentSignature/verifyCommentSignature"; +export * from "./verifyDocumentNameSignature/verifyDocumentNameSignature"; export * from "./verifyFolderNameSignature/verifyFolderNameSignature"; export * from "./zodTypes"; diff --git a/packages/common/src/verifyDocumentNameSignature/verifyDocumentNameSignature.ts b/packages/common/src/verifyDocumentNameSignature/verifyDocumentNameSignature.ts new file mode 100644 index 000000000..d3a3123f8 --- /dev/null +++ b/packages/common/src/verifyDocumentNameSignature/verifyDocumentNameSignature.ts @@ -0,0 +1,27 @@ +import { verifySignature } from "@serenity-tools/secsync"; +import sodium from "react-native-libsodium"; + +type Params = { + ciphertext: string; + nonce: string; + signature: string; + authorSigningPublicKey: string; +}; + +export const verifyDocumentNameSignature = ({ + signature, + nonce, + ciphertext, + authorSigningPublicKey, +}: Params) => { + return verifySignature( + { + nonce, + ciphertext, + }, + "document_name", + signature, + sodium.from_base64(authorSigningPublicKey), + sodium + ); +}; diff --git a/packages/common/src/zodTypes.ts b/packages/common/src/zodTypes.ts index ba2ee4335..b664b4476 100644 --- a/packages/common/src/zodTypes.ts +++ b/packages/common/src/zodTypes.ts @@ -77,6 +77,8 @@ export type SerenitySnapshotWithClientData = SnapshotWithClientData & { nonce: string; subkeyId: string; workspaceKeyId: string; + signature: string; + workspaceMemberDevicesProofHash: string; }; documentShareLinkDeviceBoxes: DocumentShareLinkDeviceBox[]; };