Tutorial: Encrypted Data on ComposeDB

Here's one methodology you can use to encrypt data and decrypt data on ComposeDB

Tutorial: Encrypted Data on ComposeDB

Storing encrypted data that only certain users can access is an important and sometimes necessary feature for many Web3 applications. When it comes to ComposeDB on Ceramic, given that the underlying protocol entails an open and public network, any data streams can be accessed and read by any participating nodes.

In this tutorial, we will walk through one methodology you can use to encrypt data and decrypt data on ComposeDB. However, before we dive in, let’s first walk through some key concepts that will come into play.

What are DIDs?

DIDs are the W3C standard for Decentralized Identifiers. It specifies a general way of going from a string identifier, e.g. did:key:z6Mki..., to a DID document that contains public keys for signature verification and key exchange.

JOSE is a standard from IETF that stands for JSON Object Signing and Encryption, and that pretty much explains what it is. There are two main primitives in this standard: JWS (JSON Web Signatures) and JWE (JSON Web Encryption). Both of these formats allow for multiple participants: in JWS there can be one or multiple signatures over the payload, and in JWE there might be one or multiple recipients for the encrypted cleartext.

Building With Ceramic Libraries

Whether you need to authenticate users and create DID-based sessions, or you need to encrypt data to live on ComposeDB, we've created several libraries that can easily be accessed as node package dependencies. This tutorial will show you how to use these powerful tools directly.

key-did-provider-ed25519 offers a simple DID Provider that supports both signing and data encryption.

js-did is a library that allows developers to represent a user in the form of a DID. This is the main interface we're going to be looking at in this tutorial. It allows us to sign data with the currently authenticated user, encrypt data to any user (DID), and decrypt data with the currently authenticated user.

@stablelib/sha256 is a library that allows developers to encrypt data and is a subset of the larger @stablelib collection of encryption algorithms.

Setup your environment

This tutorial will walk you through how to set up encryption capabilities within an existing repository. However, if you’re looking to start from scratch, we recommend exploring our Use Ceramic App starter.

Dependencies

  • MetaMask or another Ethereum wallet
  • Ability to successfully run local Ceramic and ComposeDB Clients
  • Scaffolding to allow for local runtime composite deployments when starting your ComposeDB node

Generate Encryption Key

In your application, prompt your users to generate a special DID to be used when encrypting messages or accessing encrypted data on ComposeDB. In order to do so, you will first define a userPrompt variable that will be viewable to the user as the message shown in their wallet when signing the request, while also generating consistent entropy used by the hashing algorithm (resulting in the seed defined below).

The seed is a secret that will then be used to define a new DID class instance (using the key-did-provider-ed25519 library). Finally, execute the authenticate() method on your new DID instance, returning the new DID to be saved as a session.

import * as u8a from 'uint8arrays'
import { hash } from '@stablelib/sha256'
import { Ed25519Provider } from 'key-did-provider-ed25519'
import KeyResolver from 'key-did-resolver'
import { DID } from 'dids'

/*
this prompt can be customized to whatever message you want to display for the
user. As long as the same message is signed, it should generate the same entropy
*/

const userPrompt = "Give this app permission to read or write your private data";

const accounts = await window.ethereum.request({ method: "eth_requestAccounts" });
const entropy = await window.ethereum.request({
  method: 'personal_sign', 
  params: [u8a.toString(u8a.fromString(userPrompt), 'base16'), accounts[0],
],
});

const seed = hash(u8a.fromString(entropy.slice(2), "base16"));
const encryptionDid = new DID({
  resolver: KeyResolver.getResolver(),
  provider: new Ed25519Provider(seed),
});
await encryptionDid.authenticate();

console.log('encryptionDid', encryptionDid.id);

Encrypting and Decrypting Data

For the purpose of this tutorial, we’ll be using a simple direct message data composite. Our top-level model will only make use of two fields—a “recipient” (DID value type), and a “directMessage” (a stringified JWE).

For the purposes of this tutorial, our simple message model would read as follows:

type EncryptedMessage @createModel(accountRelation: LIST, description: "A direct message encrypted data model") {
    recipient: DID!
    directMessage: String! @string(maxLength: 100000)
}

Make sure to compile and deploy your new composite on your Ceramic client, allowing you to mutate and query the models.

Encrypt & Write

If we want to encrypt data that only the currently authenticated user can decrypt we can simply encrypt it directly with the encryptionDid. In order to avoid escaping the JSON after stringifying your resulting JWE (preventing the data from conforming to the String constraint), we can temporarily replace double quotes with backticks.

Use your composeClient instance to perform a mutation using the values generated from the encrypted message:


const cleartext = 'this is a secret message';
const jwe = await encryptionDid.createDagJWE(cleartext, [encryptionDid.id]);

