diff --git a/apps/app/generated/graphql.ts b/apps/app/generated/graphql.ts index 5bb256e1c..05521e332 100644 --- a/apps/app/generated/graphql.ts +++ b/apps/app/generated/graphql.ts @@ -94,6 +94,7 @@ export type Comment = { signature: Scalars['String']['output']; snapshotId: Scalars['String']['output']; subkeyId: Scalars['String']['output']; + workspaceMemberDevicesProofHash: Scalars['String']['output']; }; export type CommentConnection = { @@ -126,6 +127,7 @@ export type CommentReply = { signature: Scalars['String']['output']; snapshotId: Scalars['String']['output']; subkeyId: Scalars['String']['output']; + workspaceMemberDevicesProofHash: Scalars['String']['output']; }; export type CreateCommentInput = { @@ -1726,7 +1728,7 @@ export type CommentsByDocumentIdQueryVariables = Exact<{ }>; -export type CommentsByDocumentIdQuery = { __typename?: 'Query', commentsByDocumentId?: { __typename?: 'CommentConnection', nodes?: Array<{ __typename?: 'Comment', id: string, documentId: string, snapshotId: string, subkeyId: string, contentCiphertext: string, contentNonce: string, signature: string, createdAt: any, creatorDevice: { __typename?: 'CreatorDevice', signingPublicKey: string, encryptionPublicKey: string, encryptionPublicKeySignature: string }, commentReplies?: Array<{ __typename?: 'CommentReply', id: string, snapshotId: string, subkeyId: string, contentCiphertext: string, contentNonce: string, signature: string, createdAt: any, creatorDevice: { __typename?: 'CreatorDevice', signingPublicKey: string, encryptionPublicKey: string, encryptionPublicKeySignature: string } } | null> | null } | null> | null, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } | null }; +export type CommentsByDocumentIdQuery = { __typename?: 'Query', commentsByDocumentId?: { __typename?: 'CommentConnection', nodes?: Array<{ __typename?: 'Comment', id: string, documentId: string, snapshotId: string, subkeyId: string, contentCiphertext: string, contentNonce: string, signature: string, workspaceMemberDevicesProofHash: string, createdAt: any, creatorDevice: { __typename?: 'CreatorDevice', signingPublicKey: string, encryptionPublicKey: string, encryptionPublicKeySignature: string }, commentReplies?: Array<{ __typename?: 'CommentReply', id: string, snapshotId: string, subkeyId: string, contentCiphertext: string, contentNonce: string, signature: string, workspaceMemberDevicesProofHash: string, createdAt: any, creatorDevice: { __typename?: 'CreatorDevice', signingPublicKey: string, encryptionPublicKey: string, encryptionPublicKeySignature: string } } | null> | null } | null> | null, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } | null }; export type DevicesQueryVariables = Exact<{ onlyNotExpired: Scalars['Boolean']['input']; @@ -2505,6 +2507,7 @@ export const CommentsByDocumentIdDocument = gql` contentCiphertext contentNonce signature + workspaceMemberDevicesProofHash createdAt creatorDevice { signingPublicKey @@ -2518,6 +2521,7 @@ export const CommentsByDocumentIdDocument = gql` contentCiphertext contentNonce signature + workspaceMemberDevicesProofHash createdAt creatorDevice { signingPublicKey diff --git a/apps/app/graphql/queries/commentsByDocumentId.graphql b/apps/app/graphql/queries/commentsByDocumentId.graphql index bb86709a2..3099f2af9 100644 --- a/apps/app/graphql/queries/commentsByDocumentId.graphql +++ b/apps/app/graphql/queries/commentsByDocumentId.graphql @@ -18,6 +18,7 @@ query commentsByDocumentId( contentCiphertext contentNonce signature + workspaceMemberDevicesProofHash createdAt creatorDevice { signingPublicKey @@ -31,6 +32,7 @@ query commentsByDocumentId( contentCiphertext contentNonce signature + workspaceMemberDevicesProofHash createdAt creatorDevice { signingPublicKey diff --git a/apps/app/machines/commentsMachine.ts b/apps/app/machines/commentsMachine.ts index 5b083a7a3..3b57efaec 100644 --- a/apps/app/machines/commentsMachine.ts +++ b/apps/app/machines/commentsMachine.ts @@ -22,10 +22,16 @@ import { runDeleteCommentRepliesMutation, runDeleteCommentsMutation, } from "../generated/graphql"; +import { + getLocalOrLoadRemoteWorkspaceMemberDevicesProofQueryByHash, + loadRemoteWorkspaceMemberDevicesProofQuery, +} from "../store/workspaceMemberDevicesProofStore"; +import { isValidDeviceSigningPublicKey } from "../utils/isValidDeviceSigningPublicKey/isValidDeviceSigningPublicKey"; import { showToast } from "../utils/toast/showToast"; type Params = { // these won't change + workspaceId: string; pageId: string; shareLinkToken?: string; activeDevice: LocalDevice | null; @@ -109,6 +115,7 @@ export const commentsMachine = params: { pageId: "", activeDevice: null, + workspaceId: "", }, commentsByDocumentIdQueryError: false, decryptedComments: [], @@ -411,10 +418,10 @@ export const commentsMachine = // console.log("commentReplyId", commentId, key); // }); - const decryptedComments = + const decryptedComments = await Promise.all( context.commentsByDocumentIdQueryResult.data.commentsByDocumentId.nodes .filter(notNull) - .map((encryptedComment) => { + .map(async (encryptedComment) => { let commentKey: string; const maybeCommentKey = context.yCommentKeys.get( encryptedComment.id @@ -442,6 +449,32 @@ export const commentsMachine = commentKey = sodium.to_base64(maybeCommentKey); } + const workspaceMemberDevicesProof = + await getLocalOrLoadRemoteWorkspaceMemberDevicesProofQueryByHash( + { + workspaceId: context.params.workspaceId, + hash: encryptedComment.workspaceMemberDevicesProofHash, + } + ); + if (!workspaceMemberDevicesProof) { + throw new Error( + "workspaceMemberDevicesProof for decrypting a comment is not found" + ); + } + + const isValid = isValidDeviceSigningPublicKey({ + signingPublicKey: + encryptedComment.creatorDevice.signingPublicKey, + workspaceMemberDevicesProofEntry: workspaceMemberDevicesProof, + workspaceId: context.params.workspaceId, + minimumRole: "COMMENTER", + }); + if (!isValid) { + throw new Error( + "Invalid signing public key for the workspaceMemberDevicesProof" + ); + } + const decryptedComment = verifyAndDecryptComment({ commentId: encryptedComment.id, key: commentKey, @@ -453,60 +486,93 @@ export const commentsMachine = signature: encryptedComment.signature, snapshotId: encryptedComment.snapshotId, subkeyId: encryptedComment.subkeyId, + workspaceMemberDevicesProof: + workspaceMemberDevicesProof.proof, }); const replies = encryptedComment.commentReplies - ? encryptedComment.commentReplies - .filter(notNull) - .map((encryptedReply) => { - let replyKey: string; - const maybeReplyKey = context.yCommentReplyKeys.get( - encryptedReply.id - ); - if (encryptedReply.snapshotId === activeSnapshot.id) { - const recreatedReplyKey = recreateCommentKey({ - snapshotKey: activeSnapshot.key, - subkeyId: encryptedReply.subkeyId, - }); - replyKey = recreatedReplyKey.key; - // key is missing in the yjs document so we add it - // this is the case in case the comment was produced by - // a user with the role commenter who doesn't have write - // access to the document - if (!maybeReplyKey) { - context.yCommentReplyKeys.set( - encryptedReply.id, - sodium.from_base64(replyKey) + ? await Promise.all( + encryptedComment.commentReplies + .filter(notNull) + .map(async (encryptedReply) => { + let replyKey: string; + const maybeReplyKey = context.yCommentReplyKeys.get( + encryptedReply.id + ); + if (encryptedReply.snapshotId === activeSnapshot.id) { + const recreatedReplyKey = recreateCommentKey({ + snapshotKey: activeSnapshot.key, + subkeyId: encryptedReply.subkeyId, + }); + replyKey = recreatedReplyKey.key; + // key is missing in the yjs document so we add it + // this is the case in case the comment was produced by + // a user with the role commenter who doesn't have write + // access to the document + if (!maybeReplyKey) { + context.yCommentReplyKeys.set( + encryptedReply.id, + sodium.from_base64(replyKey) + ); + } + } else { + if (!maybeReplyKey) { + throw new Error("No comment reply key found."); + } + replyKey = sodium.to_base64(maybeReplyKey); + } + + const workspaceMemberDevicesProof = + await getLocalOrLoadRemoteWorkspaceMemberDevicesProofQueryByHash( + { + workspaceId: context.params.workspaceId, + hash: encryptedReply.workspaceMemberDevicesProofHash, + } + ); + if (!workspaceMemberDevicesProof) { + throw new Error( + "workspaceMemberDevicesProof for decrypting a comment is not found" ); } - } else { - if (!maybeReplyKey) { - throw new Error("No comment reply key found."); + + const isValid = isValidDeviceSigningPublicKey({ + signingPublicKey: + encryptedReply.creatorDevice.signingPublicKey, + workspaceMemberDevicesProofEntry: + workspaceMemberDevicesProof, + workspaceId: context.params.workspaceId, + minimumRole: "COMMENTER", + }); + if (!isValid) { + throw new Error( + "Invalid signing public key for the workspaceMemberDevicesProof" + ); } - replyKey = sodium.to_base64(maybeReplyKey); - } - const decryptedReply = verifyAndDecryptCommentReply({ - key: replyKey, - ciphertext: encryptedReply.contentCiphertext, - nonce: encryptedReply.contentNonce, - commentId: encryptedComment.id, - commentReplyId: encryptedReply.id, - authorSigningPublicKey: - encryptedReply.creatorDevice.signingPublicKey, - documentId: context.params.pageId, - signature: encryptedReply.signature, - snapshotId: encryptedReply.snapshotId, - subkeyId: encryptedReply.subkeyId, - }); + const decryptedReply = verifyAndDecryptCommentReply({ + key: replyKey, + ciphertext: encryptedReply.contentCiphertext, + nonce: encryptedReply.contentNonce, + commentId: encryptedComment.id, + commentReplyId: encryptedReply.id, + authorSigningPublicKey: + encryptedReply.creatorDevice.signingPublicKey, + documentId: context.params.pageId, + signature: encryptedReply.signature, + snapshotId: encryptedReply.snapshotId, + subkeyId: encryptedReply.subkeyId, + workspaceMemberDevicesProof: + workspaceMemberDevicesProof.proof, + }); - return { - ...decryptedReply, - id: encryptedReply.id, - createdAt: encryptedReply.createdAt, - creatorDevice: encryptedReply.creatorDevice, - }; - }) + return { + ...decryptedReply, + id: encryptedReply.id, + createdAt: encryptedReply.createdAt, + creatorDevice: encryptedReply.creatorDevice, + }; + }) + ) : []; return { @@ -516,7 +582,8 @@ export const commentsMachine = replies, creatorDevice: encryptedComment.creatorDevice, }; - }); + }) + ); return decryptedComments; }, @@ -533,6 +600,11 @@ export const commentsMachine = snapshotKey: activeSnapshot.key, }); + const workspaceMemberDevicesProof = + await loadRemoteWorkspaceMemberDevicesProofQuery({ + workspaceId: context.params.workspaceId, + }); + const result = encryptAndSignComment({ key: commentKey.key, text: event.text, @@ -542,6 +614,7 @@ export const commentsMachine = documentId: context.params.pageId, snapshotId: activeSnapshot.id, subkeyId: commentKey.subkeyId, + workspaceMemberDevicesProof: workspaceMemberDevicesProof.proof, }); const createCommentMutationResult = await runCreateCommentMutation({ @@ -577,6 +650,11 @@ export const commentsMachine = snapshotKey: activeSnapshot.key, }); + const workspaceMemberDevicesProof = + await loadRemoteWorkspaceMemberDevicesProofQuery({ + workspaceId: context.params.workspaceId, + }); + const result = encryptAndSignCommentReply({ key: replyKey.key, commentId: event.commentId, @@ -585,6 +663,7 @@ export const commentsMachine = documentId: context.params.pageId, snapshotId: activeSnapshot.id, subkeyId: replyKey.subkeyId, + workspaceMemberDevicesProof: workspaceMemberDevicesProof.proof, }); const createCommentReplyMutation = await runCreateCommentReplyMutation({ diff --git a/apps/app/navigation/screens/pageScreen/PageScreen.tsx b/apps/app/navigation/screens/pageScreen/PageScreen.tsx index 33d595629..99f2411ef 100644 --- a/apps/app/navigation/screens/pageScreen/PageScreen.tsx +++ b/apps/app/navigation/screens/pageScreen/PageScreen.tsx @@ -68,6 +68,7 @@ const ActualPageScreen = ( const commentsService = useInterpret(commentsMachine, { context: { params: { + workspaceId, pageId: props.route.params.pageId, activeDevice: activeDevice as LocalDevice, }, diff --git a/apps/app/navigation/screens/sharePageScreen/SharePageScreen.tsx b/apps/app/navigation/screens/sharePageScreen/SharePageScreen.tsx index 97219b5d1..4d98f43df 100644 --- a/apps/app/navigation/screens/sharePageScreen/SharePageScreen.tsx +++ b/apps/app/navigation/screens/sharePageScreen/SharePageScreen.tsx @@ -52,6 +52,7 @@ const SharePageContainer: React.FC = ({ const commentsService = useInterpret(commentsMachine, { context: { params: { + workspaceId, pageId: route.params.pageId, shareLinkToken: route.params.token, activeDevice: shareDevice, diff --git a/apps/backend/prisma/migrations/20240118100802_add_workspace_member_devices_proof_hash_to_comment/migration.sql b/apps/backend/prisma/migrations/20240118100802_add_workspace_member_devices_proof_hash_to_comment/migration.sql new file mode 100644 index 000000000..1ff9dfe26 --- /dev/null +++ b/apps/backend/prisma/migrations/20240118100802_add_workspace_member_devices_proof_hash_to_comment/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Comment" ADD COLUMN "workspaceMemberDevicesProofHash" TEXT; diff --git a/apps/backend/prisma/migrations/20240118101808_add_workspace_member_devices_proof_to_comment_reply/migration.sql b/apps/backend/prisma/migrations/20240118101808_add_workspace_member_devices_proof_to_comment_reply/migration.sql new file mode 100644 index 000000000..70a2a5573 --- /dev/null +++ b/apps/backend/prisma/migrations/20240118101808_add_workspace_member_devices_proof_to_comment_reply/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "CommentReply" ADD COLUMN "workspaceMemberDevicesProofHash" TEXT; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index f210b7394..d0d50ce2c 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -366,36 +366,38 @@ model SnapshotKeyBox { } model Comment { - id String @id @unique - document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) - documentId String - createdAt DateTime @default(now()) - contentCiphertext String - contentNonce String - creatorDevice CreatorDevice @relation(fields: [creatorDeviceSigningPublicKey], references: [signingPublicKey], onDelete: SetDefault) - creatorDeviceSigningPublicKey String - commentReplies CommentReply[] - snapshot Snapshot @relation(fields: [snapshotId], references: [id], onDelete: Cascade) - snapshotId String - subkeyId String - signature String + id String @id @unique + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + documentId String + createdAt DateTime @default(now()) + contentCiphertext String + contentNonce String + creatorDevice CreatorDevice @relation(fields: [creatorDeviceSigningPublicKey], references: [signingPublicKey], onDelete: SetDefault) + creatorDeviceSigningPublicKey String + commentReplies CommentReply[] + snapshot Snapshot @relation(fields: [snapshotId], references: [id], onDelete: Cascade) + snapshotId String + subkeyId String + signature String + workspaceMemberDevicesProofHash String? // only needed for workspace members } model CommentReply { - id String @id @unique - document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) - documentId String - comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade) - commentId String - createdAt DateTime @default(now()) - contentCiphertext String - contentNonce String - creatorDevice CreatorDevice @relation(fields: [creatorDeviceSigningPublicKey], references: [signingPublicKey], onDelete: SetDefault) - creatorDeviceSigningPublicKey String - snapshot Snapshot @relation(fields: [snapshotId], references: [id], onDelete: Cascade) - snapshotId String - subkeyId String - signature String + id String @id @unique + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + documentId String + comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade) + commentId String + createdAt DateTime @default(now()) + contentCiphertext String + contentNonce String + creatorDevice CreatorDevice @relation(fields: [creatorDeviceSigningPublicKey], references: [signingPublicKey], onDelete: SetDefault) + creatorDeviceSigningPublicKey String + snapshot Snapshot @relation(fields: [snapshotId], references: [id], onDelete: Cascade) + snapshotId String + subkeyId String + signature String + workspaceMemberDevicesProofHash String? // only needed for workspace members } model LoginAttempt { diff --git a/apps/backend/src/database/comment/createComment.ts b/apps/backend/src/database/comment/createComment.ts index 873726e80..38459ef3f 100644 --- a/apps/backend/src/database/comment/createComment.ts +++ b/apps/backend/src/database/comment/createComment.ts @@ -1,7 +1,9 @@ +import * as workspaceMemberDevicesProofUtil from "@serenity-kit/workspace-member-devices-proof"; import { ForbiddenError, UserInputError } from "apollo-server-express"; import { Role, ShareDocumentRole } from "../../../prisma/generated/output"; import { getOrCreateCreatorDevice } from "../../utils/device/getOrCreateCreatorDevice"; import { prisma } from "../prisma"; +import { getWorkspaceMemberDevicesProof } from "../workspace/getWorkspaceMemberDevicesProof"; type Params = { commentId: string; @@ -26,6 +28,9 @@ export async function createComment({ contentNonce, signature, }: Params) { + let workspaceMemberDevicesProof: null | workspaceMemberDevicesProofUtil.WorkspaceMemberDevicesProof = + null; + // verify the document exists const document = await prisma.document.findFirst({ where: { activeSnapshotId: snapshotId }, @@ -63,6 +68,13 @@ export async function createComment({ if (!user2Workspace) { throw new ForbiddenError("Unauthorized"); } + + const workspaceMemberDevicesProofEntry = + await getWorkspaceMemberDevicesProof({ + workspaceId: user2Workspace.workspaceId, + userId, + }); + workspaceMemberDevicesProof = workspaceMemberDevicesProofEntry.proof; } // convert the user's device into a creatorDevice @@ -83,6 +95,7 @@ export async function createComment({ contentNonce, subkeyId, signature, + workspaceMemberDevicesProofHash: workspaceMemberDevicesProof?.hash, }, }); return { diff --git a/apps/backend/src/database/commentreply/createCommentReply.ts b/apps/backend/src/database/commentreply/createCommentReply.ts index ae5be8c38..1b148104b 100644 --- a/apps/backend/src/database/commentreply/createCommentReply.ts +++ b/apps/backend/src/database/commentreply/createCommentReply.ts @@ -1,7 +1,9 @@ +import * as workspaceMemberDevicesProofUtil from "@serenity-kit/workspace-member-devices-proof"; import { ForbiddenError, UserInputError } from "apollo-server-express"; import { Role, ShareDocumentRole } from "../../../prisma/generated/output"; import { getOrCreateCreatorDevice } from "../../utils/device/getOrCreateCreatorDevice"; import { prisma } from "../prisma"; +import { getWorkspaceMemberDevicesProof } from "../workspace/getWorkspaceMemberDevicesProof"; type Params = { userId: string; @@ -28,6 +30,9 @@ export async function createCommentReply({ contentNonce, signature, }: Params) { + let workspaceMemberDevicesProof: null | workspaceMemberDevicesProofUtil.WorkspaceMemberDevicesProof = + null; + // verify the document exists const document = await prisma.document.findFirst({ where: { activeSnapshotId: snapshotId }, @@ -72,6 +77,13 @@ export async function createCommentReply({ if (!user2Workspace) { throw new ForbiddenError("Unauthorized"); } + + const workspaceMemberDevicesProofEntry = + await getWorkspaceMemberDevicesProof({ + workspaceId: user2Workspace.workspaceId, + userId, + }); + workspaceMemberDevicesProof = workspaceMemberDevicesProofEntry.proof; } // convert the user's device into a creatorDevice @@ -93,6 +105,7 @@ export async function createCommentReply({ contentCiphertext, contentNonce, signature, + workspaceMemberDevicesProofHash: workspaceMemberDevicesProof?.hash, }, }); return { diff --git a/apps/backend/src/graphql/mutations/comment/createComment.test.ts b/apps/backend/src/graphql/mutations/comment/createComment.test.ts index f8f022681..85910ca00 100644 --- a/apps/backend/src/graphql/mutations/comment/createComment.test.ts +++ b/apps/backend/src/graphql/mutations/comment/createComment.test.ts @@ -43,6 +43,8 @@ test("owner comments", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: documentId1, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); const comment = createCommentResult.createComment.comment; expect(typeof comment.id).toBe("string"); @@ -210,6 +212,8 @@ test("admin comments", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); const comment = createCommentResult.createComment.comment; expect(typeof comment.id).toBe("string"); @@ -247,6 +251,8 @@ test("editor comments", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); const comment = createCommentResult.createComment.comment; expect(typeof comment.id).toBe("string"); @@ -284,6 +290,8 @@ test("commenter comment", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); const comment = createCommentResult.createComment.comment; expect(typeof comment.id).toBe("string"); @@ -324,6 +332,8 @@ test("viewer tries to comment", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }))() ).rejects.toThrowError("Unauthorized"); }); @@ -347,6 +357,8 @@ test("unauthorized document", async () => { sessionKey: otherUser.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }))() ).rejects.toThrowError("Unauthorized"); }); @@ -368,6 +380,8 @@ test("invalid document", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }))() ).rejects.toThrowError("Unauthorized"); }); @@ -386,6 +400,8 @@ test("Unauthenticated", async () => { creatorDeviceSigningPrivateKey: userData1.webDevice.signingPrivateKey, authorizationHeader: "badauthkey", documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }))() ).rejects.toThrowError(/UNAUTHENTICATED/); }); diff --git a/apps/backend/src/graphql/mutations/comment/deleteComments.test.ts b/apps/backend/src/graphql/mutations/comment/deleteComments.test.ts index 53b41f7ca..245f5f1cd 100644 --- a/apps/backend/src/graphql/mutations/comment/deleteComments.test.ts +++ b/apps/backend/src/graphql/mutations/comment/deleteComments.test.ts @@ -60,6 +60,8 @@ test("commenter deletes own comment", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); const comment = commentResult.createComment.comment; const numCommentsBeforeDelete = await prisma.comment.count({ @@ -102,6 +104,8 @@ test("admin deletes comment", async () => { sessionKey: userData2.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData2.user.id, }); const comment = commentResult.createComment.comment; const numCommentsBeforeDelete = await prisma.comment.count({ @@ -144,6 +148,8 @@ test("editor deletes comment", async () => { sessionKey: userData2.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData2.user.id, }); const comment = commentResult.createComment.comment; const numCommentsBeforeDelete = await prisma.comment.count({ @@ -186,6 +192,8 @@ test("commentor tries to delete other comment", async () => { sessionKey: userData2.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData2.user.id, }); const comment = commentResult.createComment.comment; await expect( @@ -222,6 +230,8 @@ test("viewer tries to delete other comment", async () => { sessionKey: userData2.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData2.user.id, }); const comment = commentResult.createComment.comment; await expect( @@ -259,6 +269,8 @@ test("delete some comments", async () => { sessionKey: userData2.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData2.user.id, }); const comment = commentResult.createComment.comment; const numCommentsBeforeDelete = await prisma.comment.count({ @@ -293,6 +305,8 @@ test("editor share token", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); const comment = commentResult.createComment.comment; const userData2 = await createUserWithWorkspace({ @@ -343,6 +357,8 @@ test("commenter share token", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); const comment = commentResult.createComment.comment; const userData2 = await createUserWithWorkspace({ @@ -389,6 +405,8 @@ test("viewer share token can't delete", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); const comment = commentResult.createComment.comment; const userData2 = await createUserWithWorkspace({ @@ -435,6 +453,8 @@ test("can't delete comments on outside document", async () => { sessionKey: userData2.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); await prisma.usersToWorkspaces.deleteMany({ where: { diff --git a/apps/backend/src/graphql/mutations/commentReply/createCommentReply.test.ts b/apps/backend/src/graphql/mutations/commentReply/createCommentReply.test.ts index e3986caf6..29ca9dd19 100644 --- a/apps/backend/src/graphql/mutations/commentReply/createCommentReply.test.ts +++ b/apps/backend/src/graphql/mutations/commentReply/createCommentReply.test.ts @@ -41,6 +41,8 @@ const setup = async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); comment = createCommentResult.createComment.comment; }; @@ -64,6 +66,8 @@ test("commenter responds to comment", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); const commentReply = createCommentReplyResult.createCommentReply.commentReply; expect(typeof commentReply.id).toBe("string"); @@ -235,6 +239,8 @@ test("admin replies to comment", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); const commentReply = createCommentReplyResult.createCommentReply.commentReply; expect(typeof commentReply.id).toBe("string"); @@ -274,6 +280,8 @@ test("editor replies to comment", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); const commentReply = createCommentReplyResult.createCommentReply.commentReply; expect(typeof commentReply.id).toBe("string"); @@ -313,6 +321,8 @@ test("commenter replies to comment", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); const commentReply = createCommentReplyResult.createCommentReply.commentReply; expect(typeof commentReply.id).toBe("string"); @@ -355,6 +365,8 @@ test("viewer tries to comment", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }))() ).rejects.toThrowError("Unauthorized"); }); @@ -379,6 +391,8 @@ test("unauthorized document", async () => { sessionKey: otherUser.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }))() ).rejects.toThrowError("Unauthorized"); }); @@ -401,6 +415,8 @@ test("invalid document", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }))() ).rejects.toThrowError("Unauthorized"); }); @@ -423,6 +439,8 @@ test("invalid commentId", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }))() ).rejects.toThrowError(/BAD_USER_INPUT/); }); @@ -442,6 +460,8 @@ test("Unauthenticated", async () => { creatorDeviceSigningPrivateKey: userData1.webDevice.signingPrivateKey, authorizationHeader: "badauthkey", documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }))() ).rejects.toThrowError(/UNAUTHENTICATED/); }); diff --git a/apps/backend/src/graphql/mutations/commentReply/deleteCommentReplies.test.ts b/apps/backend/src/graphql/mutations/commentReply/deleteCommentReplies.test.ts index 731d090ac..58ec22d93 100644 --- a/apps/backend/src/graphql/mutations/commentReply/deleteCommentReplies.test.ts +++ b/apps/backend/src/graphql/mutations/commentReply/deleteCommentReplies.test.ts @@ -53,6 +53,8 @@ const setup = async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); user1Comment = user1CommentResult.createComment.comment; const user2CommentResult = await createComment({ @@ -67,6 +69,8 @@ const setup = async () => { sessionKey: userData2.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); user2Comment = user2CommentResult.createComment.comment; }; @@ -90,6 +94,8 @@ test("commenter deletes own reply", async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); const commentReply = commentReplyResult.createCommentReply.commentReply; const numCommentsBeforeDelete = await prisma.commentReply.count({ @@ -135,6 +141,8 @@ test("admin deletes reply", async () => { sessionKey: userData2.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData2.user.id, }); const commentReply = commentReplyResult.createCommentReply.commentReply; const numCommentsBeforeDelete = await prisma.commentReply.count({ @@ -180,6 +188,8 @@ test("editor deletes reply", async () => { sessionKey: userData2.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData2.user.id, }); const commentReply = commentReplyResult.createCommentReply.commentReply; const numCommentsBeforeDelete = await prisma.commentReply.count({ @@ -225,6 +235,8 @@ test("commentor tries to delete other reply", async () => { sessionKey: userData2.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData2.user.id, }); const commentReply = commentReplyResult.createCommentReply.commentReply; expect( @@ -263,6 +275,8 @@ test("viewer tries to delete other reply", async () => { sessionKey: userData2.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData2.user.id, }); const commentReply = commentReplyResult.createCommentReply.commentReply; expect( @@ -292,6 +306,8 @@ test("delete some replies", async () => { sessionKey: userData2.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData2.user.id, }); const commentReply = commentReplyResult.createCommentReply.commentReply; const numCommentsBeforeDelete = await prisma.commentReply.count({ @@ -329,6 +345,8 @@ test("cant delete replies on outside document", async () => { sessionKey: userData2.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData2.user.id, }); await prisma.usersToWorkspaces.deleteMany({ where: { diff --git a/apps/backend/src/graphql/queries/comment/commentsByDocumentId.test.ts b/apps/backend/src/graphql/queries/comment/commentsByDocumentId.test.ts index f0109f484..c23c7fe0e 100644 --- a/apps/backend/src/graphql/queries/comment/commentsByDocumentId.test.ts +++ b/apps/backend/src/graphql/queries/comment/commentsByDocumentId.test.ts @@ -37,6 +37,8 @@ const setup = async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); comment1 = comment1Result.createComment.comment; const comment2Result = await createComment({ @@ -51,6 +53,8 @@ const setup = async () => { sessionKey: userData1.sessionKey, }).authorization, documentId: userData1.document.id, + workspaceId: userData1.workspace.id, + userId: userData1.user.id, }); comment2 = comment2Result.createComment.comment; }; diff --git a/apps/backend/src/graphql/types/comment.ts b/apps/backend/src/graphql/types/comment.ts index 850549402..ba32f6804 100644 --- a/apps/backend/src/graphql/types/comment.ts +++ b/apps/backend/src/graphql/types/comment.ts @@ -12,6 +12,7 @@ export const Comment = objectType({ t.nonNull.string("subkeyId"); t.nonNull.string("contentCiphertext"); t.nonNull.string("contentNonce"); + t.nonNull.string("workspaceMemberDevicesProofHash"); t.nonNull.field("createdAt", { type: nonNull("Date") }); t.nonNull.field("creatorDevice", { type: CreatorDevice }); t.list.field("commentReplies", { type: CommentReply }); diff --git a/apps/backend/src/graphql/types/commentReply.ts b/apps/backend/src/graphql/types/commentReply.ts index 4cb4656ea..56953c27d 100644 --- a/apps/backend/src/graphql/types/commentReply.ts +++ b/apps/backend/src/graphql/types/commentReply.ts @@ -12,6 +12,7 @@ export const CommentReply = objectType({ t.nonNull.string("subkeyId"); t.nonNull.string("contentCiphertext"); t.nonNull.string("contentNonce"); + t.nonNull.string("workspaceMemberDevicesProofHash"); t.nonNull.field("createdAt", { type: "Date" }); t.nonNull.field("creatorDevice", { type: CreatorDevice }); }, diff --git a/apps/backend/test/helpers/comment/createComment.ts b/apps/backend/test/helpers/comment/createComment.ts index 624d02693..3a5ecc99d 100644 --- a/apps/backend/test/helpers/comment/createComment.ts +++ b/apps/backend/test/helpers/comment/createComment.ts @@ -4,22 +4,42 @@ import { LocalDevice, } from "@serenity-tools/common"; import { gql } from "graphql-request"; +import { getWorkspaceMemberDevicesProof } from "../../../src/database/workspace/getWorkspaceMemberDevicesProof"; -type Params = { - graphql: any; - documentId: string; - snapshotId: string; - snapshotKey: string; - documentShareLinkToken?: string | null | undefined; - comment: string; - authorizationHeader: string; - creatorDevice: LocalDevice; - creatorDeviceEncryptionPrivateKey: string; - creatorDeviceSigningPrivateKey: string; -}; +type Params = + | { + graphql: any; + documentId: string; + workspaceId?: undefined; + userId?: undefined; + snapshotId: string; + snapshotKey: string; + documentShareLinkToken: string; + comment: string; + authorizationHeader: string; + creatorDevice: LocalDevice; + creatorDeviceEncryptionPrivateKey: string; + creatorDeviceSigningPrivateKey: string; + } + | { + graphql: any; + documentId: string; + workspaceId: string; + userId: string; + snapshotId: string; + snapshotKey: string; + documentShareLinkToken?: undefined; + comment: string; + authorizationHeader: string; + creatorDevice: LocalDevice; + creatorDeviceEncryptionPrivateKey: string; + creatorDeviceSigningPrivateKey: string; + }; export const createComment = async ({ graphql, + userId, + workspaceId, documentId, snapshotId, snapshotKey, @@ -33,6 +53,14 @@ export const createComment = async ({ const authorizationHeaders = { authorization: authorizationHeader, }; + + const workspaceMemberDevicesProof = userId + ? await getWorkspaceMemberDevicesProof({ + userId, + workspaceId, + }) + : undefined; + const commentKey = createCommentKey({ snapshotKey }); const { ciphertext, nonce, commentId, signature } = encryptAndSignComment({ text: comment, @@ -43,6 +71,7 @@ export const createComment = async ({ to: 10, snapshotId, subkeyId: commentKey.subkeyId, + workspaceMemberDevicesProof: workspaceMemberDevicesProof?.proof, }); const query = gql` diff --git a/apps/backend/test/helpers/commentReply/createCommentReply.ts b/apps/backend/test/helpers/commentReply/createCommentReply.ts index 758191c93..973f11c6e 100644 --- a/apps/backend/test/helpers/commentReply/createCommentReply.ts +++ b/apps/backend/test/helpers/commentReply/createCommentReply.ts @@ -4,23 +4,44 @@ import { encryptAndSignCommentReply, } from "@serenity-tools/common"; import { gql } from "graphql-request"; +import { getWorkspaceMemberDevicesProof } from "../../../src/database/workspace/getWorkspaceMemberDevicesProof"; -type Params = { - graphql: any; - commentId: string; - snapshotId: string; - documentId: string; - documentShareLinkToken?: string | null | undefined; - snapshotKey: string; - comment: string; - authorizationHeader: string; - creatorDevice: LocalDevice; - creatorDeviceEncryptionPrivateKey: string; - creatorDeviceSigningPrivateKey: string; -}; +type Params = + | { + graphql: any; + userId?: undefined; + workspaceId?: undefined; + commentId: string; + snapshotId: string; + documentId: string; + documentShareLinkToken: string; + snapshotKey: string; + comment: string; + authorizationHeader: string; + creatorDevice: LocalDevice; + creatorDeviceEncryptionPrivateKey: string; + creatorDeviceSigningPrivateKey: string; + } + | { + graphql: any; + userId: string; + workspaceId: string; + commentId: string; + snapshotId: string; + documentId: string; + documentShareLinkToken?: undefined; + snapshotKey: string; + comment: string; + authorizationHeader: string; + creatorDevice: LocalDevice; + creatorDeviceEncryptionPrivateKey: string; + creatorDeviceSigningPrivateKey: string; + }; export const createCommentReply = async ({ graphql, + workspaceId, + userId, commentId, documentId, snapshotId, @@ -36,6 +57,14 @@ export const createCommentReply = async ({ authorization: authorizationHeader, }; const commentKey = createCommentKey({ snapshotKey }); + + const workspaceMemberDevicesProof = userId + ? await getWorkspaceMemberDevicesProof({ + userId, + workspaceId, + }) + : undefined; + const { ciphertext, nonce, commentReplyId, signature } = encryptAndSignCommentReply({ text: comment, @@ -45,6 +74,7 @@ export const createCommentReply = async ({ documentId, snapshotId, subkeyId: commentKey.subkeyId, + workspaceMemberDevicesProof: workspaceMemberDevicesProof?.proof, }); const query = gql` diff --git a/packages/common/src/encryptAndSignComment/encryptAndSignComment.test.ts b/packages/common/src/encryptAndSignComment/encryptAndSignComment.test.ts index 6e429bea2..5fe64dde6 100644 --- a/packages/common/src/encryptAndSignComment/encryptAndSignComment.test.ts +++ b/packages/common/src/encryptAndSignComment/encryptAndSignComment.test.ts @@ -20,6 +20,12 @@ test("encrypt comment", () => { documentId: "123", snapshotId: "456", subkeyId: commentKey.subkeyId, + workspaceMemberDevicesProof: { + clock: 0, + hash: "abc", + hashSignature: "abc", + version: 0, + }, }); expect(typeof result.ciphertext).toBe("string"); expect(typeof result.nonce).toBe("string"); diff --git a/packages/common/src/encryptAndSignComment/encryptAndSignComment.ts b/packages/common/src/encryptAndSignComment/encryptAndSignComment.ts index 2d950dd77..fe8b13509 100644 --- a/packages/common/src/encryptAndSignComment/encryptAndSignComment.ts +++ b/packages/common/src/encryptAndSignComment/encryptAndSignComment.ts @@ -1,3 +1,4 @@ +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"; @@ -13,6 +14,8 @@ type Params = { documentId: string; snapshotId: string; subkeyId: string; + // optional for documentShareLinks + workspaceMemberDevicesProof?: workspaceMemberDevicesProofUtil.WorkspaceMemberDevicesProof; device: LocalDevice; }; @@ -26,6 +29,7 @@ export const encryptAndSignComment = ({ documentId, snapshotId, subkeyId, + workspaceMemberDevicesProof, device, }: Params) => { const commentId = generateId(); @@ -41,6 +45,7 @@ export const encryptAndSignComment = ({ snapshotId, subkeyId, signingPublicKey: device.signingPublicKey, + workspaceMemberDevicesProof, }; const publicDataAsBase64 = canonicalizeAndToBase64(publicData, sodium); const encryptedComment = encryptAead( diff --git a/packages/common/src/encryptAndSignCommentReply/encryptAndSignCommentReply.ts b/packages/common/src/encryptAndSignCommentReply/encryptAndSignCommentReply.ts index 4338ce474..82943de23 100644 --- a/packages/common/src/encryptAndSignCommentReply/encryptAndSignCommentReply.ts +++ b/packages/common/src/encryptAndSignCommentReply/encryptAndSignCommentReply.ts @@ -1,3 +1,4 @@ +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"; @@ -12,6 +13,8 @@ type Params = { documentId: string; snapshotId: string; subkeyId: string; + // optional for documentShareLinks + workspaceMemberDevicesProof?: workspaceMemberDevicesProofUtil.WorkspaceMemberDevicesProof; device: LocalDevice; }; @@ -25,6 +28,7 @@ export const encryptAndSignCommentReply = ({ snapshotId, subkeyId, device, + workspaceMemberDevicesProof, }: Params) => { const commentReplyId = generateId(); const content = JSON.stringify({ @@ -37,6 +41,7 @@ export const encryptAndSignCommentReply = ({ documentId, snapshotId, subkeyId, + workspaceMemberDevicesProof, signingPublicKey: device.signingPublicKey, }; const publicDataAsBase64 = canonicalizeAndToBase64(publicData, sodium); diff --git a/packages/common/src/verifyAndDecryptComment/verifyAndDecryptComment.ts b/packages/common/src/verifyAndDecryptComment/verifyAndDecryptComment.ts index e49f4cd18..d24d3b96a 100644 --- a/packages/common/src/verifyAndDecryptComment/verifyAndDecryptComment.ts +++ b/packages/common/src/verifyAndDecryptComment/verifyAndDecryptComment.ts @@ -1,3 +1,4 @@ +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"; @@ -14,6 +15,7 @@ type Params = { subkeyId: string; signature: string; authorSigningPublicKey: string; + workspaceMemberDevicesProof?: workspaceMemberDevicesProofUtil.WorkspaceMemberDevicesProof; }; export const verifyAndDecryptComment = ({ @@ -26,6 +28,7 @@ export const verifyAndDecryptComment = ({ documentId, snapshotId, subkeyId, + workspaceMemberDevicesProof, }: Params) => { const isValid = verifyCommentSignature({ authorSigningPublicKey, @@ -43,6 +46,7 @@ export const verifyAndDecryptComment = ({ signingPublicKey: authorSigningPublicKey, snapshotId, subkeyId, + workspaceMemberDevicesProof, }; const publicDataAsBase64 = canonicalizeAndToBase64(publicData, sodium); const result = decryptAead( diff --git a/packages/common/src/verifyAndDecryptCommentReply/verifyAndDecryptCommentReply.ts b/packages/common/src/verifyAndDecryptCommentReply/verifyAndDecryptCommentReply.ts index 38ab59b9d..020f4e458 100644 --- a/packages/common/src/verifyAndDecryptCommentReply/verifyAndDecryptCommentReply.ts +++ b/packages/common/src/verifyAndDecryptCommentReply/verifyAndDecryptCommentReply.ts @@ -1,3 +1,4 @@ +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"; @@ -14,6 +15,7 @@ type Params = { signature: string; authorSigningPublicKey: string; subkeyId: string; + workspaceMemberDevicesProof: workspaceMemberDevicesProofUtil.WorkspaceMemberDevicesProof; }; export const verifyAndDecryptCommentReply = ({ @@ -27,6 +29,7 @@ export const verifyAndDecryptCommentReply = ({ documentId, snapshotId, subkeyId, + workspaceMemberDevicesProof, }: Params) => { const isValid = verifyCommentReplySignature({ authorSigningPublicKey, @@ -45,6 +48,7 @@ export const verifyAndDecryptCommentReply = ({ signingPublicKey: authorSigningPublicKey, snapshotId, subkeyId, + workspaceMemberDevicesProof, }; const publicDataAsBase64 = canonicalizeAndToBase64(publicData, sodium); const result = decryptAead( diff --git a/packages/common/src/verifyCommentSignature/verifyCommentSignature.test.ts b/packages/common/src/verifyCommentSignature/verifyCommentSignature.test.ts index f26bd2a3e..53b8681d3 100644 --- a/packages/common/src/verifyCommentSignature/verifyCommentSignature.test.ts +++ b/packages/common/src/verifyCommentSignature/verifyCommentSignature.test.ts @@ -21,6 +21,12 @@ test("verify comment signature", () => { documentId: "123", snapshotId: "456", subkeyId: commentKey.subkeyId, + workspaceMemberDevicesProof: { + clock: 0, + hash: "abc", + hashSignature: "abc", + version: 0, + }, }); const verified = verifyCommentSignature({ ciphertext: result.ciphertext,