Cryptography
Workspace Chain (Membership & Roles)

Workspace-Chain

Each workspace has a workspace-chain to determine the membership and role of a user. 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 workspace-chain in a secure storage.

If no secure storage is available e.g. in a web client the full workspace chain is downloaded and processed upon loading the web application and in stored in memory. This means a web client can't dedect a fork, except if it's happening while having an active session. See the current implementation: https://github.com/serenity-kit/Serenity/blob/main/apps/app/navigation/Navigation.tsx#L247-L249C8 (opens in a new tab)

https://github.com/serenity-kit/Serenity/blob/main/apps/app/navigation/Navigation.tsx#L255-L257 (opens in a new tab)

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.

Structure

The chain is a series of events where each event contains a

  • transaction
  • hash of the previews transaction (except for the first event)
  • list of authors containing
    • publicKey
    • signature of the combined transaction hash and the previous transaction hash

https://github.com/serenity-kit/Serenity/tree/main/packages/workspace-chain/src (opens in a new tab)

Based on the workspace-chain a current state of the members 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 https://www.npmjs.com/package/canonicalize (opens in a new tab)
  • signSeedKeypair: sodium.crypto_sign_seed_keypair(seed)
  • sign: sodium.crypto_sign_detached(message, privateKey)
  • signVerify: sodium.crypto_sign_detached(signature, message, publicKey)

Available Transaction Types

  • createChain
  • addMember
  • updateMember
  • removeMember
  • addInvitation
  • acceptInvitation
  • removeInvitation

Overview of state after a chain has been processed

  • id
  • invitations
  • members
  • lastEventHash
  • encryptedStateClock // unused at the moment
  • workspaceChainVersion // for protocol versioning

Structure

Example structure of an event:

{
  // the purpose of authors being an array is so that multiple users can sign an event and declare themselves as authors - currently not in use in Serenity
  "authors": [
    {
      // public signing key of creator of the event or others that signed it
      "publicKey": "pkcVysaH_mC-TpXzZEAAeB47rIqsWwubaM4stZQu-B4",
      // signature of the hash of the prev transaction + the hash of the current transaction
      "signature": "q5BHaR8Wu1CCA6mt-XCwmxuYpVU1-6J5E_FmxgWu_C63yfCJ9IwFh9bBMX3WPAdMN_yMFMAK3Ygapjd96qKHCg"
    }
  ],
  // hash of the prev transaction
  "prevHash": "M-LtpoR7cMzADedkf0TXAPVIXQR5kj7T-gCtcgNaFwu1IShI84B5PaXULQjQHMVANiMTyRyCcsne389jHIRvng",
  // transaction is the actual event content
  "transaction": {
    "type": "update-member",

  }
}

Creating any chain event

transactionHash = hash(canonicalize(transaction));
// previousTransactionHash is null for the createChain event
message = canonicalize(transactionHash, previousTransactionHash);
signature = sign(concat("workspace_chain", message), privateKey);

https://github.com/serenity-kit/Serenity/blob/main/packages/workspace-chain/src/createChain.ts#L19-L40 (opens in a new tab) https://github.com/serenity-kit/Serenity/blob/main/packages/workspace-chain/src/addMember.ts#L23-L42 (opens in a new tab) https://github.com/serenity-kit/Serenity/blob/main/packages/workspace-chain/src/removeMember.ts#L20-L40 (opens in a new tab) https://github.com/serenity-kit/Serenity/blob/main/packages/workspace-chain/src/addInvitation.ts#L72-L91 (opens in a new tab) https://github.com/serenity-kit/Serenity/blob/main/packages/workspace-chain/src/acceptInvitation.ts#L98-L117 (opens in a new tab) https://github.com/serenity-kit/Serenity/blob/main/packages/workspace-chain/src/removeInvitations.ts#L26-L46 (opens in a new tab) https://github.com/serenity-kit/Serenity/blob/main/packages/workspace-chain/src/updateMember.ts#L23-L42 (opens in a new tab)

Cryptographic validation for createChain event

transactionHash = hash(canonicalize(transaction));
message = canonicalize(transactionHash, null);
// for each author
signVerify(
  author.signature,
  concat("workspace_chain", message),
  author.publicKey
);

This allows to validate:

  • the transaction is authenticated by all authors
  • the transaction is not modified

https://github.com/serenity-kit/Serenity/blob/main/packages/workspace-chain/src/utils.ts#L16-L37 (opens in a new tab)

Cryptographic validation for all chain events (except createChain)

