Document Comments
Each document can have comments and replies. A reply is a comment on a comment and there is only one level of replies. The comments are encrypted and stored in the database of the server.
The comments don't need to be in the document since they can reference a position in the CRDT structure of Yjs that never changes.
Goal of the Design
- Comments content is encrypted and can only be decrypted by a client with access to the document. See document encryption for more details.
- The server must be honest an provide the comments to all clients that have access to the document.
- The comments are not stored in the document in order to allow the commenter role that can create comments, but not edit the document.
- The goal is still to store comment keys in the document when write access is available. The idea is that the role commenter doesn't have write access to the comment, they can create the comment and an editor can add the key later to the document after decrypting it.
Cryptographic Dependencies and actual implementation
- generateSubkeyId:
sodium.randombytes_buf(16)
- kdf:
sodium.crypto_kdf_hkdf_sha256_expand(sodium.crypto_kdf_hkdf_sha256_extract(key, subkeyId), context, crypto_aead_xchacha20poly1305_ietf_KEYBYTES)
- generateNonce:
sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES)
- encryptAead:
sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(message, additionalData, nonce, key)
- decryptAead:
sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(ciphertext, additionalData, nonce, key)
- sign:
sodium.crypto_sign_detached(message, privateKey)
- signVerify:
sodium.crypto_sign_detached(signature, message, publicKey)
Note: For encryptAead
every message is prefixed with a block of four 0-bytes to ensure commitment. More info here (opens in a new tab)
Comments State Machine
Creating a comment
When creating a comment first a commentKey is derived from the currently active snapshotKey. The commentKey is used to encrypt the comment.
subkeyId = generateSubkeyId();
commentKey = kdf(subkeyId, "comment_", snapshotKey);
nonce = generateNonce();
additionalAuthenticatedData = canonicalize(
commentId,
documentId,
snapshotId,
subkeyId,
device.signingPublicKey
);
ciphertext = encryptAead(commentContent, additionalAuthenticatedData, nonce, commentKey);
encryptedComment = canonicalize(ciphertext, nonce);
signatur = sign(
concat(
"comment",
encryptedComment
),
device.signingPrivateKey
);
Create comment: https://github.com/serenity-kit/Serenity/blob/main/apps/app/machines/commentsMachine.ts#L502-L545 (opens in a new tab)
Since only the latest snapshot is fetched to retrieve the current document state, the comment key is also stored in the Yjs document. This avoids the need to fetch an older snapshot to derive the comment key.
They system to derive the comment key from the snapshot key is still in place to allow users with the role COMMENTER
to create comments without direct write access to the document. Clients with write access will add the keys to the document after loading the comments and identifying that the comment key is missing in the document.
Verify and decrypt comments
When decrypting a comment the commentKey is derived from the currently active snapshotKey. The commentKey is used to decrypt the comment.
signVerify(
signature,
concat("comment", canonicalize(ciphertext, nonce)),
authorDevice.signingPublicKey
);
commentKey = kdf(subkeyId, "comment_", snapshotKey);
additionalAuthenticatedData = canonicalize(
commentId,
documentId,
snapshotId,
subkeyId,
authorDevice.signingPublicKey,
);
commentContent = decryptAead(ciphertext, additionalAuthenticatedData, nonce, commentKey);
The purpose of the signature is to ensure that the comment reply was created by the author of the comment.
Create a comment reply
It's very similar to creating a comment. The main difference is that the AAD is different.
When creating a comment reply first a commentKey is derived from the currently active snapshotKey. The commentReplyKey is used to encrypt the comment.
subkeyId = generateSubkeyId();
commentReplyKey = kdf(subkeyId, "comment_", snapshotKey);
nonce = generateNonce();
additionalAuthenticatedData = canonicalize(
commentReplyId,
commentId,
documentId,
snapshotId,
subkeyId,
device.signingPublicKey,
);
ciphertext = encryptAead(commentReplyContent, additionalAuthenticatedData, nonce, commentKey);
encryptedCommentReply = canonicalize(ciphertext, nonce);
signatur = sign(
concat(
"comment_reply",
encryptedCommentReply
),
device.signingPrivateKey
);
Since only the latest snapshot is fetched to retrieve the current document state, the comment reply key is also stored in the Yjs document. This avoids the need to fetch an older snapshot to derive the comment reply key.
They system to derive the comment reply key from the snapshot key is still in place to allow users with the role COMMENTER
to create comments without direct write access to the document. Clients with write access will add the keys to the document after loading the comments and identifying that the comment key is missing in the document.
Verify and decrypt comment replies
When decrypting a comment the commentKey is derived from the currently active snapshotKey. The commentKey is used to decrypt the comment.
signVerify(
signature,
concat("comment_reply", canonicalize(ciphertext, nonce)),
authorDevice.signingPublicKey
);
commentReplyKey = kdf(subkeyId, "comment_", snapshotKey);
additionalAuthenticatedData = canonicalize(
commentReplyId,
commentId,
documentId,
snapshotId,
subkeyId,
authorDevice.signingPublicKey,
);
commentReplyContent = decryptAead(ciphertext, additionalAuthenticatedData, nonce, commentReplyKey);
The purpose of the signature is to ensure that the comment reply was created by the author of the comment.
Deleting a comment or reply
On the client side a GraphQL mutation has to be invoked.
https://github.com/serenity-kit/Serenity/blob/main/apps/app/machines/commentsMachine.ts#L587-L593 (opens in a new tab) (delete comment) https://github.com/serenity-kit/Serenity/blob/main/apps/app/machines/commentsMachine.ts#L594-L601 (opens in a new tab) (delete comment reply)
In both cases the key is also remove from the document entry.
The server needs to check if the client has the permission to delete the comment. If the user has the permission the comment is deleted from the database.
https://github.com/serenity-kit/Serenity/blob/main/apps/backend/src/graphql/mutations/comment/deleteComments.ts (opens in a new tab) https://github.com/serenity-kit/Serenity/blob/main/apps/backend/src/graphql/mutations/commentReply/deleteCommentReplies.ts (opens in a new tab)
https://github.com/serenity-kit/Serenity/blob/main/apps/backend/src/database/comment/deleteComments.ts (opens in a new tab) https://github.com/serenity-kit/Serenity/blob/main/apps/backend/src/database/commentreply/deleteCommentReplies.ts (opens in a new tab)