Document Chain
Each document has a document-chain to determine which share devices have been created. A share device is a device that is provided to an external user to interact only with this specific document. These entries can be revoked manually, but also expire based on the expiredAt property. In addition a role must be defined to determine the permissions of the share device. The role can be one of the following:
- viewer
- commenter
- editor
Only a workspace owner or editor can create a share device.
The chain is supposed to be immutable. Items can't be reordered and no forks are allowed. In case a fork is dedected the chain is considered invalid.
The chain is a series of events where each event contains a
- transaction
- type
- version
- previous event hash
- … custom props depending on the type
- author
- publicKey
- signature of the combined transaction hash and the previous transaction hash
https://github.com/serenity-kit/Serenity/tree/main/packages/document-chain/src (opens in a new tab)
Based on the document-chain a current state of the active share devices can be determined for each event.
Cryptographic Dependencies and actual implementation
- generate_id:
sodium.to_base64(sodium.randombytes_buf(24))
- hash:
sodium.to_base64(sodium.crypto_generichash(64, message))
- canonicalize: predictable canonicalization of JSON as defined by RFC8785 https://www.npmjs.com/package/canonicalize (opens in a new tab)
- sign:
sodium.crypto_sign_detached(message, privateKey)
- signVerify:
sodium.crypto_sign_detached(signature, message, publicKey)
- noncegen:
sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
- signingKeyPairGen:
sodium.crypto_sign_keypair()
- secretboxKeygen:
sodium.crypto_secretbox_keygen()
- encryptionKeyPairGen:
sodium.crypto_box_keypair()
- encrypt:
sodium.crypto_secretbox_easy(message, nonce, key)
- decrypt:
sodium.crypto_secretbox_opne_easy(ciphertext, nonce, key)
Available Transaction Types
- createDocumentChain
- addDocumentShareDevice
- removeDocumentShareDevice
Device structure
- signingPublicKey
- encryptionPublicKey
- encryptionPublicKeySignature (context prefix:
share_document_device_encryption_public_key
)
Overview of state after a chain has been processed
- id
- devices
- removedDevices
- eventHash
- eventVersion // for protocol versioning
Creating any chain event
transaction = {
prevEventHash, // is null for the createChain event
…
}
transactionHash = hash(canonicalize(transaction));
signature = sign(concat("document_chain", transactionHash), privateKey);
Cryptographic validation for all chain events
transactionHash = hash(canonicalize(transaction));
signVerify(
author.signature,
concat("document_chain", transactionHash),
author.publicKey
);
This allows to validate:
- the transaction is authenticated by the author
- the transaction is not modified
For the createDocumentChain event the prevEventHash is null. For all other events it must be defined. Once provided it results in the following properties:
- the previousEventHash is authenticated by the author
- the previousEventHash is not modified
Transaction Types and Validations
Create chain
transaction = {
type: "create",
id: generateId(),
prevEventHash: null,
version,
};
The id
is to identify the document by ID.
Validations:
- has a signingPublicKey, encryptionPublicKey and valid encryptionPublicKeySignature for the main device
signVerify(
encryptionPublicKeySignature,
concat("share_document_device_encryption_public_key", encryptionPublicKey),
signingPublicKey
);
Add Document Share Device
Adds a device to the document. signingPublicKey
, encryptionPublicKey
and role
are required parameters, while expiresAt
is optional. The idea behind expiresAt is that you can add short lived devices and each client, but also the server can check that expired devices are not included in e.g. workspace key rotations.
In order to prevent drifts between clients the device session for example expires earlier than the expiredAt.
Validations:
- the device does not exist in the state derived until the event
- has a signingPublicKey, encryptionPublicKey and valid encryptionPublicKeySignature
signVerify(
encryptionPublicKeySignature,
concat("share_document_device_encryption_public_key", encryptionPublicKey),
signingPublicKey
);
Note: When invoking this function it must be verified that the author has the required permissions to add a device (EDITOR
| ADMIN
). This is not part of the chain validation.
Creating a document share link
The purpose of a document share device is to create a share link that allows external people to access the document content. Currently only read-only is implemented (while the UI allows to set a role of editor
or commenter
). The share link is generated here:
// create device
deviceSigningKeyPair = signingKeyPairGen();
deviceEncryptionKeyPair = encryptionKeyPairGen();
deviceEncryptionPublicKeySignature = sign(
concat("share_document_device_encryption_public_key", deviceEncryptionKeyPair.publicKey),
deviceSigningKeyPair.privateKey
);
// encrypt device
shareLinkDeviceKey = secretboxKeygen();
nonce = noncegen();
shareLinkDeviceCiphertext = encrypt(
{
deviceSigningKeyPair.publicKey,
deviceSigningKeyPair.privateKey,
deviceEncryptionKeyPair.publicKey,
deviceEncryptionKeyPair.privateKey,
deviceEncryptionPublicKeySignature,
},
nonce,
shareLinkDeviceKey
);
The encrypted device is sent to the backend when a share link is created. During this process an access token is generated by the backend. With the token
, documentId
and shareLinkDeviceKey
the share link can be generated. It has the following structure: ${frontendOrigin}/page/${documentId}/${token}#key=${shareLinkDeviceKey}
.
Remove Document Share Device
Removes a device based on the provided signingPublicKey
Validations:
- the device does exist in the state derived until the event
Note: When invoking this function it must be verified that the author has the required permissions to add a device (EDITOR
| ADMIN
). This is not part of the chain validation.
Versioning
Each transaction includes a version of the protocol. Once the version has been increased by one transaction it can't go lower anymore. This means every client must have the same or a higher version than the last transaction in the chain otherwise the chain is considered invalid.
It also fails in case the version is higher than the currently known to prevent the chain being processed by an older client.
Server Checks
When resolving the state on the backend also the knownVersion has to passed in. This ensures are client doesn't send a chain with a higher version than the server knows.
App Integration
Whenever a new document is created a new document chain is created with it: https://github.com/serenity-kit/Serenity/pull/806/files (opens in a new tab)
This PR added querying the documentChain, adding and removing share links: https://github.com/serenity-kit/Serenity/pull/807 (opens in a new tab)
Each snapshot references the documentChain hash: https://github.com/serenity-kit/Serenity/blob/main/apps/app/components/createWorkspaceForm/CreateWorkspaceForm.tsx#L163 (opens in a new tab)
The purpose is to bind the documentChain to the snapshot. This allows to determine the current state of the documentChain based on the snapshot. This is required to determine the current state of the share devices at the given time and will be relevant in the future if a share link can add comments or edit the document.
When reconstrucing a snapshot key it is verified that the document chain the server provided to the user is the same or newer than the one already used in the snapshot. This is to prevent the server from providing an older document chain than already known.
This ensures the chain can only move forward.
Decrypting the shareLinkDevice
When the share link is used with someone external this person can use the share link to access to the document. The key is used to decrypt the box.
shareLinkDevice = decrypt(
shareLinkDeviceCiphertext,
nonce,
senderDeviceEncryptionPublicKey,
shareLinkDeviceKey
);
Instead of having a seed for generating the key pair it was decided that a secret box should be used. This ensurese that if the link gets invalidated the shareLinkDevice can't be reconstructed from the link anymore, because the server can refuse to share the secret box. With a seed used for key generation functions reconstruction would have been possible and a leaked share link would have meant a leaked share link device that could decrypt document content.
Of couse a leaked share link that has not been revoked can be used to decrypt the shareLinkDevice. So if a share link has been used before it was revoked then an attacker would have had access to the content. This is by design. The idea of this design was to improve for revoked share links.
Miscellaneous
All types are documented here: https://github.com/serenity-kit/Serenity/blob/main/packages/document-chain/src/types.ts (opens in a new tab)