Delivering End-to-End Encryption Using Skiff Crypto
Back to Blog

Delivering End-to-End Encryption Using Skiff Crypto

Denis Laca
March 25, 2024

While developing end-to-end encrypted sync, we set our priorities on zero knowledge encryption, with the main requirement being, that our servers and database do not possess any knowledge of your password, encryption keys, or your data.

We chose the standard stack of tools to do the job: Argon2id to hash the user password, HKDF to derive secrets from the hash, and NaCl to generate public private keypairs and to encrypt user content.

The Fully Custom Solution

Our journey started with trying implement the solution ourselves. We have quickly learned all the struggles as we fell into a hellhole of problems, trying to make all of it work.

Want to use argon in the browser? You need to set up a wasm loader in your project. Our architecture is highly dependent on WebWorkers for better performance, meaning we also needed to make wasm work in WebWorker. Want to do all of the other aforementioned things? You need to install 6 libraries, create functions that will use them, create serialization and deserialization for data you want to encrypt, solve how you will store it etc. The things we needed to figure out just kept piling on.

Using Skiff Crypto

After a few days of prototyping and trying to resolve all of the issues that come with shipping encryption on three different platforms and in different browsers, we started looking for a solution that would solve all of this in one go. We ended up using Skiff Crypto, which ticked all of our boxes.

Open-source codebase, zero configuration, and tested in production by lots of Skiff users. This relieved us from fear of potential risk or issues we could face while implementing all of the encryption in-house, or using alternative libraries. From the implementation standpoint, this means a single library to manage, with nicely wrapped crypto functions and serialization helpers that make the implementation of end-to-end encrypted sync simple. 

Datagrams

The main benefit of the library for our use case were Datagrams. Datagram represents a structure that stores data you want to encrypt and does all of the serialization and deserialization for you, including compression and versioning of the encrypted data.

We created a simple wrapper on top of the createRawCompressedJSONDatagram function that enabled us to simply pick properties we want to encrypt and then let skiff datagrams handle all of the following serialization needed for encryption.

export const createEntityDatagram = (
    entity: EncryptionDatagramType,
    serializer: DatagramSerializer,
) => {

    const rawDatagram = createRawCompressedJSONDatagram>(
        entity,
        '1.0.1',
        /1.0.\d+/,
    );

    return {
        ...rawDatagram,
        serialize: (data: T) => {
            return rawDatagram.serialize(serializer.serialize(data));
        },
        deserialize: (data: Uint8Array) => {
            return serializer.deserialize(
                rawDatagram.deserialize(data, rawDatagram.version),
            );
        },
    };
};

This way, we created datagrams for each entity we chose encrypt and register it to the encryption factory

export const encryptedProperties: (keyof Document)[] = [
    'content',
    'title',
    'start',
    'end',
    'mdContent',
    'clip',
];

export const DocumentDatagram = createEntityDatagram(
    EncryptionDatagramType.DOCUMENT,
    createSerializer(encryptedProperties),
);

encryptionFactory.registerDatagram(DocumentDatagram);

From this point on, encrypting any user content meant calling a simple encrypt function that would return a nicely serialized string that can be easily synced and stored in our database.

const encryptEntity = (entity: EncryptableEntity, keys: PrivateKeyPair): string => {
   const datagramEncryption = encryptionFactory
      .symmetric()
      .datagram(entity.type)
      .build();
   // since we store entity encryption key encrypted on entity, we first need to extract and decrypt it
  
   const encryptionKey = getEntityEncryptionKey(entity.data, keys);
   
  return datagramEncryption.encrypt(
      entity.data,
      encryptionKey,
   );
}

The same goes for decryption and key extraction

const decryptEntity = (entity: EncryptableEntity, keys: PrivateKeyPair): string => {
   const datagramEncryption = encryptionFactory   
      .symmetric()
      .datagram(entity.type)
      .build();
   // since we store entity encryption key encrypted on entity, we first need to extract and decrypt it 
  
   const encryptionKey = getEntityEncryptionKey(entity.data, keys);
   
  return datagramEncryption.decrypt(
      entity.data,
      encryptionKey,
   )
}

const getEntityEncryptionKey = (data: EncryptableEntityData, keys: PrivateKeyPair): string => {
   const datagramEncryption = encryptionFactory
      .asymmetric()
      .build();
  
   return datagramEncryption.decrypt(data.encryptionKey, keys.privateKey, keys.publicKey);
}

Encrypt and decrypt calls are just a wrapper for skiff-provided functions, such as stringEncryptAsymmetric and encryptSymmetric.

Password hashing and secret key derivation

Next step was deriving hkdf from the user password and generating the private key object. With skiff, it meant calling three functions:

  • createKeyFromSecret to get the argon2id hash

  • createPasswordDerivedSecret HKDF to get the derived secret from the hash

  • generatePublicPrivateKeyPair to generate the keypair

All of the underlying logic is handled by Skiff Crypto, there was no configuration required on our side to make it work. 

To encrypt the user's keypair, we simply used the same principle as in the wrapper functions described above.

const encryptUserKeypair = (keypair: SigningAndEncryptionKeypairs, hkdf: string): string => {
   const datagramEncryption = encryptionFactory
      .symmetric()
      .datagram(EncryptionDatagramType.USER_KEYPAIR)
      .build();
  
   return datagramEncryption.encrypt(keypair, hkdf);
}

We managed to prototype the first functioning version of the end-to-end encrypted sync in just 2 days and roll it out to users for testing in less than 2 weeks. If you have any questions feel free to reach out to us!