User Chain (Devices)


Each user has a user-chain to determine the ownership of devices. 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.

Different clients will do/can do different validations based on their capabilities. On a with a secure storage the chain for each workspace is stored in a the secure storage. The idea is that the client only asks the server only for new chain items and verifies that these are valid. In case an invalid item would appear an exception is thrown, the user informed and the entiry workspace should go into a read-only mode. Currently neither Desktop nor Mobile clients have have this implemented and store the user-chain in a secure storage.

If no secure storage is available e.g. in a web client the full user-chain per user is downloaded and processed when necessary. This means a web client can't dedect a fork. See exmaples where the user-chains reconstructed are used:

In the future a client without a secure storage could store the last chain event hash and verify that it's part of the chain. If these hashes get leaked there is some meta-data leaked but it doesn't result in leaking of encrypted content. There is a trade-off between verifying the chain and leaking meta-data.


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 (opens in a new tab)

Based on the user-chain a current state of a user including all theirs devices can be derived.

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 (opens in a new tab)
  • sign: sodium.crypto_sign_detached(message, privateKey)
  • signVerify: sodium.crypto_sign_detached(signature, message, publicKey)

Available Transaction Types

  • createUserChain
  • addDevice
  • removeDevice

Device structure

  • signingPublicKey
  • encryptionPublicKey
  • encryptionPublicKeySignature (context prefix: user_device_encryption_public_key)

Overview of state after a chain has been processed

  • id
  • email
  • mainDeviceSigningPublicKey
  • mainDeviceEncryptionPublicKey
  • mainDeviceEncryptionPublicKeySignature
  • 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("user_chain", transactionHash), privateKey);

Cryptographic validation for all chain events

transactionHash = hash(canonicalize(transaction));
  concat("user_chain", transactionHash),

This allows to validate:

  • the transaction is authenticated by the author
  • the transaction is not modified

For the createChain 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

encryptionPublicKeySignature = sign(
  concat("user_device_encryption_public_key", encryptionPublicKey),
transaction = {
  type: "create",
  id: generateId(),
  prevEventHash: null,

The id is to identify the user by ID. (opens in a new tab)


  • has a signingPublicKey, encryptionPublicKey and valid encryptionPublicKeySignature for the main device
  concat("user_device_encryption_public_key", encryptionPublicKey),
); (opens in a new tab)

Add Device

Adds a device to the user. signingPublicKey & encryptionPublicKey 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.

In addition the signing private key is used to sign the prevEventHash. This allows to verify that the user adding the device even has access to the private key of the device that is being added. Otherwise the user could add a device that is not theirs. There is no value in doing so as a user, but does not hurt to prevent it.

deviceSigningContent = canonicalize(
deviceSigningKeyProof = sign(
  concat("user_device_signing_key_proof", deviceSigningContent),
); (opens in a new tab)


  • the device does not exist in the state derived until the event
  • has a signingPublicKey, encryptionPublicKey and valid encryptionPublicKeySignature
  concat("user_device_encryption_public_key", encryptionPublicKey),
  • deviceSigningKeyProof is valid
  concat("user_device_signing_key_proof", deviceSigningContent),
); (opens in a new tab)

Remove Device

Removes a device based on the provided signingPublicKey (opens in a new tab)


  • the device does exist in the state derived until the event
  • it's not the main device that is being removed (opens in a new tab)


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. (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.


All types are documented here: (opens in a new tab)