Cryptography
Document Chain (Share Links)

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.

https://github.com/serenity-kit/Serenity/blob/main/packages/document-chain/src/createDocumentChain.ts (opens in a new tab)

Validations:

  • has a signingPublicKey, encryptionPublicKey and valid encryptionPublicKeySignature for the main device
signVerify(
  encryptionPublicKeySignature,
  concat("share_document_device_encryption_public_key", encryptionPublicKey),
  signingPublicKey
);

https://github.com/serenity-kit/Serenity/blob/main/packages/document-chain/src/applyCreateDocumentChainEvent.ts (opens in a new tab)

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.

https://github.com/serenity-kit/Serenity/blob/main/packages/document-chain/src/addShareDocumentDevice.ts (opens in a new tab)

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
);

https://github.com/serenity-kit/Serenity/blob/main/packages/document-chain/src/applyEvent.ts#L63-L80 (opens in a new tab)

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
);

https://github.com/serenity-kit/Serenity/blob/main/apps/app/utils/document/createDocumentShareLink.ts (opens in a new tab)

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

https://github.com/serenity-kit/Serenity/blob/main/packages/document-chain/src/removeShareDocumentDevice.ts (opens in a new tab)

Validations:

  • the device does exist in the state derived until the event

https://github.com/serenity-kit/Serenity/blob/main/packages/document-chain/src/applyEvent.ts#L82-L96 (opens in a new tab)

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.

https://github.com/serenity-kit/Serenity/blob/main/packages/document-chain/src/applyEvent.ts#L44-L55 (opens in a new tab)

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.

https://github.com/serenity-kit/Serenity/blob/main/apps/backend/src/database/document/createDocument.ts#L63-L66 (opens in a new tab)

https://github.com/serenity-kit/Serenity/blob/main/apps/backend/src/database/document/createDocumentShareLink.ts#L81-L85 (opens in a new tab)

https://github.com/serenity-kit/Serenity/blob/main/apps/backend/src/database/document/removeDocumentShareLink.ts#L54-L58 (opens in a new tab)

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.

https://github.com/serenity-kit/Serenity/blob/main/apps/app/components/page/Page.tsx#L263-L282 (opens in a new tab)

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
);

https://github.com/serenity-kit/Serenity/blob/main/apps/app/navigation/screens/sharePageScreen/sharePageScreenMachine.ts#L118-L129 (opens in a new tab)

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)