transactionHash = canonicalize(transaction);
message = canonicalize(transactionHash, previousTransactionHash);
// for each author
signVerify(
  author.signature,
  concat("workspace_chain", message),
  author.publicKey
);

This allows to validate:

  • the transaction is authenticated by all authors
  • the transaction is not modified
  • the previousTransactionHash is authenticated by all authors
  • the previousTransactionHash is not modified

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

Transaction Types and Validations

Create Chain

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

transaction = {
  type: "create",
  id: generateId(),
};

The id is to identify the workspace by ID.

Validations:

  • must be a single author

Add member

Adds a member based on the provided memberMainDeviceSigningPublicKey & memberRole.

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

Validations:

  • all authors must be members with the role ADMIN
  • the member must not exist in state constructured from the existing chain

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

Remove Member

Removes a member based on the provided memberMainDeviceSigningPublicKey

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

Validations:

  • all authors must be members with the role ADMIN
  • the member must exist in state constructured from the existing chain
  • not allowed to remove the last admin

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

Update Member

Updates a member role based on the provided memberMainDeviceSigningPublicKey & memberRole.

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

Validations:

  • all authors must be members with the role ADMIN
  • the member must exist in state constructured from the existing chain
  • not allowed to demote the last admin
  • the role must be different than the current one

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

Add invitation

A user can invite another user to a workspace. The invitation is stored in the chain as an invitation event. The purpose is to create an invitation link with the seed in the hash that can be sent out via a secure out of band channel e.g. a secure messenger. The user can sign in or sign up and then accept the invitation adding themselves as a member to the workspace.

The invitation transaction contains the following information:

  • invitationId
  • role
  • expiresAt // can only be verified by the server, but also helps admins to clean out old invitations
  • invitationSigningPublicKey
  • invitationDataSignature
  • workspaceId
invitationSigningKeys = signSeedKeypair(seed); // sodium.randombytes_buf(sodium.crypto_sign_SEEDBYTES)
invitationId = generateId();
invitationData = canonicalize({
  workspaceId,
  invitationId,
  invitationSigningPublicKey,
  role,
  expiresAt,
});
invitationDataSignature = sign(
  concat("workspace_chain_invitation", invitationData),
  invitationSigningKeys.privateKey
);
 
transaction = {
  type: "add-invitation",
  invitationId,
  role,
  expiresAt
  invitationSigningPublicKey,
  invitationDataSignature
  workspaceId,
}

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

Validations:

  • all authors must be members with the role ADMIN
  • the invitation ID must exist in state constructured from the existing chain
  • the invitationDataSignature must be valid
signVerify(
  invitationDataSignature,
  concat("workspace_chain_invitation", invitationData),
  invitationSigningPublicKey
);

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

Accept invitation

With the seed a user can accept an invitation and add themselves to the workspace members.

invitationSigningKeyPair = signSeedKeypair(seed);
invitationData = canonicalize({
  workspaceId,
  invitationId,
  invitationSigningPublicKey,
  role,
  expiresAt,
});
signVerify(
  invitationDataSignature,
  concat("workspace_chain_invitation", invitationData),
  invitationSigningPublicKey
); // first verify the invitationDataSignature
 
acceptInvitationData = canonicalize({
  workspaceId,
  invitationId,
  invitationSigningPublicKey,
  role,
  expiresAt,
});
acceptInvitationSignature = sign(
  concat("workspace_chain_accept_invitation", acceptInvitationData),
  invitationSigningKeyPair.privateKey
);

The acceptInvitationSignature proofs that the user has access to the seed of a currently valid invitation.

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

Validations:

  • accept-invitation event can only be signed by one author
  • the invitation ID must exist in state constructured from the existing chain and it's invitationSigningPublicKey and role must match the ones in the accept-invitation event
  • author is not a member of the workspace yet
  • acceptInvitationSignature must be valid
signVerify(
  acceptInvitationSignature,
  concat("workspace_chain_accept_invitation", acceptInvitationData),
  invitationSigningPublicKey
);

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

Remove Invitations

Removes invitations based on the provided invitationIds

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

Validations:

  • all authors must be members with the role ADMIN
  • the invitations must exist in state constructured from the existing chain

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

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.

NOTE: This check is not yet implemented (only in the user-chain)

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.

Miscellaneous

All types are documented here: https://github.com/serenity-kit/Serenity/blob/main/packages/workspace-chain/src/types.ts (opens in a new tab)

Open Questions

An open TODO is to verify if there are checks if the current user's mainDevice is actually part of the chain before loading or showing a workspace. Without it the server could provide a workspace the user never agreed to be part of. That said a user will not be able to decrypt anything anyway and the workspace will result in an error.