OrbisDB (Ceramic)
Save Encrypted Data to OrbisDB on the Ceramic Network
Learn how to use Lit Protocol to encrypt messages and save them to OrbisDB, a decentralized relational database built on Ceramic.
Objectives
At completion of this reading you should be able to:
- Create OrbisDB data models using the Orbis Studio.
- Authenticate users on Ceramic to allow them to write data to OrbisDB.
- Encrypt data with Lit Protocol and write mutation queries to save the encrypted data to OrbisDB.
- Decrypt data using Lit Protocol based on specified access control conditions.
What is the Ceramic Network?
Ceramic is a decentralized data network that combines the strong data provenance and verifiability typically associated with blockchain networks with the cost efficiencies, scalability, and flexible querying capabilities of traditional database systems.
How does it Work?
The Ceramic Protocol is built on decentralized event streams, where user accounts (enabled by decentralized identifiers, or DIDs) cryptographically sign data events and submit them to the network. These events are synchronized across subscribing nodes in the network and arranged into event logs, or Ceramic "streams." Each stream offers the flexibility to hold various types of content, making Ceramic suitable for a wide range of data applications, including user profiles, posts, relationships, and more, while retaining the history of changes the stream has undergone throughout its lifetime.
To extend Ceramic's functionality, most developers utilize a database interface that sits on top of the Ceramic protocol enabling flexible options for preferred database types, hosting methods, and other developer tools.
For more information on how Ceramic works, visit How it Works.
OrbisDB
OrbisDB is an open-source relational database that inherits data ownership, composability, and the decentralized properties of the network it’s built on (Ceramic). OrbisDB offers many developer-friendly features in a highly scalable way, including multiple ways to query data, a built-in dashboard UI, shared nodes for testing and iteration, and an ecosystem of plugins that extend its data functionality.
For this tutorial, we will be using Ceramic with OrbisDB to illustrate how developers can generate, store, and query simple encrypted messages.
This tutorial will use a message board example application to show how to create encrypted messages using Lit Protocol and save message instances to the Ceramic Network using OrbisDB.
To follow along, reference this example repository.
Initial Setup
For this tutorial, you will need:
- A browser wallet (MetaMask, Zerion, etc.)
- Node v20
First, clone the repository and install your dependencies:
git clone https://github.com/ceramicstudio/orbisdb-lit-example && cd orbisdb-lit-example
npm install
Open the repository in your editor of choice to continue following along.
Environment Setup
You will need to create a copy of the example environment file:
cp .env.example .env
The following sections include additional setup details:
WalletConnect
You must obtain a Client ID from WalletConnect as the demo uses it for its wallet provider. Log into your WalletConnect Cloud Dashboard and create a new project (with the "App" type selected). Once created, copy the "Project ID" and assign it to NEXT_PUBLIC_PROJECT_ID
.
OrbisDB
You will also need to configure a few variables to work with OrbisDB. To make things simple, we will use the hosted OrbisDB Studio and the shared node instance it provides for this demo, but keep in mind that you can set up your own instance whenever you want (more details at OrbisDB).
First, go ahead and sign in with your wallet.
Once signed in, the studio will show the "Contexts" tab at the top. On the right-hand side, you will see your environment ID. Go ahead and assign that value to NEXT_PUBLIC_ENV_ID
in your new .env file.
Next, set up a context. These help developers segment their data models and usage based on the applications they are meant for. Create a new context (you can call it "forum-app" if you'd like), and assign the resulting string to NEXT_PUBLIC_CONTEXT_ID
in your .env file.
Finally, you will need to create the post table using the OrbisDB model builder feature that this application will use for storing user data. The table definition this application uses for posts is as follows:
-- LIST accountRelation
table post {
to text
body text
chain text
edited DateTime
created DateTime
ciphertext text
accessControlConditions text
accessControlConditionType text
}
As this guide will explore below, some of these fields are derived from arguments used by the Lit SDK. You will therefore need to keep track of these combinations to successfully decrypt your data (which this model is designed to do).
In your Orbis Studio view, select the "Model Builder" tab at the top and create a new model named "post" using the post definition above (using string
in place of text
). Once created, assign the resulting identifier to NEXT_PUBLIC_POST_ID
in your .env file.
You can also optionally use the NEXT_PUBLIC_POST_ID
already provided for you in the env.example file. You can find the definition on Cerscan
Run the Application in Developer Mode
To run the application, first make sure you're running node version 20, and then run the dev
command:
nvm use 20
npm run dev
Unlike the ComposeDB tutorial, this application will not utilize a local node running on port 7007. Instead, this application uses the existing shared OrbisDB instance (the same one that the Orbis Studio uses). You can observe where this is implemented in the OrbisContext contextual wrapper.
Developers can optionally spin up standalone OrbisDB instances with their own dedicated endpoints (while continuing to benefit from OrbisDB's network interoperability).
You can now navigate to localhost:3000 in your browser window to begin interacting with the UI.
Authenticate with Ceramic
Upon opening the homepage in your browser, you will be prompted to connect your wallet. This will prompt an authentication request that you'll need to sign to create a new Ceramic session that will live in your browser.
Navigating back to your text editor, observe the useEffect
lifecycle hook within /src/pages/index.tsx that checks the browser's local storage for an item with a "orbis:session" key. This session is derived from the Orbis Contextual Wrapper component which leverages the OrbisDB SDK to create a new session if one does not already exist:
const StartOrbisAuth = async (): Promise<OrbisConnectResult | undefined> => {
const auth = new OrbisEVMAuth(window.ethereum);
// Authenticate - this option persists the session in local storage
const authResult: OrbisConnectResult = await orbis.connectUser({
auth,
});
if (authResult.auth.session) {
return authResult;
}
return undefined;
};
The important item to recognize during this sequence is which DID method is being used. While Ceramic supports multiple DID methods, this application authorizes Ethereum accounts using @didtools/pkh-ethereum (visit User Sessions for more information).
This type of authentication flow offers a familiar "web2" experience allowing users to sign in once (thus generating a timebound session), removing the need to manually approve every transaction. In doing so, this method utilizes a root Ceramic did:pkh
account with the user's wallet, and generates a temporary and resolvable Ceramic did:key
account that lives in the browser's local storage, expiring after a default duration of 24 hours.
Once authenticated, you should now see a blank message board appear on the screen:
If you now check your local storage, you'll also see a newly generated field with a "orbis:session" key and a serialized corresponding value.
Finally, navigating back to /src/pages/index.tsx, you'll also notice that a startLitClient
method is invoked within the useEffect
hook. This method is imported from /utils/client.ts and is meant to connect your host to LIT Protocol's network:
const startLitClient = (window: Window): ILitNodeClient => {
// connect to lit
console.log("Starting Lit Client...");
const client = new LitJsSdk.LitNodeClient({
url: window.location.origin,
});
client.connect();
return client as ILitNodeClient;
};
Generate Encrypted Messages
Now that we are authenticated with Ceramic, we can go ahead and send messages to the network. In your text editor, you'll notice that the component defined in /src/components/Chat.tsx
imports and returns a <ChatInputBox />
component (using the raw message contents and the user's address as props). If you navigate into /src/fragments/chatinputbox.tsx
, you'll find a flow that involves both encryption with LIT and saving to Ceramic.
Locate the doSendMessage
method definition. You'll notice that an array named accessControlConditions
is defined within this method that uses Boolean Logic discussed in our access control section. In this simple example, we're setting access control conditions based on the user's address (in this case, requiring that the user's address be strictly equal to the one we're currently signed in with).
Next, you'll find an encrypted
constant assigned to the evaluated result of invoking encryptWithLit
, using the Lit client instance, the raw message, access control conditions, and the assigned chain as arguments. This method is imported from /utils/lit.ts. Similar to the Ceramic authentication flow outlined in the previous section, encryptWithLit
first invoked a child method called checkAndSignAuthMessage
that checks for an existing cryptographic authentication signature and creates one if it does not exist. The result of this signature is then stored in local storage so the user doesn't have to sign each time they perform an operation.
Observe how the child methods within encryptWithLit
use the original arguments to eventually return an object that we will then save to OrbisDB.
Back in /src/fragments/chatinputbox.tsx
, observe how the insert
method on our orbis
client class instance is invoked with the values we just generated from the LIT encryption sequence. It's important to note that mutation queries (such as this one) only work when a user is authenticated. Since we are importing the useOrbisContext
wrapper from /context/OrbisContext.tsx, we are able to access the authenticated session we established in the last section from within any child components. You can also see how the createPosts
mutation query accesses the table definitions we created in the Orbis Studio by importing them as client environment variables:
const accessControlConditions = [
{
contractAddress: "",
standardContractType: "",
chain,
method: "",
parameters: [":userAddress"],
returnValueTest: {
comparator: "=",
value: address,
},
},
];
const { ciphertext, dataToEncryptHash } = await encryptWithLit(
lit,
newMessage,
accessControlConditions,
chain
);
const stringified = JSON.stringify(accessControlConditions);
const b64 = new TextEncoder().encode(stringified);
const encoded = await encodeb64(b64);
await orbis.getConnectedUser(); // Get the connected user
const createQuery = await orbis
// insert into the posts table
.insert(POST_ID)
// using the encrypted payload and associated arguments
.value({
body: dataToEncryptHash,
to: address,
created: new Date().toISOString(),
ciphertext,
chain,
accessControlConditions: encoded,
accessControlConditionType: "accessControlConditions",
})
// ensure that the stream is associated with our OrbisDB application context
.context(CONTEXT_ID)
// execute the query
.run();
If you've followed the steps above to submit an encrypted message, your UI should now look something like this:
Querying Indexed Messages
Now that you've generated encrypted messages using LIT and saved them to your local Ceramic node using OrbisDB, you'll notice that every time you refresh the page, those messages are rendered in the UI.
If you navigate back to /src/components/Chat.tsx
, you'll be able to observe why this is happening. You'll notice that a getMessages
method is tied to the useEffect
lifecycle hook. When invoked, this method queries your imported OrbisDB client using the option of running raw SQL. It's important to note that, unlike mutation queries, this read request works regardless if someone is authenticated or not:
const user = await orbis.getConnectedUser();
if (user) {
const query = await orbis
.select()
// using raw SQL
.raw(
`SELECT *
FROM ${env.NEXT_PUBLIC_POST_ID} as post
ORDER BY created DESC`
)
.run();
const queryResult = query.rows as Post[];
if (queryResult.length) {
queryResult.forEach((el: any) => {
setChatMessages((prevMessages) => [
...prevMessages,
{
text: el.body,
sentBy: el.controller.split(":")[4]!!,
sentAt: new Date(el.created),
isChatOwner: address === el.controller.split(":")[4]!!,
...el,
},
]);
});
}
}
Decrypting Messages
If you've followed along in the tutorial up until this point without switching to a different wallet address (meaning you're still logged into the one you used to generate a few messages), you'll see a "Decrypt" button within each message box rendered in the UI. This button renders conditionally based on whether you're the message author (see src/fragments/chatcontent.tsx
for the conditional message.isChatOwner
). However, even if we rendered this button for all users regardless of author, we can still rely on LIT to grant decryption access solely to users who meet the correct access control conditions.
In /src/fragments/chatcontent.tsx
you can observe how this works. When you click the "Decrypt" button, this action invokes the handleDecrypt
method with both an event pointer and the message contents relevant to that component instance. Similar to the sequence of events incurred when encrypting data with LIT, observe how the decryptWithLit
method is invoked (after converting the message contents to their necessary formats).
The definition for this method lives in /utils/lit.ts
, which checks for an existing cryptographic authentication signature in the browser's local storage. If the user is authorized, a decryptToString
method is later invoked using the litNodeClient
instance on the window object, along with the access control conditions, ciphertext, encrypted and hashed data, and chain. This will finally decrypt the message contents and allow us to render it in our UI.
If you press the "Decrypt" button, that corresponding message should now allow you to read its contents in plaintext:
Signing in as Different Users
If you want to simulate what the experience might look like with multiple users interacting with the application, make sure that you clear your local storage (in addition to disconnecting your current MetaMask account) each time you want to sign in with a different address.
Learn More
To learn more about Ceramic please visit the following links
Ceramic Documentation - Learn more about the Ceramic Ecosystem.
To learn more about OrbisDB please visit the following links
Not finding the answer you're looking for? Share your feedback on these docs by creating an issue in our GitHub Issues and Reports repository or get support by visiting our Support page.