Getting started
Technologies
Secsync is defining a contract between a central backend service and clients. There are helpers to setup secsync using the following technologies:
- Client/server communication: Websocket
- CRDT: Yjs or Automerge
- Backend language: JavaScript
- Client rendering: React
Secsync is not bound to these technologies and all utitlies are exposed to implement different technologies. If you are looking for a different combination please reach out.
Installation
npm install secsync
Tutorial
Let's build an end-to-end encrypted to-do list.
We start from a Yjs To-Dos application that currently only manages a To-Do list locally in a Yjs document. The whole application is built in one component.
import React, { useRef, useState } from "react";
import * as Yjs from "yjs";
import { useYArray } from "../../hooks/useYArray";
export const YjsUnsyncedTodosExample: React.FC = () => {
// initialize Yjs document
const yDocRef = useRef<Yjs.Doc>(new Yjs.Doc());
// get/define the array in the Yjs document
const yTodos: Yjs.Array<string> = yDocRef.current.getArray("todos");
// the useYArray hook ensures React re-renders once
// the array changes and returns the array
const todos = useYArray(yTodos);
// local state for the text of a new to-do
const [newTodoText, setNewTodoText] = useState("");
return (
<>
<div className="todoapp">
<form
onSubmit={(event) => {
event.preventDefault();
yTodos.push([newTodoText]);
setNewTodoText("");
}}
>
<input
placeholder="What needs to be done?"
onChange={(event) => setNewTodoText(event.target.value)}
value={newTodoText}
className="new-todo"
/>
<button className="add">Add</button>
</form>
<ul className="todo-list">
{todos.map((entry, index) => {
return (
<li key={`${index}-${entry}`}>
<div className="edit">{entry}</div>
<button
className="destroy"
onClick={() => {
yTodos.delete(index, 1);
}}
/>
</li>
);
})}
</ul>
</div>
</>
);
};
You can try it out here:
Example App
Setup useYjsSync
Now let's setup the end-to-end encrypted data sync. For now we will use the backend service used by the documentation.
const websocketEndpoint = "wss://secsync.fly.dev";
Next we want to setup the keys. While in Secsync every Snapshot (and related Updates and EphemeralMessages) can each have their own encryption keys we use a stable documentKey
. In addition we create signing keys for the client. In this tutorial we won't validate them and therefor we can generate some.
import sodium, { KeyPair } from "libsodium-wrappers";
export const YjsTodosExample: React.FC = () => {
// Can be created using sodium.randombytes_buf(sodium.crypto_aead_chacha20poly1305_IETF_KEYBYTES)
// Sodium is only used inside the component since sodium can only be used after sodium.ready
// resolved which is recommended to be checked before even mounting this component.
const documentKey = sodium.from_base64(
"MTcyipWZ6Kiibd5fATw55i9wyEU7KbdDoTE_MRgDR98"
);
const [authorKeyPair] = useState<KeyPair>(() => {
return sodium.crypto_sign_keypair();
});
…
};
Next up we adding the useYjsSync
hook. Each parameter is explained by it's code comment.
const [state, send] = useYjsSync({
// The Yjs document
yDoc: yDocRef.current,
// A unique ID identifying the document
documentId,
// The current client's signing keyPair
signatureKeyPair: authorKeyPair,
// The backend service to connect to
websocketEndpoint,
// Used to authenticate the client with the server (not checked by the documentation backend)
websocketSessionKey: "your-secret-session-key",
// Callback to return the key for the Snapshot. In this case we are going to use
// one documentKey for all Snapshots.
getSnapshotKey: async (snapshotInfo) => {
return documentKey;
},
// Callback invoked to return the necessary values to create a Snapshot.
getNewSnapshotData: async ({ id }) => {
return {
// A Snapshot of the CRDT data
data: Yjs.encodeStateAsUpdateV2(yDocRef.current),
// Encryption key for the snapshot
key: documentKey,
// Custom data that will not be encrypted, but cryptographically attached
// to the Snapshot as public data. In cryptography also referred to as
// additional authenticated data (AAD).
publicData: {},
};
},
// Callback invoked for ever snapshot, update, ephemeralMessage to validate if
// it was signed by a valid client for this document. While the encryption key
// is securing confidentiality this can be used to verify the author and possibly
// reject changes from clients that are not valid anymore e.g. a removed member.
isValidClient: async (signingPublicKey: string) => {
return true;
},
// The libsodium-wrappers API implementation. This design was chosen to allow to pass in
// other implementations e.g. react-native-libsodium for ReactNative
sodium,
});
You can try it out here by adding To-Dos and you they will load if you refresh the page since the documentId
is part of the URL. Once you refresh the last snapshot with all related updates will be downloaded.
Add Secsync DevTools
In order to make development more transparent we created a DevTool
component visualizing relevant information from the document.
import { DevTool } from "secsync-react-devtool";
// `state` and `send` from the useYjsSync hook
<DevTool state={state} send={send} />
Example App
Setup your own backend
Secsync ships with a backend utility that handles a Websocket connection including validations and handling expected error cases e.g. trying to send snapshot without having synced first. It requires 5 callbacks to be defined:
- getDocument
- createSnaphot
- createUpdate
- hasAccess
- hasBroadcastAccess
import { createWebSocketConnection } from "secsync";
import { WebSocketServer } from "ws";
const webSocketServer = new WebSocketServer();
webSocketServer.on(
"connection",
createWebSocketConnection({
createSnapshot,
createUpdate,
getDocument,
hasAccess,
hasBroadcastAccess,
})
);
Callbacks
createSnapshot
A callback that receives an object containing the snapshot
structure sent by clients. The Snapshot should be persistet to a database. In case persisting fails an error should be thrown.
createUpdate
A callback that receives an object containing the update
structure sent by clients. The Update should be persistet to a database. In case persisting fails an error should be thrown.
getDocument
A callback that should return the necessary document information. The simples implementation would just return the latest snapshot, the proofs for snapshot ancestor chain and all related updates.
A more advanced implementation should take into account if the full document or just a delta was requested and idenitfy if and if so which updates are necessary to send and only return those.
hasAccess
A callback to verify if the client has read or write access to the requested document. Should return true
if the client has access and false
in case not.
The callback is invoked with the following argument:
type HasAccessParams =
| {
action: "read";
documentId: string;
websocketSessionKey: string | undefined;
}
| {
action: "write-snapshot" | "write-update" | "send-ephemeral-message";
documentId: string;
publicKey: string;
websocketSessionKey: string | undefined;
};
hasBroadcastAccess
A callback to verify if clients are allowed to receive a new message. Should an array of true
or false
values matching the index of the websocketSessionKeys
to indicate which client has access.
The callback is invoked with the following argument:
export type HasBroadcastAccessParams = {
documentId: string;
websocketSessionKeys: string[];
};
Documentation Example Backend
A fully functional backend can be found here https://github.com/serenity-kit/secsync/tree/main/examples/backend (opens in a new tab). Keep in mind that it accepts any client and does not verify the Websocket session key/token.