Document Attachments
Each document can have one or mutliple attachments. An attachment is a file that is separately encrypted and storred on a blob storage (Cloudflare R2). The key to decrypt the file is only storred in the document.
Handling attachments separately was done for three reasons:
- Avoid attachment downloads blocking the reconstruction of the document
- Allow attachments to be downloaded on demand (not implemented yet as attachments will download automatically)
- Snapshots could become very large with multiple attachments in a document. Encrypting and submitting them could take a long time.
The downside of this approach is that once the key to attachment is known the user could download the attachment later on and decrypt it. This only can be solved with re-encryption of the attachment with a new key and deleting the old attachment.
Instead here the clients rely on the server to prevent access.
Cryptographic Dependencies and actual implementation
- generate_id:
sodium.to_base64(sodium.randombytes_buf(24))
- generateKey:
sodium.crypto_aead_xchacha20poly1305_ietf_keygen()
- 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)
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)
Encrypt and uploaded
encryptAndUploadFile
is invoked here https://github.com/serenity-kit/Serenity/blob/main/packages/editor-file-extension/src/uploadFileProsemirrorPlugin.ts (opens in a new tab)
key = generateKey();
nonce = generateNonce();
ciphertext = encryptAead(fileContent, "", nonce, key);
Once the encrypted blob exists the client will request a signed URL from the server to directly upload the blob to R2.
https://github.com/serenity-kit/Serenity/blob/main/apps/backend/src/graphql/mutations/file/initiateFileUpload.ts (opens in a new tab) (Server GraphQL Mutation) https://github.com/serenity-kit/Serenity/blob/main/apps/backend/src/database/file/initiateFileUpload.ts (opens in a new tab) (Server)
Download and decrypt
In order to download an attachment a client needs to be authenticated and have access to the document. The server will create a signed R2 URL that is only valid for a short period of time. The client will then use this URL to download the attachment.
fileContent = decryptAead(ciphertext, "", nonce, key);
https://github.com/serenity-kit/Serenity/blob/main/apps/backend/src/graphql/queries/file/fileUrl.ts (opens in a new tab) (Server GraphQL Query) https://github.com/serenity-kit/Serenity/blob/main/apps/backend/src/database/file/getFileUrl.ts (opens in a new tab) (Server)
Note about copy & pasting
Usually when copying an image inside the editor the image is serialized to an img
tag with various attributes. In our case this would include the actual key
to decrypt the image. Since this would be a potential security risk the key is only stored in memory referenced by a fileId
and not serialized to the img
tag. The fileId
is then serialized to the img
tag. When pasting the image the fileId
is used to retrieve the key from memory and store the key
in the document.
In these places the key is removed from the HTML that is copied to the clipboard: https://github.com/serenity-kit/Serenity/blob/main/packages/editor-file-extension/src/fileNodeExtension.ts#L149-L151 (opens in a new tab) https://github.com/serenity-kit/Serenity/blob/main/packages/editor-file-extension/src/fileNodeExtension.ts#L164-L166 (opens in a new tab)
When pasted again somewhere else in the same editor the key is added again and it's not necessary to upload the file again: https://github.com/serenity-kit/Serenity/blob/main/packages/editor-file-extension/src/fileNodeExtension.ts#L98 (opens in a new tab) https://github.com/serenity-kit/Serenity/blob/main/packages/editor-file-extension/src/fileNodeExtension.ts#L123 (opens in a new tab)
R2 CORS Setup
Production environment:
[
{
"AllowedOrigins": [
"https://www.serenityapp.page",
"serenity-desktop://app"
],
"AllowedMethods": [
"GET",
"PUT",
"HEAD"
],
"AllowedHeaders": [
"*"
],
"ExposeHeaders": [],
"MaxAgeSeconds": 3000
}
]
Staging environment:
[
{
"AllowedOrigins": [
"https://www.serenity.li",
"serenity-desktop://app"
],
"AllowedMethods": [
"GET",
"PUT",
"HEAD"
],
"AllowedHeaders": [
"*"
],
"ExposeHeaders": [],
"MaxAgeSeconds": 3000
}
]