//stringify your JWE object and replace escape characters
const stringified = JSON.stringify(jwe).replace(/"/g,"`");

const message = await composeClient.executeQuery(`
    mutation {
    createEncryptedMessage(
      input: {
        content: {
          recipient: "${encryptionDid.id}"
          directMessage: "${stringified}"
        }
      }
    ) {
      document {
        recipient{
          id
        }
        directMessage
      }
    }
  }
`)

Read & Decrypt

Similarly, in order to allow only authenticated users to decrypt data, we can also allow access using their encryptionDid. The following example query obtains the first message and uses the authenticated user’s encryptionDid to decrypt the message:

const query = await composeClient.executeQuery(`
      query{
        encryptedMessageIndex(first:1){
          edges{
            node{
              recipient{
                id
              }
              directMessage
            }
          }
        }
      }
      `);

const arr = query.data?.encryptedMessageIndex?.edges;

//Reverse-replacement of backticks for double-quotes prior to parsing
const string = arr[0].node.directMessage.replace(/`/g,'"');
const plaintext = await encryptionDid.decryptDagJWE(JSON.parse(string));

Multiple User Encryption

In certain situations, you may choose to grant multiple users access to encrypting and decrypting data on ComposeDB. In this situation, two components of your data composite might be comprised of one model for storing encryption keys for each user.

Store Encryption Keys

Let’s say your data model for storing encryption keys reads as follows:

type PublicEncryptionDID @createModel(accountRelation: LIST, description: "A data model to store encryption DIDs for an application") {
    author: DID! @documentAccount
		publicEncryptionDID: DID!
}

And your simplified encrypted message model reads as follows:

type EncryptedMessage @createModel(accountRelation: LIST, description: "An encrypted message data model") {
    message: String! @string(maxLength: 100000)
    recipients: [DID] @list(maxLength: 200)
}

Hopping back to our application, as we authenticate users, we can store their corresponding encryption keys:

//the method below creates a session for your user and returns their DID.id
const auth = await encryptionDid.authenticate();

const storeEncryptionKey = await composeClient.executeQuery(`
    mutation {
      createPublicEncryptionDID(
        input: {
          content: {
            publicEncryptionDID:  "${auth}"
          }
        }
      ) {
        document {
          author{
            id
          }
          publicEncryptionDID  {
            id
          }
        }
      }
    }
  `);

Writing Group-Encrypted Data

Let’s say you’ve gated a segment of your audience based on their PKH DID CeramicAccount (explained below). This section is intended to show how to encrypt data that multiple users can decrypt.

Since anyone within the Ceramic network can write to any stream, you can filter based on which CeramicAccount authored each model instance. In our example below, we’ve displayed filtering by the did:pkh. This type of DID account allows interoperability between blockchain accounts and DIDs. Each time you are prompting your users to author mutations using an Ethereum wallet, the corresponding native Ceramic account is the PKH DID.

For the full specification, please reference the PKH DID Method specification.

The example below shows how to create an encrypted message only two separate users can decrypt:

//Query first user based on PKH DID
const query1 = await composeClient.executeQuery(`
    query {
      node(id: "did:pkh...") {
        ... on CeramicAccount {
          id
          publicEncryptionDidList(first: 1) {
            edges {
              node {
                publicEncryptionDID{
                  id
                }
              }
            }
          }
        } 
      }
    }
  `);

//Query second user based on PKH DID
const query2 = await composeClient.executeQuery(`
    query {
      node(id: "did:pkh...") {
        ... on CeramicAccount {
          id
          publicEncryptionDidList(first: 1) {
            edges {
              node {
                publicEncryptionDID{
                  id
                }
              }
            }
          }
        } 
      }
    }
  `);

const results = [...query1.data?.node?.publicEncryptionDidList?.edges,
				 ...query2.data?.node?.publicEncryptionDidList?.edges];

const users = new Array();
results.forEach(el => {
  users.push(el.node.publicEncryptionDID.id);
})

const cleartext = 'this is a shared secret message';
const jwe = await encryptionDid.createDagJWE(cleartext, users);

//stringify your JWE object and replace escape characters
const stringified = JSON.stringify(jwe).replace(/"/g,"`");
const encryptedGroupMessage = await composeClient.executeQuery(`
  mutation {
    createEncryptedMessage(
      input: {
        content: {
          message: "${stringified}"
          recipients: 
            ["${users[0]}", "${users[1]}"]
        }
      }
    ) {
      document {
        message
      }
    }
  }
`);

Decoding Group-Encrypted Data

Regardless of how many encryption keys representing individual users are involved in generating a JWE, a single holder of just one of those encryption keys will still be able to decrypt the corresponding message in a similar fashion:

const query = await composeClient.executeQuery(`
    query{
      encryptedMessageIndex(first:1){
        edges{
          node{
            message
          }
        }
      }
    }
  `);

const arr = query.data?.encryptedMessageIndex?.edges;

//Reverse-replacement of backticks for double-quotes prior to parsing
const string = arr[0].node.message.replace(/`/g,'"');
const plaintext = await encryptionDid.decryptDagJWE(JSON.parse(string));

That’s it for this tutorial! Hope you enjoyed exploring one encryption methodology you can use, both when encrypting data on behalf of your users, as well as segmenting your application’s user base and allowing only those partitions the ability to decrypt relevant data.

If you want to get started with ComposeDB on Ceramic and don’t know where to start, dive into the ComposeDB Developer Docs, or walk through a detailed tutorial that features end-to-end steps using an article publishing platform as the example use case.