├── .github
├── FUNDING.yml
└── workflows
│ ├── deploy.yml
│ └── tests-and-checks.yml
├── .gitignore
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── benchmarks
├── automerge-paper.json.gz
└── snapshots
│ ├── .gitignore
│ ├── README.md
│ ├── initData.js
│ ├── loadAutomerge.js
│ ├── loadSecsyncAutomerge.js
│ ├── loadSecsyncYjs2.js
│ ├── loadYjs.js
│ ├── loadYjs2.js
│ └── package.json
├── docker-compose.yml
├── documentation
├── .gitignore
├── LICENSE
├── README.md
├── archive
│ ├── protocol-suggestions.md
│ └── snapshot-verifcation-suggestions.md
├── components
│ ├── AuthorizedAuthorsExample
│ │ └── AuthorizedAuthorsExample.tsx
│ ├── AutomergeTodosExample.tsx
│ ├── Code.tsx
│ ├── Footer.tsx
│ ├── Logo.tsx
│ ├── Pre.tsx
│ ├── SimpleExampleWrapper.tsx
│ ├── Table.tsx
│ ├── Td.tsx
│ ├── Th.tsx
│ ├── Tr.tsx
│ ├── YjsLocalFirstExample
│ │ ├── YjsLocalFirstExample.tsx
│ │ ├── deserialize.ts
│ │ └── serialize.ts
│ ├── YjsProsemirrorExample.tsx
│ ├── YjsTiptapExample.tsx
│ ├── YjsTldrawExample
│ │ ├── Dynamic.tsx
│ │ ├── YjsTldrawExample.tsx
│ │ ├── default_store.ts
│ │ └── useYjsSecSyncStore.ts
│ ├── YjsTodosExample
│ │ └── YjsTodosExample.tsx
│ └── YjsUnsyncedTodosExample
│ │ └── YjsUnsyncedTodosExample.tsx
├── netlify.toml
├── next-env.d.ts
├── next.config.js
├── package.json
├── pages
│ ├── _app.tsx
│ ├── _meta.json
│ ├── blog.mdx
│ ├── blog
│ │ ├── test1.mdx
│ │ └── test2.mdx
│ ├── docs
│ │ ├── _meta.json
│ │ ├── api
│ │ │ ├── _meta.json
│ │ │ ├── client.mdx
│ │ │ └── server.mdx
│ │ ├── architecture-design.mdx
│ │ ├── benchmarks.mdx
│ │ ├── error-handling.mdx
│ │ ├── faq.mdx
│ │ ├── future-ideas.mdx
│ │ ├── getting-started.mdx
│ │ ├── integration-examples
│ │ │ ├── _meta.json
│ │ │ ├── automerge-todos.mdx
│ │ │ ├── yjs-prosemirror.mdx
│ │ │ ├── yjs-tiptap.mdx
│ │ │ └── yjs-tldraw.mdx
│ │ ├── other-examples
│ │ │ ├── authorized-authors.mdx
│ │ │ └── yjs-local-first.mdx
│ │ ├── security_and_privacy
│ │ │ ├── _meta.json
│ │ │ ├── considerations.mdx
│ │ │ └── threat_library.mdx
│ │ ├── server
│ │ │ ├── _meta.json
│ │ │ ├── introduction.mdx
│ │ │ └── setup-guide.mdx
│ │ └── specification.mdx
│ ├── imprint.mdx
│ └── index.mdx
├── postcss.config.js
├── public
│ ├── automerge_loading_time_comparison.png
│ ├── automerge_size_comparison.png
│ ├── favicon.png
│ ├── favicon.svg
│ ├── secsync-document-representation.png
│ ├── secsync-logo.svg
│ ├── secsync-time-representation.png
│ ├── yjs_loading_time_comparison.png
│ └── yjs_size_comparison.png
├── styles
│ ├── global.css
│ ├── prosemirror-custom.css
│ ├── prosemirror-example-setup.css
│ ├── prosemirror-menu.css
│ ├── tiptap-extension-y-awareness.css
│ ├── todos.css
│ └── y-prosemirror.css
├── tailwind.config.js
├── theme.config.tsx
└── tsconfig.json
├── examples
└── backend
│ ├── .dockerignore
│ ├── .env.example
│ ├── .vscode
│ └── extensions.json
│ ├── Dockerfile
│ ├── README.md
│ ├── fly.toml
│ ├── package.json
│ ├── prisma
│ ├── migrations
│ │ ├── 20230915120302_initial_schema
│ │ │ └── migration.sql
│ │ └── migration_lock.toml
│ └── schema.prisma
│ ├── src
│ ├── database
│ │ ├── createSnapshot.ts
│ │ ├── createUpdate.ts
│ │ ├── getOrCreateDocument.ts
│ │ └── prisma.ts
│ ├── index.ts
│ └── utils
│ │ └── serialize.ts
│ └── tsconfig.json
├── ngrok.yml
├── package.json
├── packages
├── secsync-react-automerge
│ ├── CHANGELOG.md
│ ├── babel.config.js
│ ├── package-json-build-script.js
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ └── useAutomergeSync.ts
│ ├── test
│ │ └── config
│ │ │ └── jestTestSetup.ts
│ ├── tsconfig.json
│ └── tsup.config.ts
├── secsync-react-devtool
│ ├── CHANGELOG.md
│ ├── babel.config.js
│ ├── package-json-build-script.js
│ ├── package.json
│ ├── src
│ │ ├── DevTool.tsx
│ │ ├── index.ts
│ │ └── uniqueId.ts
│ ├── test
│ │ └── config
│ │ │ └── jestTestSetup.ts
│ ├── tsconfig.json
│ └── tsup.config.ts
├── secsync-react-yjs
│ ├── CHANGELOG.md
│ ├── babel.config.js
│ ├── package-json-build-script.js
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ └── useYjsSync.ts
│ ├── test
│ │ └── config
│ │ │ └── jestTestSetup.ts
│ ├── tsconfig.json
│ └── tsup.config.ts
├── secsync-server
│ ├── CHANGELOG.md
│ ├── babel.config.js
│ ├── package-json-build-script.js
│ ├── package.json
│ ├── src
│ │ ├── createWebSocketConnection.test.ts
│ │ ├── createWebSocketConnection.ts
│ │ ├── index.ts
│ │ ├── store.ts
│ │ └── utils
│ │ │ ├── canonicalizeAndToBase64.ts
│ │ │ ├── retryAsyncFunction.test.ts
│ │ │ └── retryAsyncFunction.ts
│ ├── test
│ │ └── config
│ │ │ └── jestTestSetup.ts
│ ├── tsconfig.json
│ └── tsup.config.ts
├── secsync
│ ├── CHANGELOG.md
│ ├── babel.config.js
│ ├── package-json-build-script.js
│ ├── package.json
│ ├── src
│ │ ├── createSyncMachine.connection.test.ts
│ │ ├── createSyncMachine.errors.test.ts
│ │ ├── createSyncMachine.loading-and-receiving.test.ts
│ │ ├── createSyncMachine.on-document-updated.test.ts
│ │ ├── createSyncMachine.reconnecting.test.ts
│ │ ├── createSyncMachine.sending-ephemeralMessage.test.ts
│ │ ├── createSyncMachine.sending-snapshot.test.ts
│ │ ├── createSyncMachine.sending-updates.test.ts
│ │ ├── createSyncMachine.snapshot-save-failed.test.ts
│ │ ├── createSyncMachine.ts
│ │ ├── crypto
│ │ │ ├── createSignatureKeyPair.ts
│ │ │ ├── decryptAead.ts
│ │ │ ├── encryptAead.ts
│ │ │ ├── generateId.test.ts
│ │ │ ├── generateId.ts
│ │ │ ├── hash.ts
│ │ │ ├── sign.ts
│ │ │ └── verifySignature.ts
│ │ ├── ephemeralMessage
│ │ │ ├── createEphemeralMessage.ts
│ │ │ ├── createEphemeralSession.test.ts
│ │ │ ├── createEphemeralSession.ts
│ │ │ ├── createEphemeralSessionProof.test.ts
│ │ │ ├── createEphemeralSessionProof.ts
│ │ │ ├── ephemeralMessages.test.ts
│ │ │ ├── parseEphemeralMessage.ts
│ │ │ ├── verifyAndDecryptEphemeralMessage.ts
│ │ │ ├── verifyEphemeralSessionProof.test.ts
│ │ │ └── verifyEphemeralSessionProof.ts
│ │ ├── errors.ts
│ │ ├── index.ts
│ │ ├── mocks.ts
│ │ ├── snapshot
│ │ │ ├── createInitialSnapshot.ts
│ │ │ ├── createParentSnapshotProof.test.ts
│ │ │ ├── createParentSnapshotProof.ts
│ │ │ ├── createSnapshot.ts
│ │ │ ├── isValidAncestorSnapshot.test.ts
│ │ │ ├── isValidAncestorSnapshot.ts
│ │ │ ├── isValidParentSnapshot.test.ts
│ │ │ ├── isValidParentSnapshot.ts
│ │ │ ├── parseSnapshot.ts
│ │ │ ├── parseSnapshotWithClientData.ts
│ │ │ ├── snapshot.test.ts
│ │ │ └── verifyAndDecryptSnapshot.ts
│ │ ├── types.ts
│ │ ├── update
│ │ │ ├── createUpdate.ts
│ │ │ ├── parseUpdate.ts
│ │ │ ├── update.test.ts
│ │ │ └── verifyAndDecryptUpdate.ts
│ │ └── utils
│ │ │ ├── canonicalizeAndToBase64.ts
│ │ │ ├── compareUpdateClocks.test.ts
│ │ │ ├── compareUpdateClocks.ts
│ │ │ ├── deserializeUint8ArrayUpdates.test.ts
│ │ │ ├── deserializeUint8ArrayUpdates.ts
│ │ │ ├── extractPrefixFromUint8Array.test.ts
│ │ │ ├── extractPrefixFromUint8Array.ts
│ │ │ ├── intToUint8Array.test.ts
│ │ │ ├── intToUint8Array.ts
│ │ │ ├── prefixWithUint8Array.test.ts
│ │ │ ├── prefixWithUint8Array.ts
│ │ │ ├── serializeUint8ArrayUpdates.test.ts
│ │ │ ├── serializeUint8ArrayUpdates.ts
│ │ │ ├── uint8ArrayToInt.test.ts
│ │ │ ├── uint8ArrayToInt.ts
│ │ │ ├── updateUpdateClocksEntry.ts
│ │ │ └── websocketService.ts
│ ├── test
│ │ └── config
│ │ │ └── jestTestSetup.ts
│ ├── tsconfig.json
│ └── tsup.config.ts
└── tiptap-extension-y-awareness
│ ├── CHANGELOG.md
│ ├── babel.config.js
│ ├── package-json-build-script.js
│ ├── package.json
│ ├── src
│ ├── index.ts
│ └── tiptapExtensionYAwareness.ts
│ ├── test
│ └── config
│ │ └── jestTestSetup.ts
│ ├── tsconfig.json
│ └── tsup.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── tsconfig.json
└── verifpal_and_threat_library
├── secsync_client_server.png
├── secsync_client_server.vp
├── secsync_clients.png
├── secsync_clients.vp
├── secsync_clients_ephemeral.png
├── secsync_clients_ephemeral.vp
├── secsync_verifsign_client_server.png
├── secsync_verifsign_client_server.vp
├── secsync_verifsign_clients.png
├── secsync_verifsign_clients.vp
└── threat_library.numbers
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: nikgraf
4 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: "Deploy API"
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - uses: pnpm/action-setup@v3
14 | with:
15 | version: 9
16 | - name: Build
17 | working-directory: ./examples/backend
18 | run: pnpm build
19 | - uses: superfly/flyctl-actions/setup-flyctl@master
20 | - run: flyctl deploy --remote-only ./examples/backend
21 | env:
22 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
23 |
--------------------------------------------------------------------------------
/.github/workflows/tests-and-checks.yml:
--------------------------------------------------------------------------------
1 | name: Tests and Checks
2 |
3 | on: [push]
4 |
5 | jobs:
6 | typecheck:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v3
10 | - uses: pnpm/action-setup@v3
11 | with:
12 | version: 9
13 |
14 | - name: Install dependencies
15 | run: pnpm install --frozen-lockfile
16 | - name: Generate Prisma Types
17 | run: cd examples/backend && pnpm prisma generate
18 | - name: Typecheck
19 | run: pnpm ts:check
20 | lint:
21 | runs-on: ubuntu-latest
22 | steps:
23 | - uses: actions/checkout@v3
24 | - uses: pnpm/action-setup@v3
25 | with:
26 | version: 9
27 | - name: Install dependencies
28 | run: pnpm install --frozen-lockfile
29 | - name: Linting
30 | run: pnpm lint
31 | test:
32 | runs-on: ubuntu-latest
33 | steps:
34 | - uses: actions/checkout@v3
35 | - uses: pnpm/action-setup@v3
36 | with:
37 | version: 9
38 | - name: Install dependencies
39 | run: pnpm install --frozen-lockfile
40 | - name: Test
41 | run: pnpm test
42 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | build
3 | output
4 |
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | lerna-debug.log*
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # Dependency directories
27 | node_modules/
28 |
29 | # TypeScript v1 declaration files
30 | typings/
31 |
32 | # TypeScript cache
33 | *.tsbuildinfo
34 |
35 | # Optional eslint cache
36 | .eslintcache
37 |
38 | # Optional REPL history
39 | .node_repl_history
40 |
41 | # Yarn Integrity file
42 | .yarn-integrity
43 |
44 | # dotenv environment variables file
45 | .env
46 | .env.test
47 |
48 | # parcel-bundler cache (https://parceljs.org/)
49 | .cache
50 |
51 | # Next.js build output
52 | .next
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | There is a changelog available for each package:
4 |
5 | - [secsync](./packages/secsync/CHANGELOG.md)
6 | - [secsync-server](./packages/secsync-server/CHANGELOG.md)
7 | - [secsync-react-yjs](./packages/secsync-react-yjs/CHANGELOG.md)
8 | - [secsync-react-automerge](./packages/secsync-react-automerge/CHANGELOG.md)
9 | - [secsync-react-devtool](./packages/secsync-react-devtool/CHANGELOG.md)
10 | - [tiptap-extension-y-awareness](./packages/tiptap-extension-y-awareness/CHANGELOG.md)
11 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Publishing
2 |
3 | To publish a new version you need to
4 |
5 | 1. Bump the version manually
6 | 2. `cd` into the package folder
7 | 3. Run `pnpm publish` (it will run the build and then publish the `dist` folder)
8 |
--------------------------------------------------------------------------------
/benchmarks/automerge-paper.json.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/benchmarks/automerge-paper.json.gz
--------------------------------------------------------------------------------
/benchmarks/snapshots/.gitignore:
--------------------------------------------------------------------------------
1 | automerge.*.json
2 | yjs.*.json
3 | yjs2.*.json
4 | secsync.automerge*.json
5 | secsync.yjs2.*.json
--------------------------------------------------------------------------------
/benchmarks/snapshots/README.md:
--------------------------------------------------------------------------------
1 | ## Install the Benchmarks
2 |
3 | ```sh
4 | pnpm install
5 | ```
6 |
7 | Note: The benchmarks use the secsync version published to npm. This is to avoid any performance hits that might exist in the development version.
8 |
9 | ## Run the Benchmarks
10 |
11 | ```sh
12 | pnpm init:data
13 | pnpm load:automerge
14 | pnpm load:secsync:automerge
15 | pnpm load:yjs2
16 | pnpm load:secsync:yjs2
17 | ```
18 |
19 | To reduce the amount of changes used for the benchmark uncomment this line in `initData.js`:
20 |
21 | ```js
22 | // txns = txns.slice(0, 10000);
23 | ```
24 |
--------------------------------------------------------------------------------
/benchmarks/snapshots/loadAutomerge.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const automerge = require("@automerge/automerge");
3 | const { Buffer } = require("buffer");
4 |
5 | async function snapshot() {
6 | const fileResult = JSON.parse(fs.readFileSync("./automerge.snapshot.json"));
7 | const t0 = performance.now();
8 | const result = Buffer.from(fileResult.doc, "base64");
9 | const t1 = performance.now();
10 | const doc = automerge.load(result);
11 | const t2 = performance.now();
12 | console.log(`Snapshot Base64: ${t1 - t0} milliseconds.`);
13 | console.log(`Snapshot Doc: ${t2 - t1} milliseconds.`);
14 | }
15 |
16 | async function changes() {
17 | const result = JSON.parse(
18 | fs.readFileSync("./automerge.changes.json")
19 | ).changes;
20 | let doc = automerge.init();
21 | const t0 = performance.now();
22 | const changes = result.map((change) => {
23 | return Buffer.from(change, "base64");
24 | });
25 | const t1 = performance.now();
26 | [doc] = automerge.applyChanges(doc, changes);
27 | const t2 = performance.now();
28 | console.log(`Changes Base64: ${t1 - t0} milliseconds.`);
29 | console.log(`Changes Doc: ${t2 - t1} milliseconds.`);
30 | }
31 |
32 | async function snapshotWithChanges() {
33 | const result = JSON.parse(
34 | fs.readFileSync("./automerge.snapshot-with-changes.json")
35 | );
36 | const t0 = performance.now();
37 | const docBinary = Buffer.from(result.doc, "base64");
38 | const changes = result.changes.map((change) => {
39 | return Buffer.from(change, "base64");
40 | });
41 | const t1 = performance.now();
42 | let doc = automerge.load(docBinary);
43 | [doc] = automerge.applyChanges(doc, changes);
44 | const t2 = performance.now();
45 | console.log(`Snapshot with 1000 Changes Base64: ${t1 - t0} milliseconds.`);
46 | console.log(`Snapshot with 1000 Changes Doc: ${t2 - t1} milliseconds.`);
47 | }
48 |
49 | snapshotWithChanges();
50 | changes();
51 | snapshot();
52 |
--------------------------------------------------------------------------------
/benchmarks/snapshots/loadSecsyncAutomerge.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const automerge = require("@automerge/automerge");
3 | const { Buffer } = require("buffer");
4 | const secsync = require("secsync");
5 | const sodium = require("libsodium-wrappers");
6 |
7 | async function snapshot() {
8 | await sodium.ready;
9 |
10 | const key = sodium.from_hex(
11 | "724b092810ec86d7e35c9d067702b31ef90bc43a7b598626749914d6a3e033ed"
12 | );
13 | const docId = "6e46c006-5541-11ec-bf63-0242ac130002";
14 | const snapshotId = "DJ1VrlamnVQRkaqO5lpcZXFJCWC-gsZV";
15 | const clientAKeyPair = {
16 | privateKey: sodium.from_base64(
17 | "g3dtwb9XzhSzZGkxTfg11t1KEIb4D8rO7K54R6dnxArvgg_OzZ2GgREtG7F5LvNp3MS8p9vsio4r6Mq7SZDEgw"
18 | ),
19 | publicKey: sodium.from_base64(
20 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM"
21 | ),
22 | keyType: "ed25519",
23 | };
24 |
25 | const fileResult = JSON.parse(
26 | fs.readFileSync("./secsync.automerge.snapshot.json")
27 | );
28 | const t0 = performance.now();
29 | const result = secsync.verifyAndDecryptSnapshot(
30 | fileResult,
31 | key,
32 | docId,
33 | clientAKeyPair.publicKey,
34 | sodium
35 | );
36 | const t1 = performance.now();
37 | const doc = automerge.load(result.content);
38 | const t2 = performance.now();
39 | console.log(`Snapshot Decryption: ${t1 - t0} milliseconds.`);
40 | console.log(`Snapshot Doc: ${t2 - t1} milliseconds.`);
41 | console.log(`Snapshot Decryption + Doc: ${t2 - t0} milliseconds.`);
42 | }
43 |
44 | async function changes() {
45 | await sodium.ready;
46 |
47 | const key = sodium.from_hex(
48 | "724b092810ec86d7e35c9d067702b31ef90bc43a7b598626749914d6a3e033ed"
49 | );
50 | const snapshotId = "DJ1VrlamnVQRkaqO5lpcZXFJCWC-gsZV";
51 |
52 | const result = JSON.parse(
53 | fs.readFileSync("./secsync.automerge.changes.json")
54 | ).updates;
55 | let doc = automerge.init();
56 | const t0 = performance.now();
57 | const changes = result.map((update, index) => {
58 | const { content, clock, error } = secsync.verifyAndDecryptUpdate(
59 | update,
60 | key,
61 | snapshotId,
62 | index - 1,
63 | sodium
64 | );
65 | return content;
66 | });
67 | const t1 = performance.now();
68 | [doc] = automerge.applyChanges(doc, changes);
69 | const t2 = performance.now();
70 | console.log(`Changes Decryption: ${t1 - t0} milliseconds.`);
71 | console.log(`Changes Decryption + Doc: ${t2 - t1} milliseconds.`);
72 | console.log(`Changes Decryption + Doc: ${t2 - t0} milliseconds.`);
73 | }
74 |
75 | async function snapshotWithChanges() {
76 | await sodium.ready;
77 |
78 | const key = sodium.from_hex(
79 | "724b092810ec86d7e35c9d067702b31ef90bc43a7b598626749914d6a3e033ed"
80 | );
81 | const docId = "6e46c006-5541-11ec-bf63-0242ac130002";
82 | const snapshotId = "DJ1VrlamnVQRkaqO5lpcZXFJCWC-gsZV";
83 | const clientAKeyPair = {
84 | privateKey: sodium.from_base64(
85 | "g3dtwb9XzhSzZGkxTfg11t1KEIb4D8rO7K54R6dnxArvgg_OzZ2GgREtG7F5LvNp3MS8p9vsio4r6Mq7SZDEgw"
86 | ),
87 | publicKey: sodium.from_base64(
88 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM"
89 | ),
90 | keyType: "ed25519",
91 | };
92 |
93 | const fileResult = JSON.parse(
94 | fs.readFileSync("./secsync.automerge.snapshot-with-changes.json")
95 | );
96 | const t0 = performance.now();
97 | const snapshot = secsync.verifyAndDecryptSnapshot(
98 | fileResult.snapshot,
99 | key,
100 | docId,
101 | clientAKeyPair.publicKey,
102 | sodium
103 | );
104 |
105 | const initialClock = fileResult.updates[0].publicData.clock;
106 |
107 | const changes = fileResult.updates.map((update, index) => {
108 | const { content, clock, error } = secsync.verifyAndDecryptUpdate(
109 | update,
110 | key,
111 | snapshotId,
112 | initialClock + index - 1,
113 | sodium
114 | );
115 | return content;
116 | });
117 |
118 | const t1 = performance.now();
119 | let doc = automerge.load(snapshot.content);
120 | [doc] = automerge.applyChanges(doc, changes);
121 | const t2 = performance.now();
122 | console.log(`Snapshot + Updates Decryption: ${t1 - t0} milliseconds.`);
123 | console.log(`Snapshot + Updates Doc: ${t2 - t1} milliseconds.`);
124 | console.log(`Snapshot + Updates Decryption + Doc: ${t2 - t0} milliseconds.`);
125 | }
126 |
127 | snapshotWithChanges();
128 | changes();
129 | snapshot();
130 |
--------------------------------------------------------------------------------
/benchmarks/snapshots/loadYjs.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const y = require("yjs");
3 |
4 | async function snapshot() {
5 | const fileResult = JSON.parse(fs.readFileSync("./yjs.snapshot.json"));
6 | const t0 = performance.now();
7 | const yDoc = new y.Doc();
8 | const result = Uint8Array.from(atob(fileResult.doc), (c) => c.charCodeAt(0));
9 | const t1 = performance.now();
10 | y.applyUpdate(yDoc, result);
11 | const t2 = performance.now();
12 | console.log(`Snapshot Base64: ${t1 - t0} milliseconds.`);
13 | console.log(`Snapshot Doc: ${t2 - t1} milliseconds.`);
14 | }
15 |
16 | async function changes() {
17 | const fileResult = JSON.parse(fs.readFileSync("./yjs.changes.json"));
18 | const yDoc = new y.Doc();
19 | const t0 = performance.now();
20 | const result = fileResult.changes.map((change) => {
21 | return Uint8Array.from(atob(change), (c) => c.charCodeAt(0));
22 | });
23 | const t1 = performance.now();
24 | result.forEach((change) => {
25 | y.applyUpdate(yDoc, change);
26 | });
27 | const t2 = performance.now();
28 | console.log(`Changes Base64: ${t1 - t0} milliseconds.`);
29 | console.log(`Changes Doc: ${t2 - t1} milliseconds.`);
30 | }
31 |
32 | async function snapshotWithChanges() {
33 | const fileResult = JSON.parse(
34 | fs.readFileSync("./yjs.snapshot-with-changes.json")
35 | );
36 | const t0 = performance.now();
37 | const yDoc = new y.Doc();
38 | const resultDoc = Uint8Array.from(atob(fileResult.doc), (c) =>
39 | c.charCodeAt(0)
40 | );
41 | const changes = fileResult.changes.map((change) => {
42 | return Uint8Array.from(atob(change), (c) => c.charCodeAt(0));
43 | });
44 | const t1 = performance.now();
45 | y.applyUpdate(yDoc, resultDoc);
46 | changes.forEach((change) => {
47 | y.applyUpdate(yDoc, change);
48 | });
49 | const t2 = performance.now();
50 | console.log(`Snapshot with 1000 Changes Base64: ${t1 - t0} milliseconds.`);
51 | console.log(`Snapshot with 1000 Changes Doc: ${t2 - t1} milliseconds.`);
52 | }
53 |
54 | snapshotWithChanges();
55 | changes();
56 | snapshot();
57 |
--------------------------------------------------------------------------------
/benchmarks/snapshots/loadYjs2.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const y = require("yjs");
3 |
4 | async function snapshot() {
5 | const fileResult = JSON.parse(fs.readFileSync("./yjs2.snapshot.json"));
6 | const t0 = performance.now();
7 | const result = Uint8Array.from(atob(fileResult.doc), (c) => c.charCodeAt(0));
8 | const t1 = performance.now();
9 | const yDoc = new y.Doc();
10 | y.applyUpdateV2(yDoc, result);
11 | const t2 = performance.now();
12 | console.log(`Snapshot Base64: ${t1 - t0} milliseconds.`);
13 | console.log(`Snapshot Doc: ${t2 - t1} milliseconds.`);
14 | }
15 |
16 | async function changes() {
17 | const fileResult = JSON.parse(fs.readFileSync("./yjs2.changes.json"));
18 | const t0 = performance.now();
19 | const result = fileResult.changes.map((change) => {
20 | return Uint8Array.from(atob(change), (c) => c.charCodeAt(0));
21 | });
22 | const t1 = performance.now();
23 | const yDoc = new y.Doc();
24 | result.forEach((change) => {
25 | y.applyUpdateV2(yDoc, change);
26 | });
27 | const t2 = performance.now();
28 | console.log(`Changes Base64: ${t1 - t0} milliseconds.`);
29 | console.log(`Changes Doc: ${t2 - t1} milliseconds.`);
30 | }
31 |
32 | async function snapshotWithChanges() {
33 | const fileResult = JSON.parse(
34 | fs.readFileSync("./yjs2.snapshot-with-changes.json")
35 | );
36 | const t0 = performance.now();
37 | const resultDoc = Uint8Array.from(atob(fileResult.doc), (c) =>
38 | c.charCodeAt(0)
39 | );
40 | const changes = fileResult.changes.map((change) => {
41 | return Uint8Array.from(atob(change), (c) => c.charCodeAt(0));
42 | });
43 | const t1 = performance.now();
44 | const yDoc = new y.Doc();
45 | y.applyUpdateV2(yDoc, resultDoc);
46 | changes.forEach((change) => {
47 | y.applyUpdateV2(yDoc, change);
48 | });
49 | const t2 = performance.now();
50 | console.log(`Snapshot with 1000 Changes Base64: ${t1 - t0} milliseconds.`);
51 | console.log(`Snapshot with 1000 Changes Doc: ${t2 - t1} milliseconds.`);
52 | }
53 |
54 | snapshotWithChanges();
55 | changes();
56 | snapshot();
57 |
--------------------------------------------------------------------------------
/benchmarks/snapshots/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "snapshots-benchmark",
3 | "version": "0.0.1",
4 | "scripts": {
5 | "load:secsync:automerge": "node loadSecsyncAutomerge.js",
6 | "load:secsync:yjs2": "node loadSecsyncYjs2.js",
7 | "load:automerge": "node loadAutomerge.js",
8 | "load:yjs": "node loadYjs.js",
9 | "load:yjs2": "node loadYjs2.js",
10 | "init:data": "node initData.js",
11 | "ts:check": "echo \"No typechecking\"",
12 | "test": "echo No tests yet",
13 | "lint": "echo \"No linting setup\""
14 | },
15 | "dependencies": {
16 | "@automerge/automerge": "^2.2.2",
17 | "libsodium-wrappers": "^0.7.13",
18 | "secsync": "^0.5.0",
19 | "yjs": "^13.6.15"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | postgres:
4 | image: postgres:latest
5 | ports:
6 | - "5432:5432"
7 | environment:
8 | POSTGRES_USER: prisma
9 | POSTGRES_PASSWORD: prisma
10 | volumes:
11 | - postgres:/var/lib/postgresql/data
12 | # Make sure log colors show up correctly
13 | tty: true
14 | volumes:
15 | postgres:
--------------------------------------------------------------------------------
/documentation/.gitignore:
--------------------------------------------------------------------------------
1 | .next
2 | node_modules
3 |
--------------------------------------------------------------------------------
/documentation/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Naisho GmbH
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/documentation/README.md:
--------------------------------------------------------------------------------
1 | # Secsync Documentation
2 |
3 | ## Local Development
4 |
5 | ```sh
6 | pnpm install
7 | ```
8 |
9 | Then, run
10 |
11 | ```sh
12 | pnpm dev
13 | ```
14 |
15 | to start the development server and visit localhost:3000.
16 |
17 | ## License
18 |
19 | This project is licensed under the MIT License.
20 |
--------------------------------------------------------------------------------
/documentation/archive/snapshot-verifcation-suggestions.md:
--------------------------------------------------------------------------------
1 | ## Verifying Snapshots
2 |
3 | It should not be possible for the server to send a client an old snapshot if the client has a reference to a newer one. The purpose is to reduce the attack vector that the server can send an old snapshot.
4 |
5 | ### Option A
6 |
7 | The best option would be cryptographic accumulator where the set of membership entries are the snapshot IDs. The benefit is that cryptographic accumulators are constant size, but still it would be possible to verify the server is sending a snapshot which includes the last snapshot a client is aware of.
8 |
9 | A solid and efficient cryptographic accumulator available in JavaScript is probably a research project by itself.
10 |
11 | ### Option B
12 |
13 | A hash chain could be used. Users would store the hash of the last seen snapshot, and when requesting a new version the server send the snapshot and all hashes and hashinformation to reconstruct the chain.
14 |
15 | This probably has quite an overhead.
16 |
17 | ### Option C
18 |
19 | A logical clock that is signed by the users. Users would only need to store the latest version they know.
20 |
--------------------------------------------------------------------------------
/documentation/components/Code.tsx:
--------------------------------------------------------------------------------
1 | import cn from "clsx";
2 | import type { ComponentProps, ReactElement } from "react";
3 |
4 | export const Code = ({
5 | children,
6 | className,
7 | ...props
8 | }: ComponentProps<"code">): ReactElement => {
9 | const hasLineNumbers = "data-line-numbers" in props;
10 | return (
11 |
22 | {children}
23 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/documentation/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import cn from "clsx";
2 | import Image from "next/image";
3 | import type { ReactElement } from "react";
4 |
5 | export function Footer({ menu }: { menu?: boolean }): ReactElement {
6 | return (
7 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/documentation/components/Logo.tsx:
--------------------------------------------------------------------------------
1 | export type LogoProps = {
2 | color?: string;
3 | height?: number;
4 | hoverEffect?: boolean;
5 | };
6 |
7 | import LogoSvg from "../public/secsync-logo.svg";
8 |
9 | export const Logo = ({ color = "currentColor", height = 20 }: LogoProps) => {
10 | return (
11 |
12 |
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/documentation/components/Pre.tsx:
--------------------------------------------------------------------------------
1 | import cn from "clsx";
2 | import type { ComponentProps, ReactElement } from "react";
3 | import { useRef } from "react";
4 |
5 | export const Pre = ({
6 | children,
7 | className,
8 | hasCopyCode,
9 | filename,
10 | ...props
11 | }: ComponentProps<"pre"> & {
12 | filename?: string;
13 | hasCopyCode?: boolean;
14 | }): ReactElement => {
15 | const preRef = useRef(null);
16 | const subpixel = false;
17 |
18 | return (
19 |
20 | {filename && (
21 |
22 | {filename}
23 |
24 | )}
25 |
38 | {children}
39 |
40 |
&]:nx-opacity-100 focus-within:nx-opacity-100",
43 | "nx-flex nx-gap-1 nx-absolute nx-m-[11px] nx-right-0",
44 | filename ? "nx-top-8" : "nx-top-0"
45 | )}
46 | >
47 |
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/documentation/components/SimpleExampleWrapper.tsx:
--------------------------------------------------------------------------------
1 | import { default as sodium } from "libsodium-wrappers";
2 | import { Link } from "nextra-theme-docs";
3 | import { useEffect, useRef, useState } from "react";
4 | import { generateId } from "secsync";
5 |
6 | type Props = {
7 | component: React.ComponentType<{
8 | documentId: string | null;
9 | documentKey: Uint8Array | null;
10 | }>;
11 | generateDocumentKey: boolean;
12 | };
13 |
14 | const SimpleExampleWrapper: React.FC = ({
15 | component,
16 | generateDocumentKey,
17 | }) => {
18 | const [isReady, setIsReady] = useState(false);
19 | const documentKeyRef = useRef(null);
20 | const documentIdRef = useRef(null);
21 |
22 | const updateHashParams = () => {
23 | const paramsString = window.location.hash.slice(1);
24 | const searchParams = new URLSearchParams(paramsString);
25 | if (documentIdRef.current) {
26 | searchParams.set("id", documentIdRef.current);
27 | }
28 | if (generateDocumentKey !== false && documentKeyRef.current) {
29 | searchParams.set("key", sodium.to_base64(documentKeyRef.current));
30 | }
31 | window.location.hash = searchParams.toString();
32 | };
33 |
34 | const initialize = async () => {
35 | if (typeof window === "undefined") return;
36 |
37 | await sodium.ready;
38 |
39 | let documentKey: Uint8Array | null = null;
40 | try {
41 | const paramsString = window.location.hash.slice(1);
42 | const searchParams = new URLSearchParams(paramsString);
43 | const keyString = searchParams.get("key");
44 | if (!keyString) throw new Error("No key found in URL params");
45 | documentKey = sodium.from_base64(keyString);
46 | } catch (err) {
47 | } finally {
48 | if (generateDocumentKey !== false && !documentKey) {
49 | documentKey = sodium.randombytes_buf(
50 | sodium.crypto_aead_chacha20poly1305_IETF_KEYBYTES
51 | );
52 | }
53 | }
54 |
55 | let documentId: string | null = null;
56 | try {
57 | const paramsString = window.location.hash.slice(1);
58 | const searchParams = new URLSearchParams(paramsString);
59 | documentId = searchParams.get("id");
60 | } catch (err) {
61 | } finally {
62 | if (!documentId) {
63 | documentId = generateId(sodium);
64 | }
65 | documentId;
66 | }
67 |
68 | documentKeyRef.current = documentKey;
69 | documentIdRef.current = documentId;
70 | setIsReady(true);
71 | };
72 |
73 | useEffect(() => {
74 | initialize();
75 |
76 | window.addEventListener("hashchange", updateHashParams);
77 | return () => {
78 | window.removeEventListener("hashchange", updateHashParams);
79 | };
80 | }, []);
81 |
82 | useEffect(() => {
83 | updateHashParams();
84 | });
85 |
86 | if (typeof window === "undefined" || !isReady) return null;
87 |
88 | const searchParams = new URLSearchParams("");
89 | if (documentIdRef.current) {
90 | searchParams.set("id", documentIdRef.current);
91 | }
92 | if (generateDocumentKey !== false && documentKeyRef.current) {
93 | searchParams.set("key", sodium.to_base64(documentKeyRef.current));
94 | }
95 | const shareUrl = `${window.location.origin}${
96 | window.location.pathname
97 | }#${searchParams.toString()}`;
98 |
99 | const Component = component;
100 |
101 | return (
102 |
103 |
104 |
Share link
105 |
106 | Open the link in another tab or device to experience how the content
107 | gets synced via secsync.
108 |
109 |
110 |
115 | {shareUrl}
116 |
117 |
118 |
119 |
123 |
124 |
125 | );
126 | };
127 |
128 | export default SimpleExampleWrapper;
129 |
--------------------------------------------------------------------------------
/documentation/components/Table.tsx:
--------------------------------------------------------------------------------
1 | import cn from "clsx";
2 | import type { ComponentProps } from "react";
3 |
4 | export const Table = ({
5 | className = "",
6 | ...props
7 | }: ComponentProps<"table">) => (
8 |
12 | );
13 |
--------------------------------------------------------------------------------
/documentation/components/Td.tsx:
--------------------------------------------------------------------------------
1 | import cn from "clsx";
2 | import type { ComponentProps } from "react";
3 |
4 | export const Td = ({ className = "", ...props }: ComponentProps<"td">) => (
5 |
12 | );
13 |
--------------------------------------------------------------------------------
/documentation/components/Th.tsx:
--------------------------------------------------------------------------------
1 | import cn from "clsx";
2 | import type { ComponentProps } from "react";
3 |
4 | export const Th = ({ className = "", ...props }: ComponentProps<"th">) => (
5 |
13 | );
14 |
--------------------------------------------------------------------------------
/documentation/components/Tr.tsx:
--------------------------------------------------------------------------------
1 | import cn from "clsx";
2 | import type { ComponentProps } from "react";
3 |
4 | export const Tr = ({ className = "", ...props }: ComponentProps<"tr">) => (
5 |
6 | );
7 |
--------------------------------------------------------------------------------
/documentation/components/YjsLocalFirstExample/deserialize.ts:
--------------------------------------------------------------------------------
1 | export const deserialize = (data: string) => {
2 | return JSON.parse(data, (key, value) => {
3 | if (
4 | typeof value === "object" &&
5 | value !== null &&
6 | value.type === "Uint8Array"
7 | ) {
8 | return new Uint8Array(value.data);
9 | }
10 | return value;
11 | });
12 | };
13 |
--------------------------------------------------------------------------------
/documentation/components/YjsLocalFirstExample/serialize.ts:
--------------------------------------------------------------------------------
1 | export const serialize = (data: any) => {
2 | return JSON.stringify(data, (key, value) => {
3 | if (value instanceof Uint8Array) {
4 | return { type: "Uint8Array", data: Array.from(value) };
5 | }
6 | return value;
7 | });
8 | };
9 |
--------------------------------------------------------------------------------
/documentation/components/YjsProsemirrorExample.tsx:
--------------------------------------------------------------------------------
1 | import sodium, { KeyPair } from "libsodium-wrappers";
2 | import { exampleSetup } from "prosemirror-example-setup";
3 | import { keymap } from "prosemirror-keymap";
4 | import { schema } from "prosemirror-schema-basic";
5 | import { EditorState } from "prosemirror-state";
6 | import { EditorView } from "prosemirror-view";
7 | import React, { useEffect, useRef, useState } from "react";
8 | import { DevTool } from "secsync-react-devtool";
9 | import { useYjsSync } from "secsync-react-yjs";
10 | import {
11 | redo,
12 | undo,
13 | yCursorPlugin,
14 | ySyncPlugin,
15 | yUndoPlugin,
16 | } from "y-prosemirror";
17 | import * as Yjs from "yjs";
18 |
19 | const websocketEndpoint =
20 | process.env.NODE_ENV === "development"
21 | ? "ws://localhost:4000"
22 | : "wss://secsync.fly.dev";
23 |
24 | type Props = {
25 | documentId: string;
26 | documentKey: Uint8Array;
27 | };
28 |
29 | export const cursorBuilder = (user: { publicKey: string }) => {
30 | const cursor = document.createElement("span");
31 | cursor.classList.add("ProseMirror-yjs-cursor");
32 | cursor.setAttribute("style", `border-color: #444`);
33 | const userDiv = document.createElement("div");
34 | userDiv.setAttribute("style", `background-color: #444;`);
35 | userDiv.insertBefore(
36 | document.createTextNode(`Client PublicKey: ${user.publicKey}`),
37 | null
38 | );
39 | cursor.insertBefore(userDiv, null);
40 | return cursor;
41 | };
42 |
43 | const YjsProsemirrorExample: React.FC = ({
44 | documentId,
45 | documentKey,
46 | }) => {
47 | const [authorKeyPair] = useState(() => {
48 | // return {
49 | // privateKey: sodium.from_base64(
50 | // "g3dtwb9XzhSzZGkxTfg11t1KEIb4D8rO7K54R6dnxArvgg_OzZ2GgREtG7F5LvNp3MS8p9vsio4r6Mq7SZDEgw"
51 | // ),
52 | // publicKey: sodium.from_base64(
53 | // "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM"
54 | // ),
55 | // keyType: "ed25519",
56 | // };
57 | return sodium.crypto_sign_keypair();
58 | });
59 |
60 | const editorRef = useRef(null);
61 | const yDocRef = useRef(new Yjs.Doc());
62 |
63 | const [state, send, , yAwareness] = useYjsSync({
64 | yDoc: yDocRef.current,
65 | documentId,
66 | signatureKeyPair: authorKeyPair,
67 | websocketEndpoint,
68 | websocketSessionKey: "your-secret-session-key",
69 | onDocumentUpdated: async ({ knownSnapshotInfo }) => {},
70 | getNewSnapshotData: async ({ id }) => {
71 | return {
72 | data: Yjs.encodeStateAsUpdateV2(yDocRef.current),
73 | key: documentKey,
74 | publicData: {},
75 | };
76 | },
77 | getSnapshotKey: async (snapshot) => {
78 | return documentKey;
79 | },
80 | shouldSendSnapshot: ({ snapshotUpdatesCount }) => {
81 | // create a new snapshot if the active snapshot has more than 100 updates
82 | return snapshotUpdatesCount > 100;
83 | },
84 | isValidClient: async (signingPublicKey: string) => {
85 | return true;
86 | },
87 | sodium,
88 | logging: "debug",
89 | });
90 |
91 | const initiateEditor = () => {
92 | const yXmlFragment = yDocRef.current.getXmlFragment("document");
93 |
94 | if (editorRef.current) {
95 | editorRef.current.innerHTML = "";
96 | }
97 | let state = EditorState.create({
98 | schema,
99 | plugins: [
100 | ySyncPlugin(yXmlFragment),
101 | yCursorPlugin(yAwareness, { cursorBuilder }),
102 | yUndoPlugin(),
103 | keymap({
104 | "Mod-z": undo,
105 | "Mod-y": redo,
106 | "Mod-Shift-z": redo,
107 | }),
108 | ].concat(exampleSetup({ schema })),
109 | });
110 |
111 | return new EditorView(editorRef.current, { state });
112 | };
113 |
114 | useEffect(() => {
115 | const editorView = initiateEditor();
116 |
117 | return () => {
118 | editorView.destroy();
119 | };
120 | }, []);
121 |
122 | return (
123 | <>
124 | Loading
125 |
126 |
127 | >
128 | );
129 | };
130 |
131 | export default YjsProsemirrorExample;
132 |
--------------------------------------------------------------------------------
/documentation/components/YjsTldrawExample/Dynamic.tsx:
--------------------------------------------------------------------------------
1 | import dynamic from "next/dynamic";
2 |
3 | const ComponentWithNoSSR = dynamic(() => import("./YjsTldrawExample"), {
4 | ssr: false,
5 | });
6 |
7 | function Dynamic(props: any) {
8 | return ;
9 | }
10 |
11 | export default Dynamic;
12 |
--------------------------------------------------------------------------------
/documentation/components/YjsTldrawExample/YjsTldrawExample.tsx:
--------------------------------------------------------------------------------
1 | import { Tldraw } from "@tldraw/tldraw";
2 | import "@tldraw/tldraw/tldraw.css";
3 | import { memo } from "react";
4 | import { DevTool } from "secsync-react-devtool";
5 | import { useYjsSecSyncStore } from "./useYjsSecSyncStore";
6 |
7 | const websocketEndpoint =
8 | process.env.NODE_ENV === "development"
9 | ? "ws://localhost:4000"
10 | : "wss://secsync.fly.dev";
11 |
12 | type Props = {
13 | documentId: string;
14 | documentKey: Uint8Array;
15 | };
16 |
17 | // not necessary to re-render it - this reduces stress on the browser
18 | const MemoedTldraw = memo(Tldraw);
19 |
20 | const YjsTldrawExample: React.FC = ({ documentId, documentKey }) => {
21 | const [store, state, send] = useYjsSecSyncStore({
22 | documentId,
23 | documentKey,
24 | websocketEndpoint,
25 | });
26 | return (
27 | <>
28 |
29 |
30 |
31 |
32 |
33 | >
34 | );
35 | };
36 |
37 | export default YjsTldrawExample;
38 |
--------------------------------------------------------------------------------
/documentation/components/YjsTldrawExample/default_store.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_STORE = {
2 | store: {
3 | "document:document": {
4 | gridSize: 10,
5 | name: "",
6 | meta: {},
7 | id: "document:document",
8 | typeName: "document",
9 | },
10 | "pointer:pointer": {
11 | id: "pointer:pointer",
12 | typeName: "pointer",
13 | x: 530.703125,
14 | y: 647.30859375,
15 | lastActivityTimestamp: 1696321778989,
16 | meta: {},
17 | },
18 | "page:page": {
19 | meta: {},
20 | id: "page:page",
21 | name: "Page 1",
22 | index: "a1",
23 | typeName: "page",
24 | },
25 | "camera:page:page": {
26 | x: 0,
27 | y: 0,
28 | z: 1,
29 | meta: {},
30 | id: "camera:page:page",
31 | typeName: "camera",
32 | },
33 | "instance_page_state:page:page": {
34 | editingShapeId: null,
35 | croppingShapeId: null,
36 | selectedShapeIds: [],
37 | hoveredShapeId: null,
38 | erasingShapeIds: [],
39 | hintingShapeIds: [],
40 | focusedGroupId: null,
41 | meta: {},
42 | id: "instance_page_state:page:page",
43 | pageId: "page:page",
44 | typeName: "instance_page_state",
45 | },
46 | "instance:instance": {
47 | followingUserId: null,
48 | opacityForNextShape: 1,
49 | stylesForNextShape: {},
50 | brush: null,
51 | scribble: null,
52 | cursor: {
53 | type: "default",
54 | rotation: 0,
55 | },
56 | isFocusMode: false,
57 | exportBackground: true,
58 | isDebugMode: false,
59 | isToolLocked: false,
60 | screenBounds: {
61 | x: 0,
62 | y: 0,
63 | w: 1800,
64 | h: 670,
65 | },
66 | zoomBrush: null,
67 | isGridMode: false,
68 | isPenMode: false,
69 | chatMessage: "",
70 | isChatting: false,
71 | highlightedUserIds: [],
72 | canMoveCamera: true,
73 | isFocused: false,
74 | devicePixelRatio: 2,
75 | isCoarsePointer: false,
76 | openMenus: [],
77 | isChangingStyle: false,
78 | isReadonly: false,
79 | meta: {},
80 | id: "instance:instance",
81 | currentPageId: "page:page",
82 | typeName: "instance",
83 | },
84 | },
85 | schema: {
86 | schemaVersion: 1,
87 | storeVersion: 4,
88 | recordVersions: {
89 | asset: {
90 | version: 1,
91 | subTypeKey: "type",
92 | subTypeVersions: {
93 | image: 2,
94 | video: 2,
95 | bookmark: 0,
96 | },
97 | },
98 | camera: {
99 | version: 1,
100 | },
101 | document: {
102 | version: 2,
103 | },
104 | instance: {
105 | version: 20,
106 | },
107 | instance_page_state: {
108 | version: 4,
109 | },
110 | page: {
111 | version: 1,
112 | },
113 | shape: {
114 | version: 3,
115 | subTypeKey: "type",
116 | subTypeVersions: {
117 | group: 0,
118 | text: 1,
119 | bookmark: 1,
120 | draw: 1,
121 | geo: 7,
122 | note: 4,
123 | line: 1,
124 | frame: 0,
125 | arrow: 1,
126 | highlight: 0,
127 | embed: 4,
128 | image: 2,
129 | video: 1,
130 | },
131 | },
132 | instance_presence: {
133 | version: 5,
134 | },
135 | pointer: {
136 | version: 1,
137 | },
138 | },
139 | },
140 | };
141 |
--------------------------------------------------------------------------------
/documentation/components/YjsTodosExample/YjsTodosExample.tsx:
--------------------------------------------------------------------------------
1 | import sodium, { KeyPair } from "libsodium-wrappers";
2 | import React, { useRef, useState } from "react";
3 | import { useY } from "react-yjs";
4 | import { DevTool } from "secsync-react-devtool";
5 | import { useYjsSync } from "secsync-react-yjs";
6 | import * as Yjs from "yjs";
7 |
8 | const websocketEndpoint =
9 | process.env.NODE_ENV === "development"
10 | ? "ws://localhost:4000"
11 | : "wss://secsync.fly.dev";
12 |
13 | type Props = {
14 | documentId: string;
15 | showDevTool: boolean;
16 | };
17 |
18 | export const YjsTodosExample: React.FC = ({
19 | documentId,
20 | showDevTool,
21 | }) => {
22 | const documentKey = sodium.from_base64(
23 | "MTcyipWZ6Kiibd5fATw55i9wyEU7KbdDoTE_MRgDR98"
24 | );
25 |
26 | const [authorKeyPair] = useState(() => {
27 | return sodium.crypto_sign_keypair();
28 | });
29 |
30 | const yDocRef = useRef(new Yjs.Doc());
31 | const yTodos: Yjs.Array = yDocRef.current.getArray("todos");
32 | const todos = useY(yTodos);
33 | const [newTodoText, setNewTodoText] = useState("");
34 |
35 | const [state, send] = useYjsSync({
36 | yDoc: yDocRef.current,
37 | documentId,
38 | signatureKeyPair: authorKeyPair,
39 | websocketEndpoint,
40 | websocketSessionKey: "your-secret-session-key",
41 | getNewSnapshotData: async ({ id }) => {
42 | return {
43 | data: Yjs.encodeStateAsUpdateV2(yDocRef.current),
44 | key: documentKey,
45 | publicData: {},
46 | };
47 | },
48 | getSnapshotKey: async () => {
49 | return documentKey;
50 | },
51 | shouldSendSnapshot: ({ snapshotUpdatesCount }) => {
52 | // create a new snapshot if the active snapshot has more than 100 updates
53 | return snapshotUpdatesCount > 100;
54 | },
55 | isValidClient: async (signingPublicKey: string) => {
56 | return true;
57 | },
58 | sodium,
59 | logging: "debug",
60 | });
61 |
62 | return (
63 | <>
64 |
65 |
80 |
81 |
82 | {todos.map((entry, index) => {
83 | return (
84 |
85 | {entry}
86 | {
89 | yTodos.delete(index, 1);
90 | }}
91 | />
92 |
93 | );
94 | })}
95 |
96 |
97 |
98 |
99 |
100 | >
101 | );
102 | };
103 |
--------------------------------------------------------------------------------
/documentation/components/YjsUnsyncedTodosExample/YjsUnsyncedTodosExample.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState } from "react";
2 | import { useY } from "react-yjs";
3 | import * as Yjs from "yjs";
4 |
5 | export const YjsUnsyncedTodosExample: React.FC = () => {
6 | // initialize Yjs document
7 | const yDocRef = useRef(new Yjs.Doc());
8 | // get/define the array in the Yjs document
9 | const yTodos: Yjs.Array = yDocRef.current.getArray("todos");
10 | // the useY hook ensures React re-renders once
11 | // the array changes and returns the array
12 | const todos = useY(yTodos);
13 | // local state for the text of a new to-do
14 | const [newTodoText, setNewTodoText] = useState("");
15 |
16 | return (
17 | <>
18 |
19 |
34 |
35 |
36 | {todos.map((entry, index) => {
37 | return (
38 |
39 | {entry}
40 | {
43 | yTodos.delete(index, 1);
44 | }}
45 | />
46 |
47 | );
48 | })}
49 |
50 |
51 | >
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/documentation/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | publish = ".next"
3 |
4 | [[plugins]]
5 | package = "@netlify/plugin-nextjs"
6 |
7 | [build.environment]
8 | NODE_VERSION = "18"
9 |
--------------------------------------------------------------------------------
/documentation/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/documentation/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: false,
4 | // needed for Automerge
5 | experimental: {
6 | externalDir: true,
7 | },
8 |
9 | webpack(config, { isServer, dev }) {
10 | // Use the client static directory in the server bundle and prod mode
11 | // Fixes `Error occurred prerendering page "/"`
12 | config.output.webassemblyModuleFilename =
13 | isServer && !dev
14 | ? "../static/wasm/[modulehash].wasm"
15 | : "static/wasm/[modulehash].wasm";
16 |
17 | // Since Webpack 5 doesn't enable WebAssembly by default, we should do it manually
18 | config.experiments = { ...config.experiments, asyncWebAssembly: true };
19 |
20 | // needed for SVG importing
21 | config.module.rules.push({
22 | test: /\.svg$/i,
23 | issuer: /\.[jt]sx?$/,
24 | use: ["@svgr/webpack"],
25 | });
26 |
27 | return config;
28 | },
29 | };
30 |
31 | const withNextra = require("nextra")({
32 | theme: "nextra-theme-docs",
33 | themeConfig: "./theme.config.tsx",
34 | });
35 |
36 | module.exports = withNextra(nextConfig);
37 |
--------------------------------------------------------------------------------
/documentation/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "secsync-documentation",
3 | "version": "0.0.1",
4 | "description": "Secsync Documentation",
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "ts:check": "tsc --noEmit"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "git+https://github.com/serenity-kit/secsync.git"
14 | },
15 | "author": "Nik Graf ",
16 | "license": "MIT",
17 | "bugs": {
18 | "url": "https://github.com/serenity-kit/secsync/issues"
19 | },
20 | "dependencies": {
21 | "@automerge/automerge": "^2.2.2",
22 | "@tiptap/extension-collaboration": "^2.4.0",
23 | "@tiptap/pm": "^2.4.0",
24 | "@tiptap/react": "^2.4.0",
25 | "@tiptap/starter-kit": "^2.4.0",
26 | "@tldraw/tldraw": "2.1.4",
27 | "clsx": "^2.1.1",
28 | "core-js": "^3.37.1",
29 | "libsodium-wrappers": "^0.7.13",
30 | "next": "^14.2.3",
31 | "nextra": "^2.13.4",
32 | "nextra-theme-docs": "^2.13.4",
33 | "prosemirror-example-setup": "^1.2.2",
34 | "prosemirror-keymap": "^1.2.2",
35 | "prosemirror-model": "^1.21.0",
36 | "prosemirror-schema-basic": "^1.2.2",
37 | "prosemirror-state": "^1.4.3",
38 | "prosemirror-view": "^1.33.7",
39 | "react": "^18.3.1",
40 | "react-dom": "^18.3.1",
41 | "react-yjs": "^2.0.0",
42 | "secsync": "workspace:^",
43 | "secsync-react-automerge": "workspace:^",
44 | "secsync-react-devtool": "workspace:^",
45 | "secsync-react-yjs": "workspace:^",
46 | "tiptap-extension-y-awareness": "workspace:^",
47 | "uuid": "^9.0.1",
48 | "y-prosemirror": "^1.2.5",
49 | "y-protocols": "^1.0.6",
50 | "y-utility": "^0.1.4",
51 | "yjs": "^13.6.15"
52 | },
53 | "devDependencies": {
54 | "@svgr/webpack": "^8.1.0",
55 | "@types/libsodium-wrappers": "^0.7.14",
56 | "@types/node": "^20.13.0",
57 | "@types/react": "^18.3.3",
58 | "@types/react-dom": "^18.3.0",
59 | "@types/uuid": "^9.0.8",
60 | "autoprefixer": "^10.4.19",
61 | "postcss": "^8.4.38",
62 | "tailwindcss": "^3.4.3",
63 | "typescript": "^5.4.5"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/documentation/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | // needed as explicit import before our own styles
2 | // as they are otherwise added last and would therefore overrule our stylesheet
3 | import { Inter } from "next/font/google";
4 | import "nextra-theme-docs/style.css";
5 | import "../styles/global.css";
6 | import "../styles/prosemirror-custom.css";
7 | import "../styles/prosemirror-example-setup.css";
8 | import "../styles/prosemirror-menu.css";
9 | import "../styles/tiptap-extension-y-awareness.css";
10 | import "../styles/todos.css";
11 | import "../styles/y-prosemirror.css";
12 |
13 | const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
14 |
15 | export default function App({ Component, pageProps }: any) {
16 | return (
17 | <>
18 | {/* additionally defines the font in as elements like the theme-switch render outside of
19 | and therefore wouldn't have the right font
20 | */}
21 |
26 |
27 |
28 |
29 | >
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/documentation/pages/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "index": {
3 | "title": "Secsync",
4 | "type": "page",
5 | "theme": {
6 | "typesetting": "article"
7 | },
8 | "display": "hidden"
9 | },
10 | "docs": {
11 | "title": "Docs",
12 | "type": "page"
13 | },
14 | "blog": {
15 | "title": "Blog",
16 | "type": "page",
17 | "theme": {
18 | "sidebar": false,
19 | "toc": true,
20 | "footer": false,
21 | "breadcrumb": false,
22 | "typesetting": "article"
23 | },
24 | "display": "hidden"
25 | },
26 | "imprint": {
27 | "type": "page",
28 | "theme": {
29 | "typesetting": "article"
30 | },
31 | "display": "hidden"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/documentation/pages/blog.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | searchable: false
3 | ---
4 |
5 | # Blog
6 |
7 | import { getPagesUnderRoute } from "nextra/context";
8 | import Link from "next/link";
9 |
10 | {getPagesUnderRoute('/blog').map(page => {
11 | return (
12 |
13 |
14 |
15 | {page.meta.title || page.frontMatter?.title || page.name}
16 |
17 |
{page.frontMatter?.description}
18 |
19 | ) })}
20 |
--------------------------------------------------------------------------------
/documentation/pages/blog/test1.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | description: "This is a test description of this post."
3 | ---
4 |
5 | # Test 1
6 |
--------------------------------------------------------------------------------
/documentation/pages/blog/test2.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | description: "This is a test 2 description of this post."
3 | ---
4 |
5 | # Test 2
6 |
--------------------------------------------------------------------------------
/documentation/pages/docs/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "getting-started": "Getting started",
3 | "integration-examples": "Integration Examples",
4 | "other-examples": "Other Examples",
5 | "server": "Server",
6 | "api": "API",
7 | "error-handling": "Error Handling",
8 | "security_and_privacy": "Security & Privacy",
9 | "specification": "Specification",
10 | "faq": "FAQ",
11 | "benchmarks": "Benchmarks",
12 | "architecture-design": "Architecture Design",
13 | "future-ideas": "Future Ideas"
14 | }
15 |
--------------------------------------------------------------------------------
/documentation/pages/docs/api/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "client": "Client",
3 | "server": "Server"
4 | }
5 |
--------------------------------------------------------------------------------
/documentation/pages/docs/api/server.mdx:
--------------------------------------------------------------------------------
1 | # Server API
2 |
3 | #### createSnapshot
4 |
5 | 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.
6 |
7 | ```ts
8 | export type CreateSnapshotParams = {
9 | snapshot: SnapshotWithClientData;
10 | };
11 | ```
12 |
13 | #### createUpdate
14 |
15 | 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.
16 |
17 | ```ts
18 | export type CreateUpdateParams = {
19 | update: Update;
20 | };
21 | ```
22 |
23 | #### getDocument
24 |
25 | 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.
26 |
27 | 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.
28 |
29 | ```ts
30 | export type GetDocumentMode = "complete" | "delta";
31 |
32 | export type GetDocumentParams = {
33 | documentId: string;
34 | knownSnapshotId?: string;
35 | knownSnapshotUpdateClocks?: SnapshotUpdateClocks;
36 | mode: GetDocumentMode;
37 | };
38 | ```
39 |
40 | Note: Depending on the application the `getDocument` can be a `getOrCreateDocument` and it creates a document if the ID has not seen before.
41 |
42 | #### hasAccess
43 |
44 | 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.
45 |
46 | The callback is invoked with the following argument:
47 |
48 | ```tsx
49 | type HasAccessParams =
50 | | {
51 | action: "read";
52 | documentId: string;
53 | websocketSessionKey: string | undefined;
54 | }
55 | | {
56 | action: "write-snapshot" | "write-update" | "send-ephemeral-message";
57 | documentId: string;
58 | publicKey: string;
59 | websocketSessionKey: string | undefined;
60 | };
61 | ```
62 |
63 | #### hasBroadcastAccess
64 |
65 | 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.
66 |
67 | The callback is invoked with the following argument:
68 |
69 | ```ts
70 | export type HasBroadcastAccessParams = {
71 | documentId: string;
72 | websocketSessionKeys: string[];
73 | };
74 | ```
75 |
76 | ## additionalAuthenticationDataValidations
77 |
78 | Must be identical to the supported `additionalAuthenticationDataValidations` on the client for data parsing to function correctly.
79 |
80 | ### Documentation Example Server
81 |
82 | A fully functional server can be found here [https://github.com/serenity-kit/secsync/tree/main/examples/backend](https://github.com/serenity-kit/secsync/tree/main/examples/backend). Keep in mind that it accepts any client and does not verify the Websocket session key/token.
83 |
--------------------------------------------------------------------------------
/documentation/pages/docs/architecture-design.mdx:
--------------------------------------------------------------------------------
1 | # Architecture Design
2 |
3 | ## Goal
4 |
5 | The goal is to develop an architecture and with it a protocol to allow multiple users to collaborate on a CRDT based data structure in an end-to-end encrypted way.
6 |
7 | ## Requirements
8 |
9 | ### Actors
10 |
11 | - `User` represents a person interacting with the content.
12 | - `Client` represents the actual instance connecting to the service. A user can have one or multiple clients at the same time.
13 | - `Service` represents the server responsible to receive, persist and deliver information to the clients.
14 |
15 | ### Business Requirements
16 |
17 | - The content must be end-to-end encrypted.
18 | - The same user must be able to interact on the same document with multiple clients.
19 | - Clients must not see each others IP addresses.
20 | - When activated it must be possible to identify who wrote which content.
21 | - The user must be able to start or stop sending and/or receiving updates and be able to send updates batched later.
22 |
23 | ### System level Requirements
24 |
25 | #### Data exchange
26 |
27 | - Must support asynchronous exchange of data. This means participants don't have to be online at the same time, but still can exchange data.
28 | - Must support real-time exchange incl. awareness features e.g. cursor position.
29 | - The architecture must support clients that have to rebuild the CRDT based data structure from ground up.
30 | - The architecture must support local-first clients. These clients can be offline for a while and only sync later once they are connected again.
31 | - The architecture must support multiple CRDT implementations. In detail this means Secsync is a layer on top of a data type, where the operations are commutative. In particular Yjs and automerge should be supported.
32 | - The architecture can, but must not be decentralized. Leveraging a centralized service is a viable option.
33 |
34 | #### Security
35 |
36 | - The content of a document must only be accessible to the participants.
37 | - There are no limitations on meta data e.g. who created how many changes.
38 |
39 | #### Authorization
40 |
41 | The architecture should support two main use-cases:
42 |
43 | - Every client is verifiable through a private-public keypair. The keypairs could come from any kind of Public-Key Infrastructure or Web of Trust system. The scenario here is close groups where the public keys are verified.
44 | - Every client with access to the document ID can retrieve data and only with the shared secret can decrypt it e.g. `www.example.com/doc/{id}#{pake of the shared key}` would allow multiple anonymous participants to collaborate.
45 |
46 | #### Eventual consistency
47 |
48 | All clients receive the same set of content updates (possibly in different orders), and all clients converge to the same view of the document state as they receive the same set of control content updates.
49 |
50 | ## Design Decisions
51 |
52 | ### EphemeralMessages
53 |
54 | The session ID + session counter are stored in the encrypted data.
55 |
56 | The benefit of this design is that the id as well as the counter are not exposed to any MITM (man-in-the-middle) attacker nor the server.
57 |
58 | To make this design work it's important that the sessionId is stored per client and not in one denormalized store per sessionId. Otherwise one client could increase the counter of another making their session basically invalid.
59 |
60 | #### Process
61 |
62 | - initialize -> proof and ask for proof
63 | - validate proof -> respond with a proof
64 | - validate proof
65 | - message
66 |
67 | ### Handling of missing messages and retry strategies
68 |
69 | The snapshots are stored in order - old snapshots can be cleaned out (or at least the updates)
70 |
71 | #### Usecases
72 |
73 | - missing a update (e.g. update comes in not in order)
74 | -> the solution: have an incoming queue
75 | -> challenge: when to abort it
76 | - getting a snapshot that doesn't apply to a known one
77 | -> precondition: what if it is an old snapshot?
78 | -> the solution: reconnect and ask the server for a new version of the document
79 |
80 | #### Incoming queue
81 |
82 | ```tsx
83 | {
84 | [snaphotId]: {
85 | lastUpdatesPerAuthor: {
86 | [authorId]: {
87 | lastUpdate,
88 | updatesReceived
89 | }
90 | },
91 | }
92 | }
93 | ```
94 |
--------------------------------------------------------------------------------
/documentation/pages/docs/faq.mdx:
--------------------------------------------------------------------------------
1 | # FAQ
2 |
3 | ## Is it possible to convert from Yjs e.g. to Automerge?
4 |
5 | Secsync does not provide any conversion functionality. Even if you have the raw changes/updates from one library and apply them in the other the outcome would be different, because they are based on different algorithms.
6 |
7 | A lossy conversion is probably possible but not in scope for this project.
8 |
--------------------------------------------------------------------------------
/documentation/pages/docs/future-ideas.mdx:
--------------------------------------------------------------------------------
1 | ## Throttling updates
2 |
3 | Currently updates are created and sent as soon as possible after adding one or multiple changes. This can lead to a lot of updates being sent in a short period of time and especially when a user is not collaborating in real-time with someone else this is not necessary. In fact bundling more changes together makes it even more efficient as you need download and process less updates.
4 |
5 | Ideally in the future there would be a mode where updates are bundled together based on a timeout if no other active collaborator is present (can be checked with the ephemeralMessage sessions).
6 |
7 | To keep a good real-time experience sending every change as soon as possible is still a good practice. To even improve the experience it might make sense to apply changes one by one instead all at once when multiple active collaborators are present. (can be checked with the ephemeralMessage sessions).
8 |
9 | ## Visualize which user made which change
10 |
11 | - In Automerge the user or device public should be used as an ID.
12 | - One idea was to leverage the clientID for it and assign it the publicKey. This approach would not work.
13 | - The main issue for the userId is that it's only a uin32 which results in only 4 bytes. The publicKey itself is way longer and also some randomness would be good in case users have multiple tabs open on the same web-device using the same publicKey. In the long run it's probably better to build a parallel structure allowing users to sign a transaction and make it verifyable instead of manipulating the `clientID` directly.
14 | - Note: For EphemeralMessages we already have validation. In the user state the publicKey is injected and verified within the `useYjsSync` hook. Details can be found here: https://github.com/serenity-kit/secsync/pull/47
15 |
16 | ### Possible Meta data issues
17 |
18 | - Should users be able to identify which device of a user made a certain change?
19 |
--------------------------------------------------------------------------------
/documentation/pages/docs/integration-examples/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "yjs-tiptap": "Tiptap Editor (yjs)",
3 | "yjs-prosemirror": "Prosemirror Editor (yjs)",
4 | "yjs-tldraw": {
5 | "title": "Tldraw Whiteboard (yjs)",
6 | "display": "hidden"
7 | },
8 | "automerge-todos": "Todo List (automerge)"
9 | }
10 |
--------------------------------------------------------------------------------
/documentation/pages/docs/integration-examples/automerge-todos.mdx:
--------------------------------------------------------------------------------
1 | # Automerge Todos Example
2 |
3 | ## Instructions
4 |
5 | - Any change that you make will be encrypted and uploaded to the
6 | server.
7 | - You can refresh the page and the current state will be
8 | reconstructed.
9 | - You can share the current URL and collaborate real-time with others.
10 |
11 | ## Example
12 |
13 | import AutomergeTodosExample from "../../../components/AutomergeTodosExample";
14 | import SimpleExampleWrapper from "../../../components/SimpleExampleWrapper";
15 |
16 |
17 |
18 | ## Code
19 |
20 | The code for this example is located [here](https://github.com/serenity-kit/secsync/blob/main/documentation/components/AutomergeTodosExample.tsx).
21 |
--------------------------------------------------------------------------------
/documentation/pages/docs/integration-examples/yjs-prosemirror.mdx:
--------------------------------------------------------------------------------
1 | # Yjs ProseMirror Example
2 |
3 | ## Instructions
4 |
5 | - Any change that you make will be encrypted and uploaded to the
6 | server.
7 | - You can refresh the page and the current state will be
8 | reconstructed.
9 | - You can share the current URL and collaborate real-time with others.
10 |
11 | ## Example
12 |
13 | import SimpleExampleWrapper from "../../../components/SimpleExampleWrapper";
14 | import YjsProsemirrorExample from "../../../components/YjsProsemirrorExample";
15 |
16 |
17 |
18 | ## Code
19 |
20 | The code for this example is located [here](https://github.com/serenity-kit/secsync/blob/main/documentation/components/YjsProsemirrorExample.tsx).
21 |
--------------------------------------------------------------------------------
/documentation/pages/docs/integration-examples/yjs-tiptap.mdx:
--------------------------------------------------------------------------------
1 | # Yjs Tiptap Example
2 |
3 | ## Instructions
4 |
5 | - Any change that you make will be encrypted and uploaded to the
6 | server.
7 | - You can refresh the page and the current state will be
8 | reconstructed.
9 | - You can share the current URL and collaborate real-time with others.
10 |
11 | ## Example
12 |
13 | import SimpleExampleWrapper from "../../../components/SimpleExampleWrapper";
14 | import YjsTiptapExample from "../../../components/YjsTiptapExample";
15 |
16 |
17 |
18 | ## Code
19 |
20 | The code for this example is located [here](https://github.com/serenity-kit/secsync/blob/main/documentation/components/YjsTiptapExample.tsx).
21 |
--------------------------------------------------------------------------------
/documentation/pages/docs/integration-examples/yjs-tldraw.mdx:
--------------------------------------------------------------------------------
1 | # Yjs Tldraw Example
2 |
3 | ## Warning
4 |
5 | This example an early prototype and sometime crashes with a `Maximum call stack size exceeded`.
6 |
7 | ## Instructions
8 |
9 | - Any change that you make will be encrypted and uploaded to the
10 | server.
11 | - You can refresh the page and the current state will be
12 | reconstructed.
13 | - You can share the current URL and collaborate real-time with others.
14 |
15 | ## Example
16 |
17 | {/* need to disable SSR since Tldraw throws an error on Next SSR */}
18 | import YjsTldrawExample from "../../../components/YjsTldrawExample/Dynamic";
19 | import SimpleExampleWrapper from "../../../components/SimpleExampleWrapper";
20 |
21 |
22 |
23 | ## Code
24 |
25 | The code for this example is located [here](https://github.com/serenity-kit/secsync/tree/main/documentation/components/YjsTldrawExample).
26 |
--------------------------------------------------------------------------------
/documentation/pages/docs/other-examples/authorized-authors.mdx:
--------------------------------------------------------------------------------
1 | ## Authorized Authors Example
2 |
3 | This example demonstrates how to only allow a fixed set of authors. It leverages the `isValidClient` callback which will be invoked vor every update. In case all `snapshots`, `updates` and `ephemeralMessages` pass the `isValidClient` callback and `true` is returned all changes are accepted. In case `isValidClient` returns `false` or an error is thrown the change is rejected. Depending on the case it's simply ignored or the document ends up in a broken stage. More details can be found at [Error Handling](/docs/error-handling).
4 |
5 | Example:
6 |
7 | 1. Select the first authorized author and add an item in the list
8 | 2. Open another tab and select the first non-authorized author and add an item in the list
9 |
10 | The entry will not be synced to the first tab and in the `DevTool` box below you can see the errors. You can also refresh and see how the document is reconstructed (except the last update was a snapshot). In this case the previous snapshot could be loaded, but that case is not handled in this example.
11 |
12 | NOTE: Choosing the same author in multiple clients will result in errors when trying to create update in parallel.
13 |
14 | import SimpleExampleWrapper from "../../../components/SimpleExampleWrapper";
15 | import { AuthorizedAuthorsExample } from "../../../components/AuthorizedAuthorsExample/AuthorizedAuthorsExample";
16 |
17 |
21 |
22 | ## Code
23 |
24 | The code for this example is located [here](https://github.com/serenity-kit/secsync/blob/main/documentation/components/AuthorizedAuthorsExample/AuthorizedAuthorsExample.tsx).
25 |
--------------------------------------------------------------------------------
/documentation/pages/docs/other-examples/yjs-local-first.mdx:
--------------------------------------------------------------------------------
1 | ## Yjs Local-first Example
2 |
3 | This example shows how to use secsync in a local-first setup. In this example the full document and the `pendingChanges` are stored in localStorage. In case the API is not available the changes won't be lost. Once re-connected to the API, Secsync will send the pending changes.
4 |
5 | This makes use of the `pendingChanges` parameter and `onPendingChangesUpdated` callback parameter.
6 |
7 | import SimpleExampleWrapper from "../../../components/SimpleExampleWrapper";
8 | import { YjsLocalFirstExample } from "../../../components/YjsLocalFirstExample/YjsLocalFirstExample";
9 |
10 |
14 |
15 | ## Code
16 |
17 | The code for this example is located [here](https://github.com/serenity-kit/secsync/blob/main/documentation/components/YjsLocalFirstExample/YjsLocalFirstExample.tsx).
18 |
--------------------------------------------------------------------------------
/documentation/pages/docs/security_and_privacy/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "considerations": "Considerations",
3 | "threat_library": "Threat library"
4 | }
5 |
--------------------------------------------------------------------------------
/documentation/pages/docs/server/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "introduction": "Introduction",
3 | "setup-guide": "Setup Guide"
4 | }
5 |
--------------------------------------------------------------------------------
/documentation/pages/docs/server/introduction.mdx:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
3 | Secsync requires a centralized server to ensure the correct order of snapshots and updates. It should reject invalid updates and snapshots.
4 |
5 | While ideally secsync would have been designed to be decentralized, this would have resulted in various trade-offs that were not desired.
6 |
7 | Secsync ships with a server utility that handles a Websocket connection including validations and handling expected error cases e.g. trying to send snapshot without having synced first. This is mean as a helfpul utility to simplify development, but of course you can implement your own replicating the logic.
8 |
9 | ## secsync-server utilities
10 |
11 | The `secsync-server` package exports `createWebSocketConnection` which requires 5 callbacks to be defined:
12 |
13 | - getDocument
14 | - createSnaphot
15 | - createUpdate
16 | - hasAccess
17 | - hasBroadcastAccess
18 |
19 | ```tsx
20 | import { createWebSocketConnection } from "secsync-server";
21 | import { WebSocketServer } from "ws";
22 |
23 | const webSocketServer = new WebSocketServer();
24 | webSocketServer.on(
25 | "connection",
26 | createWebSocketConnection({
27 | createSnapshot,
28 | createUpdate,
29 | getDocument,
30 | hasAccess,
31 | hasBroadcastAccess,
32 | })
33 | );
34 | ```
35 |
36 | ### Callbacks
37 |
38 | #### createSnapshot
39 |
40 | 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.
41 |
42 | #### createUpdate
43 |
44 | 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.
45 |
46 | #### getDocument
47 |
48 | 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.
49 |
50 | 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.
51 |
52 | #### hasAccess
53 |
54 | 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.
55 |
56 | The callback is invoked with the following argument:
57 |
58 | ```tsx
59 | type HasAccessParams =
60 | | {
61 | action: "read";
62 | documentId: string;
63 | websocketSessionKey: string | undefined;
64 | }
65 | | {
66 | action: "write-snapshot" | "write-update" | "send-ephemeral-message";
67 | documentId: string;
68 | publicKey: string;
69 | websocketSessionKey: string | undefined;
70 | };
71 | ```
72 |
73 | #### hasBroadcastAccess
74 |
75 | 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.
76 |
77 | The callback is invoked with the following argument:
78 |
79 | ```ts
80 | export type HasBroadcastAccessParams = {
81 | documentId: string;
82 | websocketSessionKeys: string[];
83 | };
84 | ```
85 |
86 | ### Documentation Example Server
87 |
88 | A fully functional server can be found here [https://github.com/serenity-kit/secsync/tree/main/examples/backend](https://github.com/serenity-kit/secsync/tree/main/examples/backend). Keep in mind that it accepts any client and does not verify the Websocket session key/token.
89 |
--------------------------------------------------------------------------------
/documentation/pages/imprint.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | searchable: false
3 | ---
4 |
5 | # Imprint
6 |
7 | Naisho Gmbh\
8 | Zollergasse 2/18\
9 | 1070 - Vienna\
10 | Austria
11 |
12 | VAT-ID: ATU77917747\
13 | Firmenbuch: FN 575621 b\
14 | Steuernummer: 03 723/7567
15 |
16 | Unternehmensgegenstand (purpose of the company): Erbringung von IT Dienstleistungen und Beratung, Entwicklung, Handel und Betrieb von Software und Hardware zur sicheren Verwaltung und Berabeitung von Dokumente, Formulare, Dateien und sonstige Daten\
17 |
18 | Gewerbe (business): Dienstleistungen in der automatischen Datenverarbeitung und Informationstechnik
19 |
--------------------------------------------------------------------------------
/documentation/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/documentation/public/automerge_loading_time_comparison.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/documentation/public/automerge_loading_time_comparison.png
--------------------------------------------------------------------------------
/documentation/public/automerge_size_comparison.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/documentation/public/automerge_size_comparison.png
--------------------------------------------------------------------------------
/documentation/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/documentation/public/favicon.png
--------------------------------------------------------------------------------
/documentation/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/documentation/public/secsync-document-representation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/documentation/public/secsync-document-representation.png
--------------------------------------------------------------------------------
/documentation/public/secsync-time-representation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/documentation/public/secsync-time-representation.png
--------------------------------------------------------------------------------
/documentation/public/yjs_loading_time_comparison.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/documentation/public/yjs_loading_time_comparison.png
--------------------------------------------------------------------------------
/documentation/public/yjs_size_comparison.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/documentation/public/yjs_size_comparison.png
--------------------------------------------------------------------------------
/documentation/styles/prosemirror-custom.css:
--------------------------------------------------------------------------------
1 | .ProseMirror {
2 | border: 1px solid #dcdde5;
3 | border-radius: 2px;
4 | padding: 8px;
5 | }
6 |
7 | .ProseMirror-menubar {
8 | border-bottom: 0 !important;
9 | }
10 |
--------------------------------------------------------------------------------
/documentation/styles/prosemirror-example-setup.css:
--------------------------------------------------------------------------------
1 | /* from https://github.com/ProseMirror/prosemirror-example-setup/blob/master/style/style.css */
2 | /* Add space around the hr to make clicking it easier */
3 |
4 | .ProseMirror-example-setup-style hr {
5 | padding: 2px 10px;
6 | border: none;
7 | margin: 1em 0;
8 | }
9 |
10 | .ProseMirror-example-setup-style hr:after {
11 | content: "";
12 | display: block;
13 | height: 1px;
14 | background-color: silver;
15 | line-height: 2px;
16 | }
17 |
18 | .ProseMirror ul,
19 | .ProseMirror ol {
20 | padding-left: 30px;
21 | }
22 |
23 | .ProseMirror blockquote {
24 | padding-left: 1em;
25 | border-left: 3px solid #eee;
26 | margin-left: 0;
27 | margin-right: 0;
28 | }
29 |
30 | .ProseMirror-example-setup-style img {
31 | cursor: default;
32 | }
33 |
34 | .ProseMirror-prompt {
35 | background: white;
36 | padding: 5px 10px 5px 15px;
37 | border: 1px solid silver;
38 | position: fixed;
39 | border-radius: 3px;
40 | z-index: 11;
41 | box-shadow: -0.5px 2px 5px rgba(0, 0, 0, 0.2);
42 | }
43 |
44 | .ProseMirror-prompt h5 {
45 | margin: 0;
46 | font-weight: normal;
47 | font-size: 100%;
48 | color: #444;
49 | }
50 |
51 | .ProseMirror-prompt input[type="text"],
52 | .ProseMirror-prompt textarea {
53 | background: #eee;
54 | border: none;
55 | outline: none;
56 | }
57 |
58 | .ProseMirror-prompt input[type="text"] {
59 | padding: 0 4px;
60 | }
61 |
62 | .ProseMirror-prompt-close {
63 | position: absolute;
64 | left: 2px;
65 | top: 1px;
66 | color: #666;
67 | border: none;
68 | background: transparent;
69 | padding: 0;
70 | }
71 |
72 | .ProseMirror-prompt-close:after {
73 | content: "✕";
74 | font-size: 12px;
75 | }
76 |
77 | .ProseMirror-invalid {
78 | background: #ffc;
79 | border: 1px solid #cc7;
80 | border-radius: 4px;
81 | padding: 5px 10px;
82 | position: absolute;
83 | min-width: 10em;
84 | }
85 |
86 | .ProseMirror-prompt-buttons {
87 | margin-top: 5px;
88 | display: none;
89 | }
90 |
--------------------------------------------------------------------------------
/documentation/styles/prosemirror-menu.css:
--------------------------------------------------------------------------------
1 | /** from https://github.com/ProseMirror/prosemirror-menu/blob/master/style/menu.css */
2 | .ProseMirror-textblock-dropdown {
3 | min-width: 3em;
4 | }
5 |
6 | .ProseMirror-menu {
7 | margin: 0 -4px;
8 | line-height: 1;
9 | }
10 |
11 | .ProseMirror-tooltip .ProseMirror-menu {
12 | width: -webkit-fit-content;
13 | width: fit-content;
14 | white-space: pre;
15 | }
16 |
17 | .ProseMirror-menuitem {
18 | margin-right: 3px;
19 | display: inline-block;
20 | }
21 |
22 | .ProseMirror-menuseparator {
23 | border-right: 1px solid #ddd;
24 | margin-right: 3px;
25 | }
26 |
27 | .ProseMirror-menu-dropdown,
28 | .ProseMirror-menu-dropdown-menu {
29 | font-size: 90%;
30 | white-space: nowrap;
31 | }
32 |
33 | .ProseMirror-menu-dropdown {
34 | vertical-align: 1px;
35 | cursor: pointer;
36 | position: relative;
37 | padding-right: 15px;
38 | }
39 |
40 | .ProseMirror-menu-dropdown-wrap {
41 | padding: 1px 0 1px 4px;
42 | display: inline-block;
43 | position: relative;
44 | }
45 |
46 | .ProseMirror-menu-dropdown:after {
47 | content: "";
48 | border-left: 4px solid transparent;
49 | border-right: 4px solid transparent;
50 | border-top: 4px solid currentColor;
51 | opacity: 0.6;
52 | position: absolute;
53 | right: 4px;
54 | top: calc(50% - 2px);
55 | }
56 |
57 | .ProseMirror-menu-dropdown-menu,
58 | .ProseMirror-menu-submenu {
59 | position: absolute;
60 | background: white;
61 | color: #666;
62 | border: 1px solid #aaa;
63 | padding: 2px;
64 | }
65 |
66 | .ProseMirror-menu-dropdown-menu {
67 | z-index: 15;
68 | min-width: 6em;
69 | }
70 |
71 | .ProseMirror-menu-dropdown-item {
72 | cursor: pointer;
73 | padding: 2px 8px 2px 4px;
74 | }
75 |
76 | .ProseMirror-menu-dropdown-item:hover {
77 | background: #f2f2f2;
78 | }
79 |
80 | .ProseMirror-menu-submenu-wrap {
81 | position: relative;
82 | margin-right: -4px;
83 | }
84 |
85 | .ProseMirror-menu-submenu-label:after {
86 | content: "";
87 | border-top: 4px solid transparent;
88 | border-bottom: 4px solid transparent;
89 | border-left: 4px solid currentColor;
90 | opacity: 0.6;
91 | position: absolute;
92 | right: 4px;
93 | top: calc(50% - 4px);
94 | }
95 |
96 | .ProseMirror-menu-submenu {
97 | display: none;
98 | min-width: 4em;
99 | left: 100%;
100 | top: -3px;
101 | }
102 |
103 | .ProseMirror-menu-active {
104 | background: #eee;
105 | border-radius: 4px;
106 | }
107 |
108 | .ProseMirror-menu-disabled {
109 | opacity: 0.3;
110 | }
111 |
112 | .ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu,
113 | .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu {
114 | display: block;
115 | }
116 |
117 | .ProseMirror-menubar {
118 | border-top-left-radius: inherit;
119 | border-top-right-radius: inherit;
120 | position: relative;
121 | min-height: 1em;
122 | color: #666;
123 | padding: 1px 6px;
124 | top: 0;
125 | left: 0;
126 | right: 0;
127 | border-bottom: 1px solid silver;
128 | background: white;
129 | z-index: 10;
130 | -moz-box-sizing: border-box;
131 | box-sizing: border-box;
132 | overflow: visible;
133 | }
134 |
135 | .ProseMirror-icon {
136 | display: inline-block;
137 | line-height: 0.8;
138 | vertical-align: -2px; /* Compensate for padding */
139 | padding: 2px 8px;
140 | cursor: pointer;
141 | }
142 |
143 | .ProseMirror-menu-disabled.ProseMirror-icon {
144 | cursor: default;
145 | }
146 |
147 | .ProseMirror-icon svg {
148 | fill: currentColor;
149 | height: 1em;
150 | }
151 |
152 | .ProseMirror-icon span {
153 | vertical-align: text-top;
154 | }
155 |
--------------------------------------------------------------------------------
/documentation/styles/tiptap-extension-y-awareness.css:
--------------------------------------------------------------------------------
1 | /* Give a remote user a caret */
2 | .collaboration-cursor__caret {
3 | border-left: 1px solid #0d0d0d;
4 | border-right: 1px solid #0d0d0d;
5 | margin-left: -1px;
6 | margin-right: -1px;
7 | pointer-events: none;
8 | position: relative;
9 | word-break: normal;
10 | }
11 |
12 | /* Render the username above the caret */
13 | .collaboration-cursor__label {
14 | background-color: white;
15 | border: 1px solid #ccc;
16 | border-radius: 3px 3px 3px 0;
17 | color: #0d0d0d;
18 | font-size: 12px;
19 | font-style: normal;
20 | font-weight: 600;
21 | left: -1px;
22 | line-height: normal;
23 | padding: 0.1rem 0.3rem;
24 | position: absolute;
25 | top: -1.4em;
26 | user-select: none;
27 | white-space: nowrap;
28 | }
29 |
--------------------------------------------------------------------------------
/documentation/styles/todos.css:
--------------------------------------------------------------------------------
1 | .todoapp {
2 | background: #fff;
3 | position: relative;
4 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
5 | }
6 |
7 | .todoapp input::-webkit-input-placeholder {
8 | font-style: italic;
9 | font-weight: 400;
10 | color: rgba(0, 0, 0, 0.4);
11 | }
12 |
13 | .todoapp input::-moz-placeholder {
14 | font-style: italic;
15 | font-weight: 400;
16 | color: rgba(0, 0, 0, 0.4);
17 | }
18 |
19 | .todoapp input::input-placeholder {
20 | font-style: italic;
21 | font-weight: 400;
22 | color: rgba(0, 0, 0, 0.4);
23 | }
24 |
25 | .new-todo,
26 | .edit {
27 | width: 100%;
28 | margin: 0;
29 | font-size: 24px;
30 | line-height: 1.4em;
31 | color: inherit;
32 | box-sizing: border-box;
33 | -webkit-font-smoothing: antialiased;
34 | -moz-osx-font-smoothing: grayscale;
35 | padding: 16px 80px 16px 64px;
36 | height: 64px;
37 | background: rgba(0, 0, 0, 0.003);
38 | }
39 |
40 | .new-todo {
41 | border-bottom: 1px solid #ededed;
42 | }
43 |
44 | .add {
45 | position: absolute;
46 | right: 0;
47 | height: 64px;
48 | width: 64px;
49 | border-left: 1px solid #ededed;
50 | }
51 |
52 | .todo-list {
53 | margin: 0;
54 | padding: 0;
55 | list-style: none;
56 | }
57 |
58 | .todo-list li {
59 | position: relative;
60 | font-size: 24px;
61 | display: block;
62 | padding-left: 0px;
63 | display: flex;
64 | border-bottom: 1px solid #ededed;
65 | }
66 |
67 | .todo-list li:last-child {
68 | border-bottom: none;
69 | }
70 |
71 | .todo-list li .toggle {
72 | position: absolute;
73 | text-align: center;
74 | top: 0;
75 | left: 0;
76 | width: 64px;
77 | height: 64px;
78 | border: none; /* Mobile Safari */
79 | -webkit-appearance: none;
80 | appearance: none;
81 | }
82 |
83 | .todo-list li .toggle {
84 | /*
85 | Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
86 | IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
87 | */
88 | background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E");
89 | background-repeat: no-repeat;
90 | background-position: center center;
91 | }
92 |
93 | .todo-list li .toggle:checked {
94 | background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E");
95 | }
96 |
97 | .todo-list li .destroy {
98 | position: absolute;
99 | top: 0;
100 | right: 0;
101 | width: 64px;
102 | height: 64px;
103 | margin: auto 0;
104 | font-size: 32px;
105 | color: #949494;
106 | transition: color 0.2s ease-out;
107 | padding-top: 12px;
108 | }
109 |
110 | .todo-list li .destroy:hover,
111 | .todo-list li .destroy:focus {
112 | color: #c18585;
113 | }
114 |
115 | .todo-list li .destroy:after {
116 | content: "×";
117 | display: block;
118 | height: 100%;
119 | line-height: 1.1;
120 | }
121 |
122 | .todo-list li:hover .destroy {
123 | display: block;
124 | }
125 |
--------------------------------------------------------------------------------
/documentation/styles/y-prosemirror.css:
--------------------------------------------------------------------------------
1 | /* this is a rough fix for the first cursor position when the first paragraph is empty */
2 | .ProseMirror > .ProseMirror-yjs-cursor:first-child {
3 | margin-top: 16px;
4 | }
5 | .ProseMirror p:first-child,
6 | .ProseMirror h1:first-child,
7 | .ProseMirror h2:first-child,
8 | .ProseMirror h3:first-child,
9 | .ProseMirror h4:first-child,
10 | .ProseMirror h5:first-child,
11 | .ProseMirror h6:first-child {
12 | margin-top: 16px;
13 | }
14 | /* This gives the remote user caret. The colors are automatically overwritten*/
15 | .ProseMirror-yjs-cursor {
16 | position: relative;
17 | margin-left: -1px;
18 | margin-right: -1px;
19 | border-left: 1px solid black;
20 | border-right: 1px solid black;
21 | border-color: orange;
22 | word-break: normal;
23 | pointer-events: none;
24 | }
25 | /* This renders the username above the caret */
26 | .ProseMirror-yjs-cursor > div {
27 | position: absolute;
28 | top: -1.05em;
29 | left: -1px;
30 | font-size: 13px;
31 | background-color: rgb(250, 129, 0);
32 | font-family: serif;
33 | font-style: normal;
34 | font-weight: normal;
35 | line-height: normal;
36 | user-select: none;
37 | color: white;
38 | padding-left: 2px;
39 | padding-right: 2px;
40 | white-space: nowrap;
41 | }
42 |
--------------------------------------------------------------------------------
/documentation/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | './app/**/*.{js,ts,jsx,tsx,mdx}',
5 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
6 | './components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './theme.config.tsx',
8 | ],
9 | darkMode: 'class',
10 | theme: {
11 | colors: {
12 | transparent: "transparent",
13 | current: "currentColor",
14 | black: "#000000",
15 | gray: {
16 | 100: "#FDFDFD",
17 | 120: "#FAFAFC",
18 | 150: "#F5F5F7",
19 | 200: "#EDEDF0",
20 | 300: "#DCDDE5",
21 | 400: "#CBCBD3",
22 | 500: "#B4B4BD",
23 | 600: "#8A8B96",
24 | 700: "#666771",
25 | 800: "#4F5057",
26 | 900: "#1F1F21",
27 | },
28 | primary: {
29 | 100: "#ECEEFF",
30 | 150: "#DDE1FE",
31 | 200: "#CDD3FC",
32 | 300: "#9DAAFD",
33 | 400: "#7083FA",
34 | 500: "#435BF8",
35 | 600: "#2B44E4",
36 | 700: "#172FC8",
37 | 800: "#0A1E9B",
38 | 900: "#000F70",
39 | dark: {
40 | 400: "#8091FF",
41 | 500: "#6576E1",
42 | }
43 | },
44 | surface: {
45 | primary: "#1F1F21",
46 | secondary: "#2B2B2D",
47 | tertiary: "#353538",
48 | border: "#44454B"
49 | },
50 | palette: {
51 | terracotta: "#EF5245",
52 | coral: "#FD7064",
53 | raspberry: "#F4216D",
54 | rose: "#FF91C9",
55 | honey: "#FFB921",
56 | orange: "#FF7D2E",
57 | emerald: "#47C07A",
58 | arctic: "#4ABAC1",
59 | sky: "#1E8EDE",
60 | serenity: "#435BF8", // primary 500
61 | lavender: "#515DCE",
62 | purple: "#9E36CF",
63 | slate: "#4F5D78",
64 | },
65 | white: "#FFFFFF",
66 | },
67 | extend: {
68 | fontFamily: {
69 | inter: 'var(--font-inter)'
70 | },
71 | fontSize: {
72 | h1: ["2rem", "2.375rem"],
73 | h2: ["1.5rem", "1.75rem"],
74 | h3: ["1.125rem", "1.375rem"],
75 | sm: ["0.8125rem", "130%"], // 13px
76 | }
77 | },
78 | },
79 | plugins: [],
80 | }
81 |
82 |
--------------------------------------------------------------------------------
/documentation/theme.config.tsx:
--------------------------------------------------------------------------------
1 | import { DocsThemeConfig } from "nextra-theme-docs";
2 | import { Code } from "./components/Code";
3 | import { Footer } from "./components/Footer";
4 | import { Logo } from "./components/Logo";
5 | import { Pre } from "./components/Pre";
6 | import { Table } from "./components/Table";
7 | import { Td } from "./components/Td";
8 | import { Th } from "./components/Th";
9 | import { Tr } from "./components/Tr";
10 |
11 | const config: DocsThemeConfig = {
12 | head: (
13 | <>
14 |
15 |
16 | >
17 | ),
18 | logo: (
19 | // wrapper needed so it looks vertically centered in header
20 |
21 |
22 |
23 | ),
24 | project: {
25 | link: "https://github.com/serenity-kit/secsync",
26 | },
27 | // chat: {
28 | // link: "https://discord.com",
29 | // },
30 | docsRepositoryBase: "https://github.com/serenity-kit/secsync",
31 | footer: {
32 | component: Footer,
33 | },
34 | primaryHue: 232,
35 | components: {
36 | // https://mdxjs.com/table-of-components/
37 | pre: Pre,
38 | code: Code,
39 | p: (props) =>
,
40 | table: Table,
41 | th: Th,
42 | tr: Tr,
43 | td: Td,
44 | },
45 | };
46 |
47 | export default config;
48 |
--------------------------------------------------------------------------------
/documentation/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "incremental": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve"
17 | },
18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------
/examples/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | npm-debug.log
--------------------------------------------------------------------------------
/examples/backend/.env.example:
--------------------------------------------------------------------------------
1 | DATABASE_URL="postgresql://prisma:prisma@localhost:5432/secsync"
--------------------------------------------------------------------------------
/examples/backend/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["Prisma.prisma"]
3 | }
4 |
--------------------------------------------------------------------------------
/examples/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine
2 |
3 | # Create app directory
4 | WORKDIR /usr/src/app
5 |
6 | COPY . .
7 |
8 | EXPOSE $PORT
9 | # necessary for small machines on fly.io to avoid crashing during npm install
10 | ENV NODE_OPTIONS=--max_old_space_size=4096
11 | CMD ["npm", "run", "start:prod" ]
--------------------------------------------------------------------------------
/examples/backend/README.md:
--------------------------------------------------------------------------------
1 | ## Dev
2 |
3 | ```
4 | npx prisma generate
5 | ```
6 |
7 | ## Idea: get inspired by event sourcing
8 |
9 | ## Resources
10 |
11 | - https://dev.to/kspeakman/event-storage-in-postgres-4dk2
12 | - https://softwaremill.com/implementing-event-sourcing-using-a-relational-database/
13 | - https://github.com/evgeniy-khist/postgresql-event-sourcing
14 |
--------------------------------------------------------------------------------
/examples/backend/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml app configuration file generated for secsync on 2023-06-27T11:49:39+02:00
2 | #
3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4 | #
5 |
6 | app = "secsync"
7 | primary_region = "ams"
8 | # necessary for small machines on fly.io to avoid running out of memory
9 | swap_size_mb = 2048
10 |
11 | [deploy]
12 | release_command = "npm run prisma:prod:migrate"
13 |
14 | [env]
15 | PORT = "8080"
16 |
17 | [http_service]
18 | internal_port = 8080
19 | force_https = true
20 | auto_stop_machines = true
21 | auto_start_machines = true
22 | min_machines_running = 0
23 | processes = ["app"]
24 |
--------------------------------------------------------------------------------
/examples/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "backend",
3 | "version": "1.0.0",
4 | "private": true,
5 | "devDependencies": {
6 | "@types/node": "^20.13.0",
7 | "@types/uuid": "^9.0.8",
8 | "@types/ws": "^8.5.10",
9 | "@vercel/ncc": "^0.38.1",
10 | "prettier": "^3.2.5",
11 | "prisma": "^5.14.0",
12 | "ts-node": "10.9.2",
13 | "ts-node-dev": "^2.0.0"
14 | },
15 | "scripts": {
16 | "ts:check": "pnpm tsc --noEmit",
17 | "test": "echo \"No tests yet\"",
18 | "lint": "echo \"No linting setup\"",
19 | "dev": "ts-node-dev --transpile-only --no-notify ./src/index.ts",
20 | "clean": "rm -rf build",
21 | "build": "pnpm install && pnpm clean && pnpm prisma:prod:generate && pnpm ncc build ./src/index.ts -o build",
22 | "deploy": "pnpm build && fly launch",
23 | "prisma:prod:migrate": "npm install -g prisma@5 && DATABASE_URL=$DATABASE_URL prisma migrate deploy",
24 | "prisma:prod:generate": "DATABASE_URL=$DATABASE_URL prisma generate",
25 | "prisma:prod:studio": "DATABASE_URL=$DATABASE_URL prisma studio",
26 | "start:prod": "PORT=$PORT DATABASE_URL=$DATABASE_URL NODE_ENV=production node ./build"
27 | },
28 | "dependencies": {
29 | "@prisma/client": "^5.14.0",
30 | "cors": "^2.8.5",
31 | "express": "^4.19.2",
32 | "libsodium-wrappers": "^0.7.13",
33 | "make-promises-safe": "^5.1.0",
34 | "secsync": "workspace:^",
35 | "secsync-server": "workspace:^",
36 | "uuid": "^9.0.1",
37 | "ws": "^8.17.0"
38 | },
39 | "engines": {
40 | "node": ">=12.2.0"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/examples/backend/prisma/migrations/20230915120302_initial_schema/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "Document" (
3 | "id" TEXT NOT NULL,
4 | "activeSnapshotId" TEXT,
5 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
6 |
7 | CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
8 | );
9 |
10 | -- CreateTable
11 | CREATE TABLE "Snapshot" (
12 | "id" TEXT NOT NULL,
13 | "latestVersion" INTEGER NOT NULL,
14 | "data" TEXT NOT NULL,
15 | "ciphertextHash" TEXT NOT NULL,
16 | "documentId" TEXT NOT NULL,
17 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
18 | "clocks" JSONB NOT NULL,
19 | "parentSnapshotUpdateClocks" JSONB NOT NULL,
20 | "parentSnapshotProof" TEXT NOT NULL,
21 |
22 | CONSTRAINT "Snapshot_pkey" PRIMARY KEY ("id")
23 | );
24 |
25 | -- CreateTable
26 | CREATE TABLE "Update" (
27 | "id" TEXT NOT NULL,
28 | "version" INTEGER NOT NULL,
29 | "data" TEXT NOT NULL,
30 | "snapshotId" TEXT NOT NULL,
31 | "clock" INTEGER NOT NULL,
32 | "pubKey" TEXT NOT NULL
33 | );
34 |
35 | -- CreateIndex
36 | CREATE UNIQUE INDEX "Document_activeSnapshotId_key" ON "Document"("activeSnapshotId");
37 |
38 | -- CreateIndex
39 | CREATE UNIQUE INDEX "Update_id_key" ON "Update"("id");
40 |
41 | -- CreateIndex
42 | CREATE INDEX "Update_id_version_idx" ON "Update"("id", "version");
43 |
44 | -- CreateIndex
45 | CREATE UNIQUE INDEX "Update_snapshotId_version_key" ON "Update"("snapshotId", "version");
46 |
47 | -- CreateIndex
48 | CREATE UNIQUE INDEX "Update_snapshotId_pubKey_clock_key" ON "Update"("snapshotId", "pubKey", "clock");
49 |
50 | -- AddForeignKey
51 | ALTER TABLE "Document" ADD CONSTRAINT "Document_activeSnapshotId_fkey" FOREIGN KEY ("activeSnapshotId") REFERENCES "Snapshot"("id") ON DELETE SET NULL ON UPDATE CASCADE;
52 |
53 | -- AddForeignKey
54 | ALTER TABLE "Snapshot" ADD CONSTRAINT "Snapshot_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
55 |
56 | -- AddForeignKey
57 | ALTER TABLE "Update" ADD CONSTRAINT "Update_snapshotId_fkey" FOREIGN KEY ("snapshotId") REFERENCES "Snapshot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
58 |
--------------------------------------------------------------------------------
/examples/backend/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/examples/backend/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "postgresql"
3 | url = env("DATABASE_URL")
4 | }
5 |
6 | generator client {
7 | provider = "prisma-client-js"
8 | binaryTargets = ["linux-musl-openssl-3.0.x", "native"]
9 | output = "./generated/output"
10 | }
11 |
12 | model Document {
13 | id String @id
14 | activeSnapshot Snapshot? @relation(name: "activeSnapshot", fields: [activeSnapshotId], references: [id])
15 | activeSnapshotId String? @unique
16 | snapshots Snapshot[]
17 | createdAt DateTime @default(now())
18 | }
19 |
20 | model Snapshot {
21 | id String @id
22 | latestVersion Int
23 | data String
24 | ciphertextHash String
25 | document Document @relation(fields: [documentId], references: [id])
26 | documentId String
27 | updates Update[]
28 | activeSnapshotDocument Document? @relation("activeSnapshot")
29 | createdAt DateTime @default(now())
30 | clocks Json
31 | parentSnapshotUpdateClocks Json
32 | parentSnapshotProof String
33 | }
34 |
35 | model Update {
36 | id String @unique // composed out of snapshotId, pubKey, clock
37 | version Int
38 | data String
39 | snapshot Snapshot @relation(fields: [snapshotId], references: [id])
40 | snapshotId String
41 | clock Int
42 | pubKey String
43 |
44 | @@unique([snapshotId, version])
45 | @@unique([snapshotId, pubKey, clock]) // matches the id
46 | @@index([id, version])
47 | }
48 |
--------------------------------------------------------------------------------
/examples/backend/src/database/createSnapshot.ts:
--------------------------------------------------------------------------------
1 | import sodium from "libsodium-wrappers";
2 | import {
3 | CreateSnapshotParams,
4 | SecsyncSnapshotBasedOnOutdatedSnapshotError,
5 | SecsyncSnapshotMissesUpdatesError,
6 | Snapshot,
7 | compareUpdateClocks,
8 | hash,
9 | } from "secsync";
10 | import { serializeSnapshot } from "../utils/serialize";
11 | import { Prisma, prisma } from "./prisma";
12 |
13 | export async function createSnapshot({ snapshot }: CreateSnapshotParams) {
14 | const MAX_RETRIES = 5;
15 | let retries = 0;
16 | let result: Snapshot;
17 |
18 | // use retries approach as described here: https://www.prisma.io/docs/concepts/components/prisma-client/transactions#transaction-timing-issues
19 | while (retries < MAX_RETRIES) {
20 | try {
21 | result = await prisma.$transaction(
22 | async (prisma) => {
23 | const document = await prisma.document.findUniqueOrThrow({
24 | where: { id: snapshot.publicData.docId },
25 | select: {
26 | activeSnapshot: true,
27 | },
28 | });
29 |
30 | // function sleep(ms) {
31 | // return new Promise((resolve) => setTimeout(resolve, ms));
32 | // }
33 | // await sleep(3000);
34 |
35 | // const random = Math.floor(Math.random() * 10);
36 | // if (random < 8) {
37 | // throw new SecsyncSnapshotBasedOnOutdatedSnapshotError(
38 | // "Snapshot is out of date."
39 | // );
40 | // }
41 |
42 | // const random = Math.floor(Math.random() * 10);
43 | // if (random < 8) {
44 | // throw new SecsyncSnapshotMissesUpdatesError(
45 | // "Snapshot does not include the latest changes."
46 | // );
47 | // }
48 |
49 | if (document.activeSnapshot) {
50 | if (
51 | snapshot.publicData.parentSnapshotId !== undefined &&
52 | snapshot.publicData.parentSnapshotId !==
53 | document.activeSnapshot.id
54 | ) {
55 | throw new SecsyncSnapshotBasedOnOutdatedSnapshotError(
56 | "Snapshot is out of date."
57 | );
58 | }
59 |
60 | const compareUpdateClocksResult = compareUpdateClocks(
61 | // @ts-expect-error the values are parsed by the function
62 | document.activeSnapshot.clocks,
63 | snapshot.publicData.parentSnapshotUpdateClocks
64 | );
65 |
66 | if (!compareUpdateClocksResult.equal) {
67 | throw new SecsyncSnapshotMissesUpdatesError(
68 | "Snapshot does not include the latest changes."
69 | );
70 | }
71 | }
72 |
73 | const newSnapshot = await prisma.snapshot.create({
74 | data: {
75 | id: snapshot.publicData.snapshotId,
76 | latestVersion: 0,
77 | data: JSON.stringify(snapshot),
78 | ciphertextHash: hash(snapshot.ciphertext, sodium),
79 | activeSnapshotDocument: {
80 | connect: { id: snapshot.publicData.docId },
81 | },
82 | document: { connect: { id: snapshot.publicData.docId } },
83 | clocks: {},
84 | parentSnapshotProof: snapshot.publicData.parentSnapshotProof,
85 | parentSnapshotUpdateClocks:
86 | snapshot.publicData.parentSnapshotUpdateClocks,
87 | },
88 | });
89 |
90 | return serializeSnapshot(newSnapshot);
91 | },
92 | {
93 | isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
94 | }
95 | );
96 | break;
97 | } catch (error) {
98 | if (error.code === "P2034") {
99 | retries++;
100 | continue;
101 | }
102 | throw error;
103 | }
104 | }
105 |
106 | return result;
107 | }
108 |
--------------------------------------------------------------------------------
/examples/backend/src/database/createUpdate.ts:
--------------------------------------------------------------------------------
1 | import { CreateUpdateParams, Update } from "secsync";
2 | import { Prisma } from "../../prisma/generated/output";
3 | import { serializeUpdate } from "../utils/serialize";
4 | import { prisma } from "./prisma";
5 |
6 | export async function createUpdate({ update }: CreateUpdateParams) {
7 | const MAX_RETRIES = 5;
8 | let retries = 0;
9 | let result: Update;
10 |
11 | // use retries approach as described here: https://www.prisma.io/docs/concepts/components/prisma-client/transactions#transaction-timing-issues
12 | while (retries < MAX_RETRIES) {
13 | try {
14 | result = await prisma.$transaction(
15 | async (prisma) => {
16 | const snapshot = await prisma.snapshot.findUniqueOrThrow({
17 | where: { id: update.publicData.refSnapshotId },
18 | select: {
19 | latestVersion: true,
20 | clocks: true,
21 | document: { select: { activeSnapshotId: true } },
22 | },
23 | });
24 | if (
25 | snapshot.document.activeSnapshotId !==
26 | update.publicData.refSnapshotId
27 | ) {
28 | throw new Error("Update referencing an out of date snapshot.");
29 | }
30 |
31 | if (
32 | snapshot.clocks &&
33 | typeof snapshot.clocks === "object" &&
34 | !Array.isArray(snapshot.clocks)
35 | ) {
36 | if (snapshot.clocks[update.publicData.pubKey] === undefined) {
37 | if (update.publicData.clock !== 0) {
38 | throw new Error(
39 | `Update clock incorrect. Clock: ${update.publicData.clock}, but should be 0`
40 | );
41 | }
42 | // update the clock for the public key
43 | snapshot.clocks[update.publicData.pubKey] =
44 | update.publicData.clock;
45 | } else {
46 | const expectedClockValue =
47 | // @ts-expect-error
48 | snapshot.clocks[update.publicData.pubKey] + 1;
49 | if (expectedClockValue !== update.publicData.clock) {
50 | throw new Error(
51 | `Update clock incorrect. Clock: ${update.publicData.clock}, but should be ${expectedClockValue}`
52 | );
53 | }
54 | // update the clock for the public key
55 | snapshot.clocks[update.publicData.pubKey] =
56 | update.publicData.clock;
57 | }
58 | }
59 |
60 | await prisma.snapshot.update({
61 | where: { id: update.publicData.refSnapshotId },
62 | data: {
63 | latestVersion: snapshot.latestVersion + 1,
64 | clocks: snapshot.clocks as Prisma.JsonObject,
65 | },
66 | });
67 |
68 | return serializeUpdate(
69 | await prisma.update.create({
70 | data: {
71 | id: `${update.publicData.refSnapshotId}-${update.publicData.pubKey}-${update.publicData.clock}`,
72 | data: JSON.stringify(update),
73 | version: snapshot.latestVersion + 1,
74 | snapshot: {
75 | connect: {
76 | id: update.publicData.refSnapshotId,
77 | },
78 | },
79 | clock: update.publicData.clock,
80 | pubKey: update.publicData.pubKey,
81 | },
82 | })
83 | );
84 | },
85 | {
86 | isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
87 | }
88 | );
89 | break;
90 | } catch (error) {
91 | if (error.code === "P2034") {
92 | retries++;
93 | continue;
94 | }
95 | throw error;
96 | }
97 | }
98 |
99 | return result;
100 | }
101 |
--------------------------------------------------------------------------------
/examples/backend/src/database/getOrCreateDocument.ts:
--------------------------------------------------------------------------------
1 | import { GetDocumentParams } from "packages/secsync/src";
2 | import { serializeSnapshot, serializeUpdates } from "../utils/serialize";
3 | import { prisma } from "./prisma";
4 |
5 | export async function getOrCreateDocument({
6 | documentId,
7 | knownSnapshotId,
8 | knownSnapshotUpdateClocks,
9 | mode,
10 | }: GetDocumentParams) {
11 | return prisma.$transaction(async (prisma) => {
12 | const doc = await prisma.document.findUnique({
13 | where: { id: documentId },
14 | include: { activeSnapshot: { select: { id: true } } },
15 | });
16 |
17 | if (!doc) {
18 | await prisma.document.create({
19 | data: { id: documentId },
20 | });
21 | return {
22 | updates: [],
23 | snapshotProofChain: [],
24 | };
25 | }
26 | if (!doc.activeSnapshot) {
27 | return {
28 | updates: [],
29 | snapshotProofChain: [],
30 | };
31 | }
32 |
33 | let snapshotProofChain: {
34 | id: string;
35 | parentSnapshotProof: string;
36 | ciphertextHash: string;
37 | }[] = [];
38 |
39 | if (knownSnapshotId && knownSnapshotId !== doc.activeSnapshot.id) {
40 | snapshotProofChain = await prisma.snapshot.findMany({
41 | where: { documentId },
42 | cursor: { id: knownSnapshotId },
43 | skip: 1,
44 | select: {
45 | id: true,
46 | parentSnapshotProof: true,
47 | ciphertextHash: true,
48 | createdAt: true,
49 | },
50 | orderBy: { createdAt: "asc" },
51 | });
52 | }
53 |
54 | let lastKnownVersion: number | undefined = undefined;
55 | // in case the last known snapshot is the current one, try to find the lastKnownVersion number
56 | if (knownSnapshotId === doc.activeSnapshot.id) {
57 | const updateIds = Object.entries(knownSnapshotUpdateClocks).map(
58 | ([pubKey, clock]) => {
59 | return `${knownSnapshotId}-${pubKey}-${clock}`;
60 | }
61 | );
62 | const lastUpdate = await prisma.update.findFirst({
63 | where: {
64 | id: { in: updateIds },
65 | },
66 | orderBy: { version: "desc" },
67 | });
68 | if (lastUpdate) {
69 | lastKnownVersion = lastUpdate.version;
70 | }
71 | }
72 |
73 | // fetch the active snapshot with
74 | // - all updates after the last known version if there is one and
75 | // - all updates if there is none
76 | const activeSnapshot = await prisma.snapshot.findUnique({
77 | where: { id: doc.activeSnapshot.id },
78 | include: {
79 | updates:
80 | lastKnownVersion !== undefined
81 | ? {
82 | orderBy: { version: "asc" },
83 | where: {
84 | version: { gt: lastKnownVersion },
85 | },
86 | }
87 | : {
88 | orderBy: { version: "asc" },
89 | },
90 | },
91 | });
92 |
93 | if (mode === "delta" && knownSnapshotId === activeSnapshot.id) {
94 | return {
95 | updates: serializeUpdates(activeSnapshot.updates),
96 | };
97 | }
98 |
99 | return {
100 | snapshot: serializeSnapshot(activeSnapshot),
101 | updates: serializeUpdates(activeSnapshot.updates),
102 | snapshotProofChain: snapshotProofChain.map((snapshotProofChainEntry) => {
103 | return {
104 | snapshotId: snapshotProofChainEntry.id,
105 | parentSnapshotProof: snapshotProofChainEntry.parentSnapshotProof,
106 | snapshotCiphertextHash: snapshotProofChainEntry.ciphertextHash,
107 | };
108 | }),
109 | };
110 | });
111 | }
112 |
--------------------------------------------------------------------------------
/examples/backend/src/database/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "../../prisma/generated/output";
2 | export { Prisma } from "../../prisma/generated/output";
3 |
4 | export const prisma = new PrismaClient();
5 |
--------------------------------------------------------------------------------
/examples/backend/src/index.ts:
--------------------------------------------------------------------------------
1 | require("make-promises-safe"); // installs an 'unhandledRejection' handler
2 | import cors from "cors";
3 | import express from "express";
4 | import { createServer } from "http";
5 | import { createWebSocketConnection } from "secsync-server";
6 | import { WebSocketServer } from "ws";
7 | import { createSnapshot as createSnapshotDb } from "./database/createSnapshot";
8 | import { createUpdate as createUpdateDb } from "./database/createUpdate";
9 | import { getOrCreateDocument as getOrCreateDocumentDb } from "./database/getOrCreateDocument";
10 |
11 | async function main() {
12 | // const allowedOrigin =
13 | // process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test"
14 | // ? "http://localhost:3000"
15 | // : "https://www.secsync.com";
16 | const allowedOrigin = "*";
17 | const corsOptions = { credentials: true, origin: allowedOrigin };
18 |
19 | const app = express();
20 | app.use(cors(corsOptions));
21 |
22 | const server = createServer(app);
23 |
24 | const webSocketServer = new WebSocketServer({ noServer: true });
25 | webSocketServer.on(
26 | "connection",
27 | createWebSocketConnection({
28 | getDocument: getOrCreateDocumentDb,
29 | createSnapshot: createSnapshotDb,
30 | createUpdate: createUpdateDb,
31 | hasAccess: async () => true,
32 | hasBroadcastAccess: async ({ websocketSessionKeys }) =>
33 | websocketSessionKeys.map(() => true),
34 | logging: "error",
35 | })
36 | );
37 |
38 | server.on("upgrade", (request, socket, head) => {
39 | // @ts-ignore
40 | webSocketServer.handleUpgrade(request, socket, head, (ws) => {
41 | webSocketServer.emit("connection", ws, request);
42 | });
43 | });
44 |
45 | const port = process.env.PORT ? parseInt(process.env.PORT) : 4000;
46 | server.listen(port, () => {
47 | console.log(`🚀 App ready at http://localhost:${port}/`);
48 | console.log(`🚀 Websocket service ready at ws://localhost:${port}`);
49 | });
50 | }
51 |
52 | main();
53 |
--------------------------------------------------------------------------------
/examples/backend/src/utils/serialize.ts:
--------------------------------------------------------------------------------
1 | import { Snapshot, Update } from "secsync";
2 | import {
3 | Snapshot as DbSnapshot,
4 | Update as DbUpdate,
5 | } from "../../prisma/generated/output";
6 |
7 | export function serializeSnapshot(snapshot: DbSnapshot): Snapshot {
8 | return {
9 | ...JSON.parse(snapshot.data),
10 | };
11 | }
12 |
13 | export function serializeUpdate(update: DbUpdate): Update {
14 | return {
15 | ...JSON.parse(update.data),
16 | };
17 | }
18 |
19 | export function serializeUpdates(updates: DbUpdate[]): Update[] {
20 | return updates.map((update) => {
21 | return serializeUpdate(update);
22 | });
23 | }
24 |
--------------------------------------------------------------------------------
/examples/backend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 |
4 | "compilerOptions": {
5 | "sourceMap": true,
6 | "outDir": "dist",
7 | "strict": false,
8 | "lib": ["esnext", "dom"],
9 | "esModuleInterop": true
10 | },
11 |
12 | "include": ["src/**/*"]
13 | }
14 |
--------------------------------------------------------------------------------
/ngrok.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | tunnels:
3 | backend:
4 | addr: 4000
5 | proto: http
6 | frontend:
7 | addr: 3000
8 | proto: http
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "secsync-monorepo",
3 | "private": true,
4 | "workspaces": [
5 | "examples/*",
6 | "packages/*",
7 | "benchmarks/*"
8 | ],
9 | "devDependencies": {
10 | "ts-node": "^10.9.2",
11 | "tsup": "^8.0.2",
12 | "typescript": "^5.4.5"
13 | },
14 | "scripts": {
15 | "ts:check": "pnpm install && pnpm -r ts:check",
16 | "test": "pnpm install && pnpm -r test",
17 | "lint": "pnpm install && pnpm -r lint"
18 | },
19 | "pnpm": {
20 | "overrides": {
21 | "yjs": "^13.6.15",
22 | "@automerge/automerge": "^2.2.2",
23 | "prosemirror-model": "^1.21.0",
24 | "prosemirror-state": "^1.4.3",
25 | "prosemirror-view": "^1.33.7",
26 | "prosemirror-transform": "^1.9.0",
27 | "y-prosemirror": "^1.2.5",
28 | "@tiptap/core": "^2.4.0"
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/packages/secsync-react-automerge/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.5.0] - 2024-06-04
11 |
12 | - Version bump due secsync core version bump
13 |
14 | ## [0.4.0] - 2024-06-01
15 |
16 | ### Changed
17 |
18 | - dependency updates
19 |
20 | ## [0.3.0] - 2024-05-29
21 |
22 | ### Changed
23 |
24 | - Upgrade secsync dependency
25 |
26 | ## [0.2.0] - 2023-09-29
27 |
28 | ### Added
29 |
30 | - Initial version
31 |
--------------------------------------------------------------------------------
/packages/secsync-react-automerge/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ["@babel/preset-env", { targets: { node: "current" } }],
4 | "@babel/preset-typescript",
5 | ],
6 | };
7 |
--------------------------------------------------------------------------------
/packages/secsync-react-automerge/package-json-build-script.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const data = fs.readFileSync("./package.json", { encoding: "utf8", flag: "r" });
3 |
4 | // Display the file data
5 | const dataJson = JSON.parse(data);
6 |
7 | dataJson.module = "index.mjs";
8 | dataJson.types = "index.d.ts";
9 | dataJson.main = "index.js";
10 | dataJson.browser = "index.mjs";
11 |
12 | fs.writeFileSync("./dist/package.json", JSON.stringify(dataJson, null, 2));
13 |
--------------------------------------------------------------------------------
/packages/secsync-react-automerge/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "secsync-react-automerge",
3 | "version": "0.5.0",
4 | "main": "src/index",
5 | "types": "src/index",
6 | "scripts": {
7 | "build": "pnpm tsup && pnpm build:package-json",
8 | "build:package-json": "node package-json-build-script.js",
9 | "prepublishOnly": "pnpm run build",
10 | "test": "echo \"No tests\"",
11 | "ts:check": "pnpm tsc --noEmit",
12 | "lint": "echo \"No linting configured\""
13 | },
14 | "dependencies": {
15 | "@xstate/react": "^4.1.1"
16 | },
17 | "peerDependencies": {
18 | "@automerge/automerge": "^2.2.0",
19 | "react": "*",
20 | "secsync": "*"
21 | },
22 | "devDependencies": {
23 | "@babel/core": "^7.24.6",
24 | "@babel/preset-env": "^7.24.6",
25 | "@babel/preset-typescript": "^7.24.6",
26 | "@types/jest": "^29.5.12",
27 | "@types/react": "^18.3.3",
28 | "jest": "^29.7.0",
29 | "secsync": "workspace:^"
30 | },
31 | "jest": {
32 | "setupFilesAfterEnv": [
33 | "/test/config/jestTestSetup.ts"
34 | ]
35 | },
36 | "publishConfig": {
37 | "directory": "dist",
38 | "linkDirectory": false
39 | },
40 | "repository": {
41 | "type": "git",
42 | "url": "git+https://github.com/serenity-kit/secsync.git"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/packages/secsync-react-automerge/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./useAutomergeSync";
2 |
--------------------------------------------------------------------------------
/packages/secsync-react-automerge/src/useAutomergeSync.ts:
--------------------------------------------------------------------------------
1 | import type { Doc } from "@automerge/automerge";
2 | import * as Automerge from "@automerge/automerge";
3 | import { useMachine } from "@xstate/react";
4 | import { useCallback, useRef, useState } from "react";
5 | import {
6 | SyncMachineConfig,
7 | createSyncMachine,
8 | deserializeUint8ArrayUpdates,
9 | serializeUint8ArrayUpdates,
10 | } from "secsync";
11 |
12 | export type AutomergeSyncConfig = Omit<
13 | SyncMachineConfig,
14 | | "applySnapshot"
15 | | "applyChanges"
16 | | "applyEphemeralMessage"
17 | | "serializeChanges"
18 | | "deserializeChanges"
19 | > & {
20 | initialDoc: Doc;
21 | };
22 |
23 | export type SyncDocParams = {
24 | doc: T;
25 | };
26 |
27 | export const useAutomergeSync = (config: AutomergeSyncConfig) => {
28 | const { initialDoc, ...rest } = config;
29 | // using a ref here since in the case of syncDoc we want a new doc, but also
30 | // want to make sure applyChanges and access the latest version in an old
31 | // or new render cycle
32 | const docRef = useRef>(initialDoc);
33 | const [, updateState] = useState({});
34 | const updateDocRefAndRender = useCallback((newDoc: Doc) => {
35 | docRef.current = newDoc;
36 | updateState({});
37 | }, []);
38 |
39 | // necessary to avoid that the same machine context is re-used for different or remounted pages
40 | // more info here:
41 | //
42 | // How to reproduce A:
43 | // 1. Open a Document a
44 | // 2. Open a Document b
45 | // 3. Open Document a again
46 | // How to reproduce B:
47 | // 1. Open a Document a
48 | // 2. During timeout click the Reload button
49 | //
50 | // more info: https://github.com/statelyai/xstate/issues/1101
51 | // related: https://github.com/statelyai/xstate/discussions/1825
52 | const [syncMachine1] = useState(() => createSyncMachine());
53 | const machine = useMachine(syncMachine1, {
54 | input: {
55 | ...rest,
56 | applySnapshot: (decryptedSnapshotData) => {
57 | let newDoc: Doc = Automerge.load(decryptedSnapshotData);
58 | if (newDoc) {
59 | newDoc = Automerge.merge(docRef.current, newDoc);
60 | }
61 | updateDocRefAndRender(newDoc);
62 | },
63 | applyChanges: (decryptedChanges) => {
64 | const [newDoc] = Automerge.applyChanges(
65 | docRef.current,
66 | decryptedChanges
67 | );
68 | updateDocRefAndRender(newDoc);
69 | },
70 | applyEphemeralMessage: (decryptedEphemeralMessage) => {},
71 | serializeChanges: (updates: Uint8Array[]) =>
72 | serializeUint8ArrayUpdates(updates, config.sodium),
73 | deserializeChanges: (serialized: string) =>
74 | deserializeUint8ArrayUpdates(serialized, config.sodium),
75 | },
76 | });
77 |
78 | const syncDoc = (newDoc: Doc) => {
79 | let changes = Automerge.getChanges(docRef.current, newDoc);
80 | updateDocRefAndRender(newDoc);
81 | machine[1]({ type: "ADD_CHANGES", data: changes });
82 | };
83 |
84 | const returnValue: [Doc, (newDoc: Doc) => void, ...typeof machine] = [
85 | docRef.current,
86 | syncDoc,
87 | ...machine,
88 | ];
89 | return returnValue;
90 | };
91 |
--------------------------------------------------------------------------------
/packages/secsync-react-automerge/test/config/jestTestSetup.ts:
--------------------------------------------------------------------------------
1 | import sodium from "libsodium-wrappers";
2 |
3 | // @ts-expect-error
4 | global.setImmediate = jest.useRealTimers;
5 |
6 | jest.setTimeout(25000);
7 |
8 | beforeEach(async () => {
9 | await sodium.ready;
10 | });
11 |
--------------------------------------------------------------------------------
/packages/secsync-react-automerge/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 |
4 | "compilerOptions": {
5 | "outDir": "./dist",
6 | "types": ["jest"]
7 | },
8 |
9 | "include": ["src/**/*"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/secsync-react-automerge/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | export default defineConfig({
4 | entry: ["src/index.ts"],
5 | clean: true,
6 | dts: true,
7 | format: ["cjs", "esm"],
8 | });
9 |
--------------------------------------------------------------------------------
/packages/secsync-react-devtool/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.5.0] - 2024-06-04
11 |
12 | - Version bump due secsync core version bump
13 |
14 | ## [0.4.0] - 2024-06-01
15 |
16 | ### Changed
17 |
18 | - dependency updates
19 |
20 | ## [0.3.0] - 2024-05-29
21 |
22 | ### Changed
23 |
24 | - Upgrade secsync dependency
25 |
26 | ## [0.2.0] - 2023-10-17
27 |
28 | ### Added
29 |
30 | - Initial version
31 |
--------------------------------------------------------------------------------
/packages/secsync-react-devtool/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ["@babel/preset-env", { targets: { node: "current" } }],
4 | "@babel/preset-typescript",
5 | ],
6 | };
7 |
--------------------------------------------------------------------------------
/packages/secsync-react-devtool/package-json-build-script.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const data = fs.readFileSync("./package.json", { encoding: "utf8", flag: "r" });
3 |
4 | // Display the file data
5 | const dataJson = JSON.parse(data);
6 |
7 | dataJson.module = "index.mjs";
8 | dataJson.types = "index.d.ts";
9 | dataJson.main = "index.js";
10 | dataJson.browser = "index.mjs";
11 |
12 | fs.writeFileSync("./dist/package.json", JSON.stringify(dataJson, null, 2));
13 |
--------------------------------------------------------------------------------
/packages/secsync-react-devtool/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "secsync-react-devtool",
3 | "version": "0.5.0",
4 | "main": "src/index",
5 | "types": "src/index",
6 | "scripts": {
7 | "build": "pnpm tsup && pnpm build:package-json",
8 | "build:package-json": "node package-json-build-script.js",
9 | "prepublishOnly": "pnpm run build",
10 | "test": "echo \"No tests\"",
11 | "ts:check": "pnpm tsc --noEmit",
12 | "lint": "echo \"No linting configured\""
13 | },
14 | "peerDependencies": {
15 | "react": "*",
16 | "secsync": "*",
17 | "yjs": "^13.6.14"
18 | },
19 | "devDependencies": {
20 | "@babel/core": "^7.24.6",
21 | "@babel/preset-env": "^7.24.6",
22 | "@babel/preset-typescript": "^7.24.6",
23 | "@types/jest": "^29.5.12",
24 | "@types/react": "^18.3.3",
25 | "jest": "^29.7.0",
26 | "secsync": "workspace:^"
27 | },
28 | "jest": {
29 | "setupFilesAfterEnv": [
30 | "/test/config/jestTestSetup.ts"
31 | ]
32 | },
33 | "publishConfig": {
34 | "directory": "dist",
35 | "linkDirectory": false
36 | },
37 | "repository": {
38 | "type": "git",
39 | "url": "git+https://github.com/serenity-kit/secsync.git"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/packages/secsync-react-devtool/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./DevTool";
2 |
--------------------------------------------------------------------------------
/packages/secsync-react-devtool/src/uniqueId.ts:
--------------------------------------------------------------------------------
1 | let idCounter = 0;
2 | export const uniqueId = () => {
3 | idCounter++;
4 | return idCounter;
5 | };
6 |
--------------------------------------------------------------------------------
/packages/secsync-react-devtool/test/config/jestTestSetup.ts:
--------------------------------------------------------------------------------
1 | import sodium from "libsodium-wrappers";
2 |
3 | // @ts-expect-error
4 | global.setImmediate = jest.useRealTimers;
5 |
6 | jest.setTimeout(25000);
7 |
8 | beforeEach(async () => {
9 | await sodium.ready;
10 | });
11 |
--------------------------------------------------------------------------------
/packages/secsync-react-devtool/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 |
4 | "compilerOptions": {
5 | "outDir": "./dist",
6 | "types": ["jest"]
7 | },
8 |
9 | "include": ["src/**/*"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/secsync-react-devtool/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | export default defineConfig({
4 | entry: ["src/index.ts"],
5 | clean: true,
6 | dts: true,
7 | format: ["cjs", "esm"],
8 | });
9 |
--------------------------------------------------------------------------------
/packages/secsync-react-yjs/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.5.0] - 2024-06-04
11 |
12 | - Version bump due secsync core version bump
13 |
14 | ## [0.4.0] - 2024-06-01
15 |
16 | ### Changed
17 |
18 | - dependency updates
19 |
20 | ## [0.3.0] - 2024-05-29
21 |
22 | ### Fixed
23 |
24 | - Instead of only allowing specific Yjs change origins now only change explicitly added by secsync are ignored
25 | - Correct check for window to avoid a react-native crash
26 | - Adding changes twice and causing the state machine to fail
27 | - Fix double events by aligning the "secsync-remote" event origin
28 |
29 | ### Changes
30 |
31 | - Upgraded dependencies
32 |
33 | ## [0.2.0] - 2023-09-29
34 |
35 | ### Added
36 |
37 | - Initial version
38 |
--------------------------------------------------------------------------------
/packages/secsync-react-yjs/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ["@babel/preset-env", { targets: { node: "current" } }],
4 | "@babel/preset-typescript",
5 | ],
6 | };
7 |
--------------------------------------------------------------------------------
/packages/secsync-react-yjs/package-json-build-script.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const data = fs.readFileSync("./package.json", { encoding: "utf8", flag: "r" });
3 |
4 | // Display the file data
5 | const dataJson = JSON.parse(data);
6 |
7 | dataJson.module = "index.mjs";
8 | dataJson.types = "index.d.ts";
9 | dataJson.main = "index.js";
10 | dataJson.browser = "index.mjs";
11 |
12 | fs.writeFileSync("./dist/package.json", JSON.stringify(dataJson, null, 2));
13 |
--------------------------------------------------------------------------------
/packages/secsync-react-yjs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "secsync-react-yjs",
3 | "version": "0.5.0",
4 | "main": "src/index",
5 | "types": "src/index",
6 | "scripts": {
7 | "build": "pnpm tsup && pnpm build:package-json",
8 | "build:package-json": "node package-json-build-script.js",
9 | "prepublishOnly": "pnpm run build",
10 | "test": "echo \"No tests\"",
11 | "ts:check": "pnpm tsc --noEmit",
12 | "lint": "echo \"No linting configured\""
13 | },
14 | "dependencies": {
15 | "@xstate/react": "^4.1.1",
16 | "lib0": "^0.2.94",
17 | "y-protocols": "^1.0.6"
18 | },
19 | "peerDependencies": {
20 | "react": "*",
21 | "secsync": "*",
22 | "yjs": "^13.6.14"
23 | },
24 | "devDependencies": {
25 | "@babel/core": "^7.24.6",
26 | "@babel/preset-env": "^7.24.6",
27 | "@babel/preset-typescript": "^7.24.6",
28 | "@types/jest": "^29.5.12",
29 | "@types/libsodium-wrappers": "^0.7.14",
30 | "@types/react": "^18.3.3",
31 | "jest": "^29.7.0",
32 | "libsodium-wrappers": "^0.7.13",
33 | "secsync": "workspace:^"
34 | },
35 | "jest": {
36 | "setupFilesAfterEnv": [
37 | "/test/config/jestTestSetup.ts"
38 | ]
39 | },
40 | "publishConfig": {
41 | "directory": "dist",
42 | "linkDirectory": false
43 | },
44 | "repository": {
45 | "type": "git",
46 | "url": "git+https://github.com/serenity-kit/secsync.git"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/packages/secsync-react-yjs/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./useYjsSync";
2 |
--------------------------------------------------------------------------------
/packages/secsync-react-yjs/test/config/jestTestSetup.ts:
--------------------------------------------------------------------------------
1 | import sodium from "libsodium-wrappers";
2 |
3 | // @ts-expect-error
4 | global.setImmediate = jest.useRealTimers;
5 |
6 | jest.setTimeout(25000);
7 |
8 | beforeEach(async () => {
9 | await sodium.ready;
10 | });
11 |
--------------------------------------------------------------------------------
/packages/secsync-react-yjs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 |
4 | "compilerOptions": {
5 | "outDir": "./dist",
6 | "types": ["jest"]
7 | },
8 |
9 | "include": ["src/**/*"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/secsync-react-yjs/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | export default defineConfig({
4 | entry: ["src/index.ts"],
5 | clean: true,
6 | dts: true,
7 | format: ["cjs", "esm"],
8 | });
9 |
--------------------------------------------------------------------------------
/packages/secsync-server/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.5.0] - 2024-06-04
11 |
12 | - Version bump due secsync core version bump
13 |
14 | ## [0.4.0] - 2024-06-01
15 |
16 | ### Added
17 |
18 | - Initial version
19 |
--------------------------------------------------------------------------------
/packages/secsync-server/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ["@babel/preset-env", { targets: { node: "current" } }],
4 | "@babel/preset-typescript",
5 | ],
6 | };
7 |
--------------------------------------------------------------------------------
/packages/secsync-server/package-json-build-script.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const data = fs.readFileSync("./package.json", { encoding: "utf8", flag: "r" });
3 |
4 | // Display the file data
5 | const dataJson = JSON.parse(data);
6 |
7 | dataJson.module = "index.mjs";
8 | dataJson.types = "index.d.ts";
9 | dataJson.main = "index.js";
10 | dataJson.browser = "index.mjs";
11 |
12 | fs.writeFileSync("./dist/package.json", JSON.stringify(dataJson, null, 2));
13 |
--------------------------------------------------------------------------------
/packages/secsync-server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "secsync-server",
3 | "version": "0.5.0",
4 | "main": "src/index",
5 | "types": "src/index",
6 | "scripts": {
7 | "build": "pnpm tsup && pnpm build:package-json",
8 | "build:package-json": "node package-json-build-script.js",
9 | "prepublishOnly": "pnpm run build",
10 | "test": "jest",
11 | "ts:check": "pnpm tsc --noEmit",
12 | "lint": "echo \"No linting configured\""
13 | },
14 | "dependencies": {
15 | "canonicalize": "^2.0.0",
16 | "libsodium-wrappers": "^0.7.13"
17 | },
18 | "peerDependencies": {
19 | "secsync": "*"
20 | },
21 | "devDependencies": {
22 | "@babel/core": "^7.24.6",
23 | "@babel/preset-env": "^7.24.6",
24 | "@babel/preset-typescript": "^7.24.6",
25 | "@types/jest": "^29.5.12",
26 | "@types/libsodium-wrappers": "^0.7.14",
27 | "@types/ws": "^8.5.10",
28 | "jest": "^29.7.0",
29 | "mock-socket": "^9.3.1",
30 | "secsync": "workspace:^"
31 | },
32 | "jest": {
33 | "setupFilesAfterEnv": [
34 | "/test/config/jestTestSetup.ts"
35 | ]
36 | },
37 | "publishConfig": {
38 | "directory": "dist",
39 | "linkDirectory": false
40 | },
41 | "repository": {
42 | "type": "git",
43 | "url": "git+https://github.com/serenity-kit/secsync.git"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/packages/secsync-server/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./createWebSocketConnection";
2 |
--------------------------------------------------------------------------------
/packages/secsync-server/src/store.ts:
--------------------------------------------------------------------------------
1 | import { HasBroadcastAccessParams } from "secsync";
2 | import WebSocket from "ws";
3 |
4 | type ConnectionEntry = { websocketSessionKey: string; websocket: WebSocket };
5 |
6 | const documents: { [key: string]: ConnectionEntry[] } = {};
7 | const messageQueues: { [key: string]: BroadcastMessageParams[] } = {};
8 |
9 | export type BroadcastMessageParams = {
10 | documentId: string;
11 | message: any;
12 | currentWebsocket: any;
13 | hasBroadcastAccess: (params: HasBroadcastAccessParams) => Promise;
14 | };
15 |
16 | export const broadcastMessage = async (params: BroadcastMessageParams) => {
17 | const { documentId } = params;
18 |
19 | if (!messageQueues[documentId]) {
20 | messageQueues[documentId] = [];
21 | }
22 |
23 | messageQueues[documentId].push(params);
24 |
25 | // only start processing if this is the only message in the queue to avoid overlapping calls
26 | if (messageQueues[documentId].length === 1) {
27 | processMessageQueue(documentId);
28 | }
29 | };
30 |
31 | const processMessageQueue = async (documentId: string) => {
32 | if (!documents[documentId] || messageQueues[documentId].length === 0) return;
33 |
34 | const { hasBroadcastAccess } = messageQueues[documentId][0];
35 |
36 | const websocketSessionKeys = documents[documentId].map(
37 | ({ websocketSessionKey }) => websocketSessionKey
38 | );
39 |
40 | const accessResults = await hasBroadcastAccess({
41 | documentId,
42 | websocketSessionKeys,
43 | });
44 |
45 | documents[documentId] = documents[documentId].filter(
46 | (_, index) => accessResults[index]
47 | );
48 |
49 | // Send all the messages in the queue to the allowed connections
50 | messageQueues[documentId].forEach(({ message, currentWebsocket }) => {
51 | documents[documentId].forEach(({ websocket }) => {
52 | if (websocket !== currentWebsocket) {
53 | websocket.send(JSON.stringify(message));
54 | }
55 | });
56 | });
57 |
58 | // clear the message queue after it's broadcasted
59 | messageQueues[documentId] = [];
60 | };
61 |
62 | export type AddConnectionParams = {
63 | documentId: string;
64 | websocket: WebSocket;
65 | websocketSessionKey: string;
66 | };
67 |
68 | export const addConnection = ({
69 | documentId,
70 | websocket,
71 | websocketSessionKey,
72 | }: AddConnectionParams) => {
73 | if (documents[documentId]) {
74 | documents[documentId].push({ websocket, websocketSessionKey });
75 | } else {
76 | documents[documentId] = [{ websocket, websocketSessionKey }];
77 | }
78 | };
79 |
80 | export type RemoveConnectionParams = {
81 | documentId: string;
82 | websocket: WebSocket;
83 | };
84 |
85 | export const removeConnection = ({
86 | documentId,
87 | websocket: currentWebsocket,
88 | }: RemoveConnectionParams) => {
89 | if (documents[documentId]) {
90 | documents[documentId] = documents[documentId].filter(
91 | ({ websocket }) => websocket !== currentWebsocket
92 | );
93 | }
94 | };
95 |
--------------------------------------------------------------------------------
/packages/secsync-server/src/utils/canonicalizeAndToBase64.ts:
--------------------------------------------------------------------------------
1 | import canonicalize from "canonicalize";
2 |
3 | export const canonicalizeAndToBase64 = (
4 | input: unknown,
5 | sodium: typeof import("libsodium-wrappers")
6 | ): string => {
7 | const canonicalized = canonicalize(input);
8 | if (!canonicalized) {
9 | throw new Error("Failed to canonicalize input");
10 | }
11 | return sodium.to_base64(canonicalized);
12 | };
13 |
--------------------------------------------------------------------------------
/packages/secsync-server/src/utils/retryAsyncFunction.test.ts:
--------------------------------------------------------------------------------
1 | import { retryAsyncFunction } from "./retryAsyncFunction";
2 |
3 | test("should return the result of asyncFunction when it is successful", async () => {
4 | const asyncFunction = jest.fn().mockResolvedValue("Success");
5 |
6 | const result = await retryAsyncFunction(asyncFunction);
7 | expect(result).toBe("Success");
8 | expect(asyncFunction).toHaveBeenCalledTimes(1);
9 | });
10 |
11 | test("should retry the function when it throws an unlisted error", async () => {
12 | const asyncFunction = jest
13 | .fn()
14 | .mockRejectedValueOnce(new Error("Retryable error"))
15 | .mockResolvedValueOnce("Success");
16 |
17 | const result = await retryAsyncFunction(asyncFunction);
18 |
19 | expect(result).toBe("Success");
20 | expect(asyncFunction).toHaveBeenCalledTimes(2);
21 | });
22 |
23 | test("should throw the error immediately when it is in the errorsToBailOn list", async () => {
24 | const asyncFunction = jest
25 | .fn()
26 | .mockRejectedValueOnce(new TypeError("Non-retryable error"));
27 | const errorsToBailOn = [TypeError];
28 |
29 | await expect(
30 | retryAsyncFunction(asyncFunction, errorsToBailOn)
31 | ).rejects.toThrow("Non-retryable error");
32 | expect(asyncFunction).toHaveBeenCalledTimes(1);
33 | });
34 |
35 | test("should stop retrying after maxRetries attempts and throw the last error", async () => {
36 | const asyncFunction = jest
37 | .fn()
38 | .mockRejectedValue(new Error("Retryable error"));
39 |
40 | await expect(retryAsyncFunction(asyncFunction, [], 2)).rejects.toThrow(
41 | "Retryable error"
42 | );
43 | expect(asyncFunction).toHaveBeenCalledTimes(2);
44 | });
45 |
--------------------------------------------------------------------------------
/packages/secsync-server/src/utils/retryAsyncFunction.ts:
--------------------------------------------------------------------------------
1 | export async function retryAsyncFunction(
2 | asyncFunction: () => Promise,
3 | errorsToBailOn: any[] = [],
4 | maxRetries: number = 5
5 | ): Promise {
6 | let delay = 100;
7 | let retries = 0;
8 |
9 | while (retries < maxRetries) {
10 | try {
11 | return await asyncFunction();
12 | } catch (err) {
13 | // if the error message is in the errorsToBailOn array, throw the error immediately
14 | for (const error of errorsToBailOn) {
15 | if (err instanceof error) {
16 | throw err;
17 | }
18 | }
19 | // increase retries count
20 | retries += 1;
21 | // ff the retries exceed maxRetries, throw the last error
22 | if (retries >= maxRetries) {
23 | throw err;
24 | }
25 | // wait for a delay before retrying the function
26 | await new Promise((resolve) => setTimeout(resolve, delay));
27 | // double the delay value
28 | delay *= 2;
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/packages/secsync-server/test/config/jestTestSetup.ts:
--------------------------------------------------------------------------------
1 | import sodium from "libsodium-wrappers";
2 | import { WebSocket } from "mock-socket";
3 |
4 | // @ts-expect-error
5 | global.setImmediate = jest.useRealTimers;
6 | global.WebSocket = WebSocket;
7 |
8 | jest.setTimeout(5000);
9 |
10 | beforeEach(async () => {
11 | await sodium.ready;
12 | });
13 |
--------------------------------------------------------------------------------
/packages/secsync-server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 |
4 | "compilerOptions": {
5 | "outDir": "./dist",
6 | "types": ["jest"]
7 | },
8 |
9 | "include": ["src/**/*"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/secsync-server/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | export default defineConfig({
4 | entry: ["src/index.ts"],
5 | clean: true,
6 | dts: true,
7 | format: ["cjs", "esm"],
8 | });
9 |
--------------------------------------------------------------------------------
/packages/secsync/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.5.0] - 2024-06-04
11 |
12 | ### Added
13 |
14 | - added `onPendingChangesUpdated` callback
15 | - added `pendingChanges` config
16 |
17 | ### Fixed
18 |
19 | - fix sync crashing due sending events to remove websocket actor
20 |
21 | ## [0.4.0] - 2024-06-01
22 |
23 | ### Changed
24 |
25 | - removed `createWebSocketConnection` and moved it to `secsync-server` package
26 | - dependency updates
27 |
28 | ## [0.3.0] - 2024-05-29
29 |
30 | ### Fixed
31 |
32 | - correctly remove changes from pendingChanges queue when sending a snapshot or update
33 | - correct check for window to avoid a react-native crash
34 |
35 | ### Changed
36 |
37 | - renamed `websocketHost` to `websocketEndpoint`
38 | - add signature context to harden the protocol
39 | - add aead robustness as recommend by libsoidum docs
40 | - upgraded xstate dependency
41 |
42 | ### Added
43 |
44 | - add support for passing additionPublicData to onDocumentUpdated
45 |
46 | ## [0.2.0] - 2023-09-29
47 |
48 | ### Added
49 |
50 | - Initial version
51 |
--------------------------------------------------------------------------------
/packages/secsync/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ["@babel/preset-env", { targets: { node: "current" } }],
4 | "@babel/preset-typescript",
5 | ],
6 | };
7 |
--------------------------------------------------------------------------------
/packages/secsync/package-json-build-script.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const data = fs.readFileSync("./package.json", { encoding: "utf8", flag: "r" });
3 |
4 | // Display the file data
5 | const dataJson = JSON.parse(data);
6 |
7 | dataJson.module = "index.mjs";
8 | dataJson.types = "index.d.ts";
9 | dataJson.main = "index.js";
10 | dataJson.browser = "index.mjs";
11 |
12 | fs.writeFileSync("./dist/package.json", JSON.stringify(dataJson, null, 2));
13 |
--------------------------------------------------------------------------------
/packages/secsync/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "secsync",
3 | "version": "0.5.0",
4 | "main": "src/index",
5 | "types": "src/index",
6 | "scripts": {
7 | "build": "pnpm build:xstate-types && pnpm tsup && pnpm build:package-json",
8 | "build:xstate-types": "xstate typegen \"./src/**/*.ts?(x)\"",
9 | "build:package-json": "node package-json-build-script.js",
10 | "prepublishOnly": "pnpm run build",
11 | "test": "jest",
12 | "ts:check": "pnpm tsc --noEmit",
13 | "lint": "echo \"No linting configured\""
14 | },
15 | "dependencies": {
16 | "canonicalize": "^2.0.0",
17 | "libsodium-wrappers": "^0.7.13",
18 | "xstate": "^5.13.0",
19 | "zod": "^3.23.8"
20 | },
21 | "devDependencies": {
22 | "@babel/core": "^7.24.6",
23 | "@babel/preset-env": "^7.24.6",
24 | "@babel/preset-typescript": "^7.24.6",
25 | "@types/jest": "^29.5.12",
26 | "@types/libsodium-wrappers": "^0.7.14",
27 | "@types/node": "^20.13.0",
28 | "@types/ws": "^8.5.10",
29 | "@xstate/cli": "^0.5.17",
30 | "jest": "^29.7.0",
31 | "mock-socket": "^9.3.1"
32 | },
33 | "jest": {
34 | "setupFilesAfterEnv": [
35 | "/test/config/jestTestSetup.ts"
36 | ]
37 | },
38 | "publishConfig": {
39 | "directory": "dist",
40 | "linkDirectory": false
41 | },
42 | "repository": {
43 | "type": "git",
44 | "url": "git+https://github.com/serenity-kit/secsync.git"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/packages/secsync/src/crypto/createSignatureKeyPair.ts:
--------------------------------------------------------------------------------
1 | import type { KeyPair } from "libsodium-wrappers";
2 |
3 | export function createSignatureKeyPair(
4 | sodium: typeof import("libsodium-wrappers")
5 | ): KeyPair {
6 | return sodium.crypto_sign_keypair();
7 | }
8 |
--------------------------------------------------------------------------------
/packages/secsync/src/crypto/decryptAead.ts:
--------------------------------------------------------------------------------
1 | export function decryptAead(
2 | ciphertext: Uint8Array,
3 | additionalData: string,
4 | key: Uint8Array,
5 | publicNonce: string,
6 | sodium: typeof import("libsodium-wrappers")
7 | ) {
8 | const robustnessTag = ciphertext.slice(0, 32);
9 | const ciphertextWithoutRobustnessTag = ciphertext.slice(32);
10 |
11 | const isValid = sodium.crypto_auth_verify(
12 | robustnessTag,
13 | publicNonce +
14 | sodium.to_base64(ciphertextWithoutRobustnessTag) +
15 | additionalData,
16 | key
17 | );
18 | if (!isValid) {
19 | throw new Error("Invalid robustness tag");
20 | }
21 |
22 | const content = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
23 | null,
24 | ciphertextWithoutRobustnessTag,
25 | additionalData,
26 | sodium.from_base64(publicNonce),
27 | key
28 | );
29 | return content;
30 | }
31 |
--------------------------------------------------------------------------------
/packages/secsync/src/crypto/encryptAead.ts:
--------------------------------------------------------------------------------
1 | export function encryptAead(
2 | message: Uint8Array | string,
3 | additionalData: string,
4 | key: Uint8Array,
5 | sodium: typeof import("libsodium-wrappers")
6 | ) {
7 | const publicNonceUint8Array = sodium.randombytes_buf(
8 | sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
9 | );
10 | const publicNonce = sodium.to_base64(publicNonceUint8Array);
11 | const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
12 | message,
13 | additionalData,
14 | null,
15 | publicNonceUint8Array,
16 | key
17 | );
18 |
19 | const robustnessTag = sodium.crypto_auth(
20 | publicNonce + sodium.to_base64(ciphertext) + additionalData,
21 | key
22 | );
23 |
24 | const finalCiphertext = new Uint8Array(
25 | robustnessTag.length + ciphertext.length
26 | );
27 | finalCiphertext.set(robustnessTag);
28 | finalCiphertext.set(ciphertext, robustnessTag.length);
29 |
30 | return {
31 | publicNonce,
32 | ciphertext: sodium.to_base64(finalCiphertext),
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/packages/secsync/src/crypto/generateId.test.ts:
--------------------------------------------------------------------------------
1 | import sodium from "libsodium-wrappers";
2 | import { generateId } from "./generateId";
3 |
4 | beforeAll(async () => {
5 | await sodium.ready;
6 | });
7 |
8 | test("should return a non-empty string", () => {
9 | const id = generateId(sodium);
10 | expect(typeof id).toBe("string");
11 | expect(id.length).toBeGreaterThan(0);
12 | });
13 |
14 | test("should return a base64 encoded string", () => {
15 | const id = generateId(sodium);
16 | const urlSafeBase64Regex =
17 | /^(?:[A-Za-z0-9-_]{4})*(?:[A-Za-z0-9-_]{2}==|[A-Za-z0-9-_]{3}=)?$/;
18 | expect(urlSafeBase64Regex.test(id)).toBe(true);
19 | });
20 |
21 | test("should return a URL-safe base64 encoded string without padding", () => {
22 | const id = generateId(sodium);
23 | const urlSafeBase64Regex =
24 | /^(?:[A-Za-z0-9-_]{4})*([A-Za-z0-9-_]{2}|[A-Za-z0-9-_]{3})?$/;
25 | expect(urlSafeBase64Regex.test(id)).toBe(true);
26 | });
27 |
28 | test("should return a 32 character string", () => {
29 | const id = generateId(sodium);
30 | expect(id.length).toBe(32);
31 | });
32 |
33 | test("should return unique ids on multiple calls", () => {
34 | const id1 = generateId(sodium);
35 | const id2 = generateId(sodium);
36 | const id3 = generateId(sodium);
37 |
38 | expect(id1).not.toBe(id2);
39 | expect(id1).not.toBe(id3);
40 | expect(id2).not.toBe(id3);
41 | });
42 |
--------------------------------------------------------------------------------
/packages/secsync/src/crypto/generateId.ts:
--------------------------------------------------------------------------------
1 | export const idLength = 24;
2 |
3 | // As pointed out in the initial SBA Research instead of using uuidv4() it's
4 | // recommended to use a cryptographically secure random number generator with
5 | // a minimum of 16 bytes of entropy.
6 | // Using a 24 bytes results in a base64 encoded string of 32 characters which
7 | // has an identical length to uuids, but an higher entropy.
8 | export const generateId = (sodium: typeof import("libsodium-wrappers")) => {
9 | return sodium.to_base64(sodium.randombytes_buf(idLength));
10 | };
11 |
--------------------------------------------------------------------------------
/packages/secsync/src/crypto/hash.ts:
--------------------------------------------------------------------------------
1 | export function hash(
2 | message: string | Uint8Array,
3 | sodium: typeof import("libsodium-wrappers")
4 | ) {
5 | return sodium.to_base64(sodium.crypto_generichash(32, message));
6 | }
7 |
--------------------------------------------------------------------------------
/packages/secsync/src/crypto/sign.ts:
--------------------------------------------------------------------------------
1 | import canonicalize from "canonicalize";
2 |
3 | export function sign(
4 | content: { [key in string]: string | number },
5 | signatureDomainContext: string,
6 | privateKey: Uint8Array,
7 | sodium: typeof import("libsodium-wrappers")
8 | ) {
9 | const message = canonicalize(content);
10 | if (typeof message !== "string") {
11 | throw new Error("Invalid content");
12 | }
13 | return sodium.to_base64(
14 | sodium.crypto_sign_detached(signatureDomainContext + message, privateKey)
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/packages/secsync/src/crypto/verifySignature.ts:
--------------------------------------------------------------------------------
1 | import canonicalize from "canonicalize";
2 |
3 | export function verifySignature(
4 | content: { [key in string]: string },
5 | signatureDomainContext: string,
6 | signature: string,
7 | publicKey: Uint8Array,
8 | sodium: typeof import("libsodium-wrappers")
9 | ) {
10 | const message = canonicalize(content);
11 | if (typeof message !== "string") {
12 | return false;
13 | }
14 | return sodium.crypto_sign_verify_detached(
15 | sodium.from_base64(signature),
16 | signatureDomainContext + message,
17 | publicKey
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/packages/secsync/src/ephemeralMessage/createEphemeralMessage.ts:
--------------------------------------------------------------------------------
1 | import type { KeyPair } from "libsodium-wrappers";
2 | import { encryptAead } from "../crypto/encryptAead";
3 | import { sign } from "../crypto/sign";
4 | import { EphemeralMessage, EphemeralMessagePublicData } from "../types";
5 | import { canonicalizeAndToBase64 } from "../utils/canonicalizeAndToBase64";
6 | import { intToUint8Array } from "../utils/intToUint8Array";
7 | import { prefixWithUint8Array } from "../utils/prefixWithUint8Array";
8 |
9 | export const messageTypes = {
10 | initialize: 1,
11 | proofAndRequestProof: 2,
12 | proof: 3,
13 | message: 4,
14 | };
15 |
16 | export function createEphemeralMessage(
17 | content: string | Uint8Array,
18 | type: keyof typeof messageTypes,
19 | publicData: EphemeralMessagePublicData,
20 | key: Uint8Array,
21 | authorSignatureKeyPair: KeyPair,
22 | authorSessionId: string,
23 | authorSessionCounter: number,
24 | sodium: typeof import("libsodium-wrappers")
25 | ) {
26 | const publicDataAsBase64 = canonicalizeAndToBase64(publicData, sodium);
27 |
28 | let prefixedContent = prefixWithUint8Array(
29 | content,
30 | intToUint8Array(authorSessionCounter)
31 | );
32 |
33 | // each EphemeralMessage is prefixed with the authorSessionId
34 | prefixedContent = prefixWithUint8Array(
35 | prefixedContent,
36 | sodium.from_base64(authorSessionId)
37 | );
38 |
39 | // each EphemeralMessage is prefixed with the message type
40 | prefixedContent = prefixWithUint8Array(
41 | prefixedContent,
42 | new Uint8Array([messageTypes[type]])
43 | );
44 |
45 | const { ciphertext, publicNonce } = encryptAead(
46 | prefixedContent,
47 | publicDataAsBase64,
48 | key,
49 | sodium
50 | );
51 | const signature = sign(
52 | {
53 | nonce: publicNonce,
54 | ciphertext,
55 | publicData: publicDataAsBase64,
56 | },
57 | "secsync_ephemeral_message",
58 | authorSignatureKeyPair.privateKey,
59 | sodium
60 | );
61 | const ephemeralMessage: EphemeralMessage = {
62 | nonce: publicNonce,
63 | ciphertext,
64 | publicData,
65 | signature,
66 | };
67 |
68 | return ephemeralMessage;
69 | }
70 |
--------------------------------------------------------------------------------
/packages/secsync/src/ephemeralMessage/createEphemeralSession.test.ts:
--------------------------------------------------------------------------------
1 | import sodium from "libsodium-wrappers";
2 | import { createEphemeralSession } from "./createEphemeralSession";
3 |
4 | beforeEach(async () => {
5 | await sodium.ready;
6 | });
7 |
8 | test("should return an object with id and counter", () => {
9 | const result = createEphemeralSession(sodium);
10 | expect(result).toHaveProperty("id");
11 | expect(typeof result.id).toBe("string");
12 | expect(result).toHaveProperty("counter");
13 | expect(typeof result.counter).toBe("number");
14 | });
15 |
--------------------------------------------------------------------------------
/packages/secsync/src/ephemeralMessage/createEphemeralSession.ts:
--------------------------------------------------------------------------------
1 | import { generateId } from "../crypto/generateId";
2 |
3 | export function createEphemeralSession(
4 | sodium: typeof import("libsodium-wrappers")
5 | ) {
6 | const id = generateId(sodium);
7 | // max value for randombytes_uniform is 4294967295 (0xffffffff)
8 | //
9 | // Math.floor(4294967295 / 2) = 2147483647 was picked as upper_bound
10 | // since as it leaves plenty of numbers to increase, but is large
11 | // enough to not reveal any relevant Meta data
12 | const counter = sodium.randombytes_uniform(2147483647);
13 | const content = {
14 | id,
15 | counter,
16 | };
17 |
18 | return {
19 | ...content,
20 | validSessions: {},
21 | };
22 | }
23 |
--------------------------------------------------------------------------------
/packages/secsync/src/ephemeralMessage/createEphemeralSessionProof.test.ts:
--------------------------------------------------------------------------------
1 | import sodium, { KeyPair } from "libsodium-wrappers";
2 | import { createEphemeralMessageProof } from "./createEphemeralSessionProof";
3 |
4 | let remoteClientSessionId: string;
5 | let currentClientSessionId: string;
6 | let currentClientSignatureKeyPair: KeyPair;
7 |
8 | beforeEach(async () => {
9 | await sodium.ready;
10 | remoteClientSessionId = "WVuBN_XDUmwzZaNc3tUKHV6NfbU-erx-";
11 | currentClientSessionId = "5ygax_FZvpZsizQV5hC23kGWFF_iyPLi";
12 |
13 | currentClientSignatureKeyPair = {
14 | privateKey: sodium.from_base64(
15 | "g3dtwb9XzhSzZGkxTfg11t1KEIb4D8rO7K54R6dnxArvgg_OzZ2GgREtG7F5LvNp3MS8p9vsio4r6Mq7SZDEgw"
16 | ),
17 | publicKey: sodium.from_base64(
18 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM"
19 | ),
20 | keyType: "ed25519",
21 | };
22 | });
23 |
24 | it("should return a valid signature", async () => {
25 | const proof = createEphemeralMessageProof(
26 | remoteClientSessionId,
27 | currentClientSessionId,
28 | currentClientSignatureKeyPair,
29 | sodium
30 | );
31 |
32 | expect(sodium.to_base64(proof)).toBe(
33 | "45Hyopdk68XfcMMjkG62DgZrl4cclTyIbcw-6wZVZ5OXcGXlBzjHeJBQ6pNO9XSILU5SXN4H-_cYqp_zYbkMAg"
34 | );
35 | });
36 |
37 | it("should throw error if any of the required parameters is missing", () => {
38 | expect(() => {
39 | createEphemeralMessageProof(
40 | remoteClientSessionId,
41 | currentClientSessionId,
42 | // @ts-expect-error
43 | null,
44 | sodium
45 | );
46 | }).toThrow();
47 |
48 | expect(() => {
49 | createEphemeralMessageProof(
50 | // @ts-expect-error
51 | null,
52 | currentClientSessionId,
53 | currentClientSignatureKeyPair,
54 | sodium
55 | );
56 | }).toThrow();
57 |
58 | expect(() => {
59 | createEphemeralMessageProof(
60 | remoteClientSessionId,
61 | // @ts-expect-error
62 | null,
63 | currentClientSignatureKeyPair,
64 | sodium
65 | );
66 | }).toThrow();
67 |
68 | expect(() => {
69 | createEphemeralMessageProof(
70 | remoteClientSessionId,
71 | currentClientSessionId,
72 | currentClientSignatureKeyPair,
73 | // @ts-expect-error
74 | null
75 | );
76 | }).toThrow();
77 | });
78 |
--------------------------------------------------------------------------------
/packages/secsync/src/ephemeralMessage/createEphemeralSessionProof.ts:
--------------------------------------------------------------------------------
1 | import type { KeyPair } from "libsodium-wrappers";
2 | import { z } from "zod";
3 | import { sign } from "../crypto/sign";
4 |
5 | export function createEphemeralMessageProof(
6 | remoteClientSessionId: string,
7 | currentClientSessionId: string,
8 | currentClientSignatureKeyPair: KeyPair,
9 | sodium: typeof import("libsodium-wrappers")
10 | ) {
11 | const SessionId = z.string();
12 |
13 | const signature = sign(
14 | {
15 | remoteClientSessionId: SessionId.parse(remoteClientSessionId),
16 | currentClientSessionId: SessionId.parse(currentClientSessionId),
17 | },
18 | "secsync_ephemeral_session_proof",
19 | currentClientSignatureKeyPair.privateKey,
20 | sodium
21 | );
22 |
23 | return sodium.from_base64(signature);
24 | }
25 |
--------------------------------------------------------------------------------
/packages/secsync/src/ephemeralMessage/parseEphemeralMessage.ts:
--------------------------------------------------------------------------------
1 | import { SomeZodObject } from "zod";
2 | import { EphemeralMessage } from "../types";
3 |
4 | export const parseEphemeralMessage = (
5 | ephemeralMessage: any,
6 | AdditionalValidation?: SomeZodObject
7 | ) => {
8 | const rawEphemeralMessage = EphemeralMessage.parse(ephemeralMessage);
9 | if (AdditionalValidation === undefined) return rawEphemeralMessage;
10 | const additionalData = AdditionalValidation.parse(
11 | ephemeralMessage.publicData
12 | );
13 | return {
14 | ...rawEphemeralMessage,
15 | publicData: {
16 | ...additionalData,
17 | ...rawEphemeralMessage.publicData,
18 | },
19 | };
20 | };
21 |
--------------------------------------------------------------------------------
/packages/secsync/src/ephemeralMessage/verifyEphemeralSessionProof.test.ts:
--------------------------------------------------------------------------------
1 | import sodium, { KeyPair } from "libsodium-wrappers";
2 | import { createEphemeralMessageProof } from "./createEphemeralSessionProof";
3 | import { verifyEphemeralSessionProof } from "./verifyEphemeralSessionProof";
4 |
5 | let remoteClientSessionId: string;
6 | let currentClientSessionId: string;
7 | let currentClientSignatureKeyPair: KeyPair;
8 | let proof: Uint8Array;
9 |
10 | beforeEach(async () => {
11 | await sodium.ready;
12 | remoteClientSessionId = "WVuBN_XDUmwzZaNc3tUKHV6NfbU-erx-";
13 | currentClientSessionId = "5ygax_FZvpZsizQV5hC23kGWFF_iyPLi";
14 |
15 | currentClientSignatureKeyPair = {
16 | privateKey: sodium.from_base64(
17 | "g3dtwb9XzhSzZGkxTfg11t1KEIb4D8rO7K54R6dnxArvgg_OzZ2GgREtG7F5LvNp3MS8p9vsio4r6Mq7SZDEgw"
18 | ),
19 | publicKey: sodium.from_base64(
20 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM"
21 | ),
22 | keyType: "ed25519",
23 | };
24 |
25 | proof = createEphemeralMessageProof(
26 | remoteClientSessionId,
27 | currentClientSessionId,
28 | currentClientSignatureKeyPair,
29 | sodium
30 | );
31 | });
32 |
33 | it("should return a valid signature", async () => {
34 | const isValid = verifyEphemeralSessionProof(
35 | proof,
36 | remoteClientSessionId,
37 | currentClientSessionId,
38 | currentClientSignatureKeyPair.publicKey,
39 | sodium
40 | );
41 |
42 | expect(isValid).toBe(true);
43 | });
44 |
45 | it("should throw error if any of the required parameters is missing", () => {
46 | const isValid = verifyEphemeralSessionProof(
47 | new Uint8Array([32, 0, 99]),
48 | remoteClientSessionId,
49 | currentClientSessionId,
50 | currentClientSignatureKeyPair.publicKey,
51 | sodium
52 | );
53 |
54 | expect(isValid).toBe(false);
55 |
56 | // flipped currentClientSessionId & remoteClientSessionId
57 | const isValid2 = verifyEphemeralSessionProof(
58 | proof,
59 | currentClientSessionId,
60 | remoteClientSessionId,
61 | currentClientSignatureKeyPair.publicKey,
62 | sodium
63 | );
64 |
65 | expect(isValid2).toBe(false);
66 | });
67 |
--------------------------------------------------------------------------------
/packages/secsync/src/ephemeralMessage/verifyEphemeralSessionProof.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { verifySignature } from "../crypto/verifySignature";
3 |
4 | export function verifyEphemeralSessionProof(
5 | signature: Uint8Array,
6 | remoteClientSessionId: string,
7 | currentClientSessionId: string,
8 | authorPublicKey: Uint8Array,
9 | sodium: typeof import("libsodium-wrappers")
10 | ) {
11 | try {
12 | const SessionId = z.string();
13 |
14 | return verifySignature(
15 | {
16 | remoteClientSessionId: SessionId.parse(remoteClientSessionId),
17 | currentClientSessionId: SessionId.parse(currentClientSessionId),
18 | },
19 | "secsync_ephemeral_session_proof",
20 | sodium.to_base64(signature),
21 | authorPublicKey,
22 | sodium
23 | );
24 | } catch (err) {
25 | return false;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/secsync/src/errors.ts:
--------------------------------------------------------------------------------
1 | export class SecsyncSnapshotBasedOnOutdatedSnapshotError extends Error {
2 | constructor(message: any) {
3 | super(message);
4 |
5 | this.name = this.constructor.name;
6 |
7 | // capturing the stack trace keeps the reference to your error class
8 | // https://github.com/microsoft/TypeScript/issues/1168#issuecomment-219296751
9 | this.stack = new Error().stack;
10 | }
11 | }
12 |
13 | export class SecsyncSnapshotMissesUpdatesError extends Error {
14 | constructor(message: any) {
15 | super(message);
16 |
17 | this.name = this.constructor.name;
18 |
19 | // capturing the stack trace keeps the reference to your error class
20 | // https://github.com/microsoft/TypeScript/issues/1168#issuecomment-219296751
21 | this.stack = new Error().stack;
22 | }
23 | }
24 |
25 | export class SecsyncNewSnapshotRequiredError extends Error {
26 | constructor(message: any) {
27 | super(message);
28 |
29 | this.name = this.constructor.name;
30 |
31 | // capturing the stack trace keeps the reference to your error class
32 | // https://github.com/microsoft/TypeScript/issues/1168#issuecomment-219296751
33 | this.stack = new Error().stack;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/packages/secsync/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./createSyncMachine";
2 | export * from "./crypto/createSignatureKeyPair";
3 | export * from "./crypto/decryptAead";
4 | export * from "./crypto/encryptAead";
5 | export * from "./crypto/generateId";
6 | export * from "./crypto/hash";
7 | export * from "./crypto/sign";
8 | export * from "./crypto/verifySignature";
9 | export * from "./ephemeralMessage/createEphemeralMessage";
10 | export * from "./ephemeralMessage/parseEphemeralMessage";
11 | export * from "./ephemeralMessage/verifyAndDecryptEphemeralMessage";
12 | export * from "./errors";
13 | export * from "./snapshot/createInitialSnapshot";
14 | export * from "./snapshot/createSnapshot";
15 | export * from "./snapshot/parseSnapshotWithClientData";
16 | export * from "./snapshot/verifyAndDecryptSnapshot";
17 | export * from "./types";
18 | export * from "./update/createUpdate";
19 | export * from "./update/parseUpdate";
20 | export * from "./update/verifyAndDecryptUpdate";
21 | export * from "./utils/compareUpdateClocks";
22 | export * from "./utils/deserializeUint8ArrayUpdates";
23 | export * from "./utils/serializeUint8ArrayUpdates";
24 |
--------------------------------------------------------------------------------
/packages/secsync/src/mocks.ts:
--------------------------------------------------------------------------------
1 | import type { KeyPair } from "libsodium-wrappers";
2 |
3 | export const defaultTestMachineInput = {
4 | signatureKeyPair: {} as KeyPair,
5 | applySnapshot: () => undefined,
6 | getSnapshotKey: () => Promise.resolve(new Uint8Array()),
7 | applyChanges: () => undefined,
8 | getNewSnapshotData: () => ({
9 | data: "",
10 | key: new Uint8Array(),
11 | publicData: {},
12 | }),
13 | applyEphemeralMessage: () => undefined,
14 | shouldSendSnapshot: () => false,
15 | serializeChanges: () => "",
16 | deserializeChanges: () => [],
17 | onDocumentUpdated: undefined,
18 | isValidClient: async () => false,
19 | };
20 |
--------------------------------------------------------------------------------
/packages/secsync/src/snapshot/createInitialSnapshot.ts:
--------------------------------------------------------------------------------
1 | import type { KeyPair } from "libsodium-wrappers";
2 | import { SnapshotPublicData } from "../types";
3 | import { createSnapshot } from "./createSnapshot";
4 |
5 | export function createInitialSnapshot(
6 | content: Uint8Array | string,
7 | publicData: SnapshotPublicData & AdditionalSnapshotPublicData,
8 | key: Uint8Array,
9 | signatureKeyPair: KeyPair,
10 | sodium: typeof import("libsodium-wrappers")
11 | ) {
12 | const snapshot = createSnapshot(
13 | content,
14 | publicData,
15 | key,
16 | signatureKeyPair,
17 | "",
18 | "",
19 | sodium
20 | );
21 | return snapshot;
22 | }
23 |
--------------------------------------------------------------------------------
/packages/secsync/src/snapshot/createParentSnapshotProof.test.ts:
--------------------------------------------------------------------------------
1 | import sodium from "libsodium-wrappers";
2 | import { hash } from "../crypto/hash";
3 | import { createParentSnapshotProof } from "./createParentSnapshotProof";
4 |
5 | const grandParentSnapshotProof = "abc";
6 | const parentSnapshotCiphertext = "cde";
7 | const parentSnapshotId = "efg";
8 |
9 | test("it returns a valid proof", () => {
10 | const parentSnapshotProof = createParentSnapshotProof({
11 | grandParentSnapshotProof,
12 | parentSnapshotId,
13 | parentSnapshotCiphertextHash: hash("abc", sodium),
14 | sodium,
15 | });
16 |
17 | expect(parentSnapshotProof).toEqual(
18 | "qxgOve6L8OoCogYaKEGF65vkPa7Gq2-DFsQbjwhXcIQ"
19 | );
20 | });
21 |
--------------------------------------------------------------------------------
/packages/secsync/src/snapshot/createParentSnapshotProof.ts:
--------------------------------------------------------------------------------
1 | import canonicalize from "canonicalize";
2 | import { hash } from "../crypto/hash";
3 |
4 | type CreateParentSnapshotProofParams = {
5 | grandParentSnapshotProof: string;
6 | parentSnapshotId: string;
7 | parentSnapshotCiphertextHash: string;
8 | sodium: typeof import("libsodium-wrappers");
9 | };
10 |
11 | export function createParentSnapshotProof({
12 | grandParentSnapshotProof,
13 | parentSnapshotId,
14 | parentSnapshotCiphertextHash,
15 | sodium,
16 | }: CreateParentSnapshotProofParams) {
17 | const snapshotProofData = canonicalize({
18 | grandParentSnapshotProof,
19 | parentSnapshotId,
20 | parentSnapshotCiphertextHash,
21 | })!;
22 | const parentSnapshotProof = hash(snapshotProofData, sodium);
23 | return parentSnapshotProof;
24 | }
25 |
--------------------------------------------------------------------------------
/packages/secsync/src/snapshot/createSnapshot.ts:
--------------------------------------------------------------------------------
1 | import type { KeyPair } from "libsodium-wrappers";
2 | import { encryptAead } from "../crypto/encryptAead";
3 | import { sign } from "../crypto/sign";
4 | import {
5 | Snapshot,
6 | SnapshotPublicData,
7 | SnapshotPublicDataWithParentSnapshotProof,
8 | } from "../types";
9 | import { canonicalizeAndToBase64 } from "../utils/canonicalizeAndToBase64";
10 | import { createParentSnapshotProof } from "./createParentSnapshotProof";
11 |
12 | export function createSnapshot(
13 | content: Uint8Array | string,
14 | publicData: SnapshotPublicData & AdditionalSnapshotPublicData,
15 | key: Uint8Array,
16 | signatureKeyPair: KeyPair,
17 | parentSnapshotCiphertextHash: string,
18 | grandParentSnapshotProof: string,
19 | sodium: typeof import("libsodium-wrappers")
20 | ) {
21 | const extendedPublicData: SnapshotPublicDataWithParentSnapshotProof &
22 | AdditionalSnapshotPublicData = {
23 | ...publicData,
24 | parentSnapshotProof: createParentSnapshotProof({
25 | parentSnapshotCiphertextHash,
26 | parentSnapshotId: publicData.parentSnapshotId,
27 | grandParentSnapshotProof,
28 | sodium,
29 | }),
30 | };
31 |
32 | const publicDataAsBase64 = canonicalizeAndToBase64(
33 | extendedPublicData,
34 | sodium
35 | );
36 |
37 | const { ciphertext, publicNonce } = encryptAead(
38 | content,
39 | publicDataAsBase64,
40 | key,
41 | sodium
42 | );
43 | const signature = sign(
44 | {
45 | nonce: publicNonce,
46 | ciphertext,
47 | publicData: publicDataAsBase64,
48 | },
49 | "secsync_snapshot",
50 | signatureKeyPair.privateKey,
51 | sodium
52 | );
53 | const snapshot: Snapshot & {
54 | publicData: AdditionalSnapshotPublicData & Snapshot["publicData"];
55 | } = {
56 | nonce: publicNonce,
57 | ciphertext,
58 | publicData: extendedPublicData,
59 | signature,
60 | };
61 |
62 | return snapshot;
63 | }
64 |
--------------------------------------------------------------------------------
/packages/secsync/src/snapshot/isValidAncestorSnapshot.ts:
--------------------------------------------------------------------------------
1 | import { hash } from "../crypto/hash";
2 | import { Snapshot, SnapshotProofChainEntry } from "../types";
3 | import { createParentSnapshotProof } from "./createParentSnapshotProof";
4 |
5 | type IsValidAncestorSnapshotParams = {
6 | knownSnapshotProofEntry: SnapshotProofChainEntry;
7 | snapshotProofChain: SnapshotProofChainEntry[];
8 | currentSnapshot: Snapshot;
9 | sodium: typeof import("libsodium-wrappers");
10 | };
11 |
12 | export function isValidAncestorSnapshot({
13 | knownSnapshotProofEntry,
14 | snapshotProofChain,
15 | currentSnapshot,
16 | sodium,
17 | }: IsValidAncestorSnapshotParams) {
18 | let isValid = true;
19 |
20 | if (
21 | knownSnapshotProofEntry.snapshotId ===
22 | currentSnapshot.publicData.snapshotId &&
23 | knownSnapshotProofEntry.snapshotCiphertextHash ===
24 | hash(currentSnapshot.ciphertext, sodium) &&
25 | knownSnapshotProofEntry.parentSnapshotProof ===
26 | currentSnapshot.publicData.parentSnapshotProof
27 | ) {
28 | return true;
29 | }
30 |
31 | if (!Array.isArray(snapshotProofChain)) {
32 | return false;
33 | }
34 |
35 | if (snapshotProofChain.length === 0) {
36 | return false;
37 | }
38 |
39 | // check the first entry with the known entry
40 | const known = createParentSnapshotProof({
41 | grandParentSnapshotProof: knownSnapshotProofEntry.parentSnapshotProof,
42 | parentSnapshotId: knownSnapshotProofEntry.snapshotId,
43 | parentSnapshotCiphertextHash:
44 | knownSnapshotProofEntry.snapshotCiphertextHash,
45 | sodium,
46 | });
47 |
48 | if (
49 | snapshotProofChain.length > 0 &&
50 | snapshotProofChain[0].parentSnapshotProof !== known
51 | ) {
52 | return false;
53 | }
54 |
55 | // check that the last chain entry matches the current snapshot
56 | if (
57 | snapshotProofChain[snapshotProofChain.length - 1].parentSnapshotProof !==
58 | currentSnapshot.publicData.parentSnapshotProof ||
59 | snapshotProofChain[snapshotProofChain.length - 1].snapshotCiphertextHash !==
60 | hash(currentSnapshot.ciphertext, sodium) ||
61 | snapshotProofChain[snapshotProofChain.length - 1].snapshotId !==
62 | currentSnapshot.publicData.snapshotId
63 | ) {
64 | return false;
65 | }
66 |
67 | // check all items in between
68 | snapshotProofChain.forEach((snapshotProofChainEntry, index) => {
69 | const { parentSnapshotProof, snapshotCiphertextHash, snapshotId } =
70 | snapshotProofChainEntry;
71 | const parentSnapshotProofBasedOnHash = createParentSnapshotProof({
72 | grandParentSnapshotProof: parentSnapshotProof,
73 | parentSnapshotId: snapshotId,
74 | parentSnapshotCiphertextHash: snapshotCiphertextHash,
75 | sodium,
76 | });
77 | if (
78 | index < snapshotProofChain.length - 1 &&
79 | parentSnapshotProofBasedOnHash !==
80 | snapshotProofChain[index + 1].parentSnapshotProof
81 | ) {
82 | isValid = false;
83 | }
84 | });
85 |
86 | return isValid;
87 | }
88 |
--------------------------------------------------------------------------------
/packages/secsync/src/snapshot/isValidParentSnapshot.ts:
--------------------------------------------------------------------------------
1 | import { Snapshot } from "../types";
2 | import { createParentSnapshotProof } from "./createParentSnapshotProof";
3 |
4 | type IsValidParentSnapshotParams = {
5 | snapshot: Snapshot;
6 | parentSnapshotId: string;
7 | parentSnapshotCiphertextHash: string;
8 | grandParentSnapshotProof: string;
9 | sodium: typeof import("libsodium-wrappers");
10 | };
11 |
12 | export function isValidParentSnapshot({
13 | snapshot,
14 | grandParentSnapshotProof,
15 | parentSnapshotId,
16 | parentSnapshotCiphertextHash,
17 | sodium,
18 | }: IsValidParentSnapshotParams) {
19 | const parentSnapshotProof = createParentSnapshotProof({
20 | parentSnapshotId,
21 | parentSnapshotCiphertextHash,
22 | grandParentSnapshotProof,
23 | sodium,
24 | });
25 | return (
26 | parentSnapshotProof === snapshot.publicData.parentSnapshotProof &&
27 | parentSnapshotId === snapshot.publicData.parentSnapshotId
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/packages/secsync/src/snapshot/parseSnapshot.ts:
--------------------------------------------------------------------------------
1 | import { SomeZodObject } from "zod";
2 | import { Snapshot } from "../types";
3 |
4 | export const parseSnapshot = (
5 | snapshot: any,
6 | AdditionalValidation?: SomeZodObject
7 | ) => {
8 | const rawSnapshot = Snapshot.parse(snapshot);
9 | if (AdditionalValidation === undefined) return { snapshot: rawSnapshot };
10 | const additionalPublicData = AdditionalValidation.parse(snapshot.publicData);
11 | return {
12 | snapshot: {
13 | ...rawSnapshot,
14 | publicData: {
15 | ...additionalPublicData,
16 | ...rawSnapshot.publicData,
17 | },
18 | },
19 | additionalPublicData,
20 | };
21 | };
22 |
--------------------------------------------------------------------------------
/packages/secsync/src/snapshot/parseSnapshotWithClientData.ts:
--------------------------------------------------------------------------------
1 | import { SomeZodObject } from "zod";
2 | import { SnapshotWithClientData } from "../types";
3 |
4 | export const parseSnapshotWithClientData = (
5 | snapshot: any,
6 | AdditionalValidation?: SomeZodObject
7 | ) => {
8 | const rawSnapshot = SnapshotWithClientData.parse(snapshot);
9 | if (AdditionalValidation === undefined) return rawSnapshot;
10 | const additionalData = AdditionalValidation.parse(snapshot.publicData);
11 | return {
12 | ...rawSnapshot,
13 | publicData: {
14 | ...additionalData,
15 | ...rawSnapshot.publicData,
16 | },
17 | };
18 | };
19 |
--------------------------------------------------------------------------------
/packages/secsync/src/snapshot/verifyAndDecryptSnapshot.ts:
--------------------------------------------------------------------------------
1 | import { decryptAead } from "../crypto/decryptAead";
2 | import { verifySignature } from "../crypto/verifySignature";
3 | import { Snapshot, SnapshotProofChainEntry } from "../types";
4 | import { canonicalizeAndToBase64 } from "../utils/canonicalizeAndToBase64";
5 | import { isValidParentSnapshot } from "./isValidParentSnapshot";
6 |
7 | export function verifyAndDecryptSnapshot(
8 | snapshot: Snapshot,
9 | key: Uint8Array,
10 | currentDocId: string,
11 | currentClientPublicKey: Uint8Array,
12 | sodium: typeof import("libsodium-wrappers"),
13 | parentSnapshotProofInfo?: SnapshotProofChainEntry,
14 | parentSnapshotUpdateClock?: number,
15 | logging?: "error" | "debug" | "off"
16 | ) {
17 | try {
18 | let publicKey: Uint8Array;
19 | let publicDataAsBase64: string;
20 |
21 | try {
22 | publicKey = sodium.from_base64(snapshot.publicData.pubKey);
23 |
24 | publicDataAsBase64 = canonicalizeAndToBase64(snapshot.publicData, sodium);
25 |
26 | const isValid = verifySignature(
27 | {
28 | nonce: snapshot.nonce,
29 | ciphertext: snapshot.ciphertext,
30 | publicData: publicDataAsBase64,
31 | },
32 | "secsync_snapshot",
33 | snapshot.signature,
34 | publicKey,
35 | sodium
36 | );
37 | if (!isValid) {
38 | return {
39 | error: new Error("SECSYNC_ERROR_111"),
40 | };
41 | }
42 | } catch (err) {
43 | if (logging === "error" || logging === "debug") {
44 | console.error(err);
45 | }
46 | return {
47 | error: new Error("SECSYNC_ERROR_111"),
48 | };
49 | }
50 |
51 | if (currentDocId !== snapshot.publicData.docId) {
52 | return {
53 | error: new Error("SECSYNC_ERROR_113"),
54 | };
55 | }
56 |
57 | if (parentSnapshotProofInfo) {
58 | try {
59 | const isValid = isValidParentSnapshot({
60 | snapshot,
61 | parentSnapshotCiphertextHash:
62 | parentSnapshotProofInfo.snapshotCiphertextHash,
63 | parentSnapshotId: parentSnapshotProofInfo.snapshotId,
64 | grandParentSnapshotProof: parentSnapshotProofInfo.parentSnapshotProof,
65 | sodium,
66 | });
67 | if (!isValid) {
68 | return {
69 | error: new Error("SECSYNC_ERROR_112"),
70 | };
71 | }
72 | } catch (err) {
73 | if (logging === "error" || logging === "debug") {
74 | console.error(err);
75 | }
76 | return {
77 | error: new Error("SECSYNC_ERROR_112"),
78 | };
79 | }
80 | }
81 |
82 | if (parentSnapshotUpdateClock !== undefined) {
83 | const currentClientPublicKeyString = sodium.to_base64(
84 | currentClientPublicKey
85 | );
86 |
87 | if (
88 | snapshot.publicData.parentSnapshotUpdateClocks[
89 | currentClientPublicKeyString
90 | ] !== parentSnapshotUpdateClock
91 | ) {
92 | return {
93 | error: new Error("SECSYNC_ERROR_102"),
94 | };
95 | }
96 | }
97 |
98 | try {
99 | const content = decryptAead(
100 | sodium.from_base64(snapshot.ciphertext),
101 | publicDataAsBase64,
102 | key,
103 | snapshot.nonce,
104 | sodium
105 | );
106 | return { content };
107 | } catch (err) {
108 | if (logging === "error" || logging === "debug") {
109 | console.error(err);
110 | }
111 | return {
112 | error: new Error("SECSYNC_ERROR_101"),
113 | };
114 | }
115 | } catch (err) {
116 | if (logging === "error" || logging === "debug") {
117 | console.error(err);
118 | }
119 | return {
120 | error: new Error("SECSYNC_ERROR_100"),
121 | };
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/packages/secsync/src/update/createUpdate.ts:
--------------------------------------------------------------------------------
1 | import type { KeyPair } from "libsodium-wrappers";
2 | import { encryptAead } from "../crypto/encryptAead";
3 | import { sign } from "../crypto/sign";
4 | import { Update, UpdatePublicData } from "../types";
5 | import { canonicalizeAndToBase64 } from "../utils/canonicalizeAndToBase64";
6 |
7 | export function createUpdate(
8 | content: string | Uint8Array,
9 | publicData: UpdatePublicData,
10 | key: Uint8Array,
11 | signatureKeyPair: KeyPair,
12 | clock: number,
13 | sodium: typeof import("libsodium-wrappers")
14 | ) {
15 | const publicDataWithClock = {
16 | ...publicData,
17 | clock,
18 | };
19 |
20 | const publicDataAsBase64 = canonicalizeAndToBase64(
21 | publicDataWithClock,
22 | sodium
23 | );
24 | const { ciphertext, publicNonce } = encryptAead(
25 | content,
26 | publicDataAsBase64,
27 | key,
28 | sodium
29 | );
30 | const signature = sign(
31 | {
32 | nonce: publicNonce,
33 | ciphertext,
34 | publicData: publicDataAsBase64,
35 | },
36 | "secsync_update",
37 | signatureKeyPair.privateKey,
38 | sodium
39 | );
40 |
41 | const update: Update = {
42 | nonce: publicNonce,
43 | ciphertext: ciphertext,
44 | publicData: publicDataWithClock,
45 | signature,
46 | };
47 |
48 | return update;
49 | }
50 |
--------------------------------------------------------------------------------
/packages/secsync/src/update/parseUpdate.ts:
--------------------------------------------------------------------------------
1 | import { SomeZodObject } from "zod";
2 | import { Update } from "../types";
3 |
4 | export const parseUpdate = (
5 | update: any,
6 | AdditionalValidation?: SomeZodObject
7 | ) => {
8 | const rawUpdate = Update.parse(update);
9 | if (AdditionalValidation === undefined) return rawUpdate;
10 | const additionalData = AdditionalValidation.parse(update.publicData);
11 | return {
12 | ...rawUpdate,
13 | publicData: {
14 | ...additionalData,
15 | ...rawUpdate.publicData,
16 | },
17 | };
18 | };
19 |
--------------------------------------------------------------------------------
/packages/secsync/src/update/verifyAndDecryptUpdate.ts:
--------------------------------------------------------------------------------
1 | import { decryptAead } from "../crypto/decryptAead";
2 | import { verifySignature } from "../crypto/verifySignature";
3 | import { Update } from "../types";
4 | import { canonicalizeAndToBase64 } from "../utils/canonicalizeAndToBase64";
5 |
6 | export function verifyAndDecryptUpdate(
7 | update: Update,
8 | key: Uint8Array,
9 | currentActiveSnapshotId: string,
10 | currentClock: number,
11 | sodium: typeof import("libsodium-wrappers"),
12 | logging?: "error" | "debug" | "off"
13 | ) {
14 | try {
15 | let publicDataAsBase64: string;
16 | try {
17 | publicDataAsBase64 = canonicalizeAndToBase64(update.publicData, sodium);
18 |
19 | const publicKey = sodium.from_base64(update.publicData.pubKey);
20 |
21 | const isValid = verifySignature(
22 | {
23 | nonce: update.nonce,
24 | ciphertext: update.ciphertext,
25 | publicData: publicDataAsBase64,
26 | },
27 | "secsync_update",
28 | update.signature,
29 | publicKey,
30 | sodium
31 | );
32 | if (!isValid) {
33 | return {
34 | error: new Error("SECSYNC_ERROR_212"),
35 | };
36 | }
37 | } catch (err) {
38 | if (logging === "error" || logging === "debug") {
39 | console.error(err);
40 | }
41 | return {
42 | error: new Error("SECSYNC_ERROR_212"),
43 | };
44 | }
45 |
46 | if (currentActiveSnapshotId !== update.publicData.refSnapshotId) {
47 | return { error: new Error("SECSYNC_ERROR_213") };
48 | }
49 |
50 | if (update.publicData.clock <= currentClock) {
51 | if (logging === "error" || logging === "debug") {
52 | console.warn(
53 | `Clock ${update.publicData.clock} is equal or lower than currentClock ${currentClock}`
54 | );
55 | }
56 | return { error: new Error("SECSYNC_ERROR_214") };
57 | }
58 |
59 | if (currentClock + 1 !== update.publicData.clock) {
60 | if (logging === "error" || logging === "debug") {
61 | console.error(
62 | `Clock ${currentClock} did increase by more than one. Incoming Clock: ${update.publicData.clock} `
63 | );
64 | }
65 | return { error: new Error("SECSYNC_ERROR_202") };
66 | }
67 |
68 | try {
69 | const content = decryptAead(
70 | sodium.from_base64(update.ciphertext),
71 | publicDataAsBase64,
72 | key,
73 | update.nonce,
74 | sodium
75 | );
76 |
77 | return { content, clock: update.publicData.clock };
78 | } catch (err) {
79 | if (logging === "error" || logging === "debug") {
80 | console.error(err);
81 | }
82 | return { error: new Error("SECSYNC_ERROR_201") };
83 | }
84 | } catch (err) {
85 | if (logging === "error" || logging === "debug") {
86 | console.error(err);
87 | }
88 | return { error: new Error("SECSYNC_ERROR_200") };
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/canonicalizeAndToBase64.ts:
--------------------------------------------------------------------------------
1 | import canonicalize from "canonicalize";
2 |
3 | export const canonicalizeAndToBase64 = (
4 | input: unknown,
5 | sodium: typeof import("libsodium-wrappers")
6 | ): string => {
7 | const canonicalized = canonicalize(input);
8 | if (!canonicalized) {
9 | throw new Error("Failed to canonicalize input");
10 | }
11 | return sodium.to_base64(canonicalized);
12 | };
13 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/compareUpdateClocks.test.ts:
--------------------------------------------------------------------------------
1 | import { SnapshotUpdateClocks } from "../types";
2 | import { compareUpdateClocks } from "./compareUpdateClocks";
3 |
4 | test("should return equal true if clocks are the same", () => {
5 | const clockA: SnapshotUpdateClocks = {
6 | key1: 1,
7 | key2: 2,
8 | };
9 | const result = compareUpdateClocks(clockA, clockA);
10 | expect(result).toEqual({ equal: true, missing: {} });
11 | });
12 |
13 | test("should return equal false if clocks are not the same", () => {
14 | const clockA: SnapshotUpdateClocks = {
15 | key1: 1,
16 | key2: 4,
17 | };
18 | const clockB: SnapshotUpdateClocks = {
19 | key1: 1,
20 | key2: 2,
21 | };
22 | const result = compareUpdateClocks(clockA, clockB);
23 | expect(result).toEqual({ equal: false, missing: { key2: 2 } });
24 | });
25 |
26 | test("should return missing keys from the first clock", () => {
27 | const clockA: SnapshotUpdateClocks = {
28 | key1: 1,
29 | key2: 2,
30 | };
31 | const clockB: SnapshotUpdateClocks = {
32 | key1: 1,
33 | };
34 | const result = compareUpdateClocks(clockA, clockB);
35 | expect(result).toEqual({ equal: false, missing: { key2: 0 } });
36 | });
37 |
38 | test("should return an empty missing object if second clock has extra keys", () => {
39 | const clockA: SnapshotUpdateClocks = {
40 | key1: 1,
41 | key2: 2,
42 | };
43 | const clockB: SnapshotUpdateClocks = {
44 | key1: 1,
45 | key2: 2,
46 | key3: 3,
47 | };
48 | const result = compareUpdateClocks(clockA, clockB);
49 | expect(result).toEqual({ equal: false, missing: {} });
50 | });
51 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/compareUpdateClocks.ts:
--------------------------------------------------------------------------------
1 | import { SnapshotUpdateClocks } from "../types";
2 |
3 | export const compareUpdateClocks = (
4 | updateClocksServer: SnapshotUpdateClocks,
5 | updateClocksClient: SnapshotUpdateClocks
6 | ): { equal: boolean; missing: SnapshotUpdateClocks } => {
7 | const clocksServer = SnapshotUpdateClocks.parse(updateClocksServer);
8 | const clocksClient = SnapshotUpdateClocks.parse(updateClocksClient);
9 |
10 | const keysServer = Object.keys(clocksServer);
11 | const keysClient = Object.keys(clocksClient);
12 |
13 | const equal =
14 | keysServer.every((key) => clocksClient[key] === clocksServer[key]) &&
15 | keysClient.every((key) => clocksServer[key] === clocksClient[key]);
16 |
17 | if (equal) {
18 | return { equal, missing: {} };
19 | }
20 |
21 | const missing = keysServer.reduce((acc: SnapshotUpdateClocks, key) => {
22 | return clocksServer[key] === undefined ||
23 | clocksServer[key] !== clocksClient[key]
24 | ? { ...acc, [key]: clocksClient[key] || 0 }
25 | : acc;
26 | }, {});
27 |
28 | return { equal, missing };
29 | };
30 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/deserializeUint8ArrayUpdates.test.ts:
--------------------------------------------------------------------------------
1 | import sodium from "libsodium-wrappers";
2 | import { deserializeUint8ArrayUpdates } from "./deserializeUint8ArrayUpdates";
3 | import { serializeUint8ArrayUpdates } from "./serializeUint8ArrayUpdates";
4 |
5 | test("should deserialize a single Uint8Array correctly", () => {
6 | const array = new Uint8Array([0, 1, 2, 3]);
7 | const serialized = serializeUint8ArrayUpdates([array], sodium);
8 | const deserialized = deserializeUint8ArrayUpdates(serialized, sodium);
9 | expect(deserialized.length).toBe(1);
10 | expect(deserialized[0]).toEqual(array);
11 | });
12 |
13 | test("should deserialize multiple Uint8Arrays correctly", () => {
14 | const array1 = new Uint8Array([0, 1, 2, 3]);
15 | const array2 = new Uint8Array([4, 5, 6]);
16 | const serialized = serializeUint8ArrayUpdates([array1, array2], sodium);
17 | const deserialized = deserializeUint8ArrayUpdates(serialized, sodium);
18 | expect(deserialized.length).toBe(2);
19 | expect(deserialized[0]).toEqual(array1);
20 | expect(deserialized[1]).toEqual(array2);
21 | });
22 |
23 | test("should throw an error if the serialized string is invalid", () => {
24 | expect(() => deserializeUint8ArrayUpdates("invalid", sodium)).toThrowError();
25 | });
26 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/deserializeUint8ArrayUpdates.ts:
--------------------------------------------------------------------------------
1 | export const deserializeUint8ArrayUpdates = (
2 | serialized: string,
3 | sodium: typeof import("libsodium-wrappers")
4 | ): Uint8Array[] => {
5 | const parsed = JSON.parse(serialized);
6 | return parsed.map((update: string) => sodium.from_base64(update));
7 | };
8 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/extractPrefixFromUint8Array.test.ts:
--------------------------------------------------------------------------------
1 | import { extractPrefixFromUint8Array } from "./extractPrefixFromUint8Array";
2 | test("should extract the prefix and value correctly", () => {
3 | const inputArray = new Uint8Array([1, 2, 3, 4, 5]);
4 | const amount = 2;
5 | const expectedResult = {
6 | prefix: new Uint8Array([1, 2]),
7 | value: new Uint8Array([3, 4, 5]),
8 | };
9 |
10 | const result = extractPrefixFromUint8Array(inputArray, amount);
11 |
12 | expect(result).toEqual(expectedResult);
13 | });
14 |
15 | test("should return an empty prefix and the original value when amount is zero", () => {
16 | const inputArray = new Uint8Array([1, 2, 3, 4, 5]);
17 | const amount = 0;
18 | const expectedResult = {
19 | prefix: new Uint8Array(),
20 | value: inputArray,
21 | };
22 |
23 | const result = extractPrefixFromUint8Array(inputArray, amount);
24 |
25 | expect(result).toEqual(expectedResult);
26 | });
27 |
28 | test("should return the original value as prefix and an empty value when amount equals the input array length", () => {
29 | const inputArray = new Uint8Array([1, 2, 3, 4, 5]);
30 | const amount = inputArray.length;
31 | const expectedResult = {
32 | prefix: inputArray,
33 | value: new Uint8Array(),
34 | };
35 |
36 | const result = extractPrefixFromUint8Array(inputArray, amount);
37 |
38 | expect(result).toEqual(expectedResult);
39 | });
40 |
41 | test("should throw an error when amount is larger than the input array length", () => {
42 | const inputArray = new Uint8Array([1, 2, 3, 4, 5]);
43 | const amount = 6;
44 |
45 | expect(() => {
46 | extractPrefixFromUint8Array(inputArray, amount);
47 | }).toThrowError(
48 | "Amount of prefix items to extract is larger than the Uint8Array"
49 | );
50 | });
51 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/extractPrefixFromUint8Array.ts:
--------------------------------------------------------------------------------
1 | export function extractPrefixFromUint8Array(
2 | uint8Array: Uint8Array,
3 | amount: number
4 | ): { prefix: Uint8Array; value: Uint8Array } {
5 | if (amount > uint8Array.length) {
6 | throw new Error(
7 | "Amount of prefix items to extract is larger than the Uint8Array"
8 | );
9 | }
10 |
11 | const prefix = uint8Array.slice(0, amount);
12 | const value = uint8Array.slice(amount);
13 |
14 | return { prefix, value };
15 | }
16 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/intToUint8Array.test.ts:
--------------------------------------------------------------------------------
1 | import { intToUint8Array } from "./intToUint8Array";
2 |
3 | test("convert 42 to Uint8Array", () => {
4 | const uint8 = intToUint8Array(42);
5 | expect(uint8).toEqual(new Uint8Array([42, 0, 0, 0]));
6 | });
7 |
8 | test("convert 256 to Uint8Array", () => {
9 | const uint8 = intToUint8Array(256);
10 | expect(uint8).toEqual(new Uint8Array([0, 1, 0, 0]));
11 | });
12 |
13 | test("convert 0 to Uint8Array", () => {
14 | const uint8 = intToUint8Array(0);
15 | expect(uint8).toEqual(new Uint8Array([0, 0, 0, 0]));
16 | });
17 |
18 | test("convert 3000575 to Uint8Array", () => {
19 | const uint8 = intToUint8Array(3000575);
20 | expect(uint8).toEqual(new Uint8Array([255, 200, 45, 0]));
21 | });
22 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/intToUint8Array.ts:
--------------------------------------------------------------------------------
1 | export const intToUint8Array = (int: number): Uint8Array => {
2 | if (!Number.isSafeInteger(int)) {
3 | throw new Error("Number is not a safe integer");
4 | }
5 |
6 | const buffer = new ArrayBuffer(4); // an integer is 4 bytes in JavaScript
7 | const view = new DataView(buffer);
8 | view.setUint32(0, int, true); // true for little-endian
9 | return new Uint8Array(buffer);
10 | };
11 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/prefixWithUint8Array.test.ts:
--------------------------------------------------------------------------------
1 | import { prefixWithUint8Array } from "./prefixWithUint8Array";
2 |
3 | const prefix = new Uint8Array([1, 2, 3]);
4 |
5 | test("concatenates prefix and string", () => {
6 | const value = "test";
7 | const expectedResult = "\x01\x02\x03test";
8 |
9 | const result = prefixWithUint8Array(value, prefix);
10 | expect(result).toBe(expectedResult);
11 | });
12 |
13 | test("concatenates prefix and Uint8Array", () => {
14 | const value = new Uint8Array([4, 5, 6]);
15 | const expectedResult = new Uint8Array([1, 2, 3, 4, 5, 6]);
16 |
17 | const result = prefixWithUint8Array(value, prefix);
18 | expect(result).toEqual(expectedResult);
19 | });
20 |
21 | test("returns empty string with empty string and empty prefix", () => {
22 | const value = "";
23 | const emptyPrefix = new Uint8Array([]);
24 |
25 | const result = prefixWithUint8Array(value, emptyPrefix);
26 | expect(result).toBe("");
27 | });
28 |
29 | test("returns empty Uint8Array with empty Uint8Array and empty prefix", () => {
30 | const value = new Uint8Array([]);
31 | const emptyPrefix = new Uint8Array([]);
32 |
33 | const result = prefixWithUint8Array(value, emptyPrefix);
34 | expect(result).toEqual(new Uint8Array([]));
35 | });
36 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/prefixWithUint8Array.ts:
--------------------------------------------------------------------------------
1 | export function prefixWithUint8Array(
2 | value: string | Uint8Array,
3 | prefix: Uint8Array
4 | ): string | Uint8Array {
5 | if (typeof value === "string") {
6 | const valueUint8Array = new Uint8Array(
7 | // @ts-ignore doesn't work with older ES targets
8 | [...value].map((char) => char.charCodeAt(0))
9 | );
10 | const result = new Uint8Array(prefix.length + valueUint8Array.length);
11 |
12 | result.set(prefix);
13 | result.set(valueUint8Array, prefix.length);
14 |
15 | // @ts-ignore fails on some environments and not in others
16 | return String.fromCharCode.apply(null, result);
17 | } else {
18 | const result = new Uint8Array(prefix.length + value.length);
19 |
20 | result.set(prefix);
21 | result.set(value, prefix.length);
22 |
23 | return result;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/serializeUint8ArrayUpdates.test.ts:
--------------------------------------------------------------------------------
1 | import sodium from "libsodium-wrappers";
2 | import { serializeUint8ArrayUpdates } from "./serializeUint8ArrayUpdates";
3 |
4 | test("serializes an array of Uint8Array updates to a JSON string of base64-encoded strings", () => {
5 | const update1 = new Uint8Array([1, 2, 3]);
6 | const update2 = new Uint8Array([4, 5, 6]);
7 | const serialized = serializeUint8ArrayUpdates([update1, update2], sodium);
8 | expect(serialized).toEqual(
9 | JSON.stringify([sodium.to_base64(update1), sodium.to_base64(update2)])
10 | );
11 | });
12 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/serializeUint8ArrayUpdates.ts:
--------------------------------------------------------------------------------
1 | export const serializeUint8ArrayUpdates = (
2 | updates: Uint8Array[],
3 | sodium: typeof import("libsodium-wrappers")
4 | ) => {
5 | return JSON.stringify(updates.map((update) => sodium.to_base64(update)));
6 | };
7 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/uint8ArrayToInt.test.ts:
--------------------------------------------------------------------------------
1 | import { intToUint8Array } from "./intToUint8Array";
2 | import { uint8ArrayToNumber } from "./uint8ArrayToInt";
3 |
4 | test("convert 42 to Uint8Array", () => {
5 | const uint8 = intToUint8Array(42);
6 | expect(uint8ArrayToNumber(uint8)).toEqual(42);
7 | });
8 |
9 | test("convert 0 to Uint8Array", () => {
10 | const uint8 = intToUint8Array(0);
11 | expect(uint8ArrayToNumber(uint8)).toEqual(0);
12 | });
13 |
14 | test("convert 3000575 to Uint8Array", () => {
15 | const uint8 = intToUint8Array(3000575);
16 | expect(uint8ArrayToNumber(uint8)).toEqual(3000575);
17 | });
18 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/uint8ArrayToInt.ts:
--------------------------------------------------------------------------------
1 | export const uint8ArrayToNumber = (uint8Array: Uint8Array): number => {
2 | const dataView = new DataView(uint8Array.buffer);
3 | return dataView.getUint32(0, true);
4 | };
5 |
--------------------------------------------------------------------------------
/packages/secsync/src/utils/updateUpdateClocksEntry.ts:
--------------------------------------------------------------------------------
1 | import { SnapshotInfoWithUpdateClocks } from "../types";
2 |
3 | type Params = {
4 | snapshotInfosWithUpdateClocks: SnapshotInfoWithUpdateClocks[];
5 | snapshotId: string;
6 | clientPublicKey: string;
7 | newClock: number;
8 | };
9 |
10 | export const updateUpdateClocksEntry = ({
11 | snapshotInfosWithUpdateClocks,
12 | snapshotId,
13 | clientPublicKey,
14 | newClock,
15 | }: Params) => {
16 | return snapshotInfosWithUpdateClocks.map((entry) => {
17 | if (
18 | entry.snapshotId === snapshotId &&
19 | // only apply the new clock if it's higher than the current one or doesn't exist
20 | (entry.updateClocks[clientPublicKey] === undefined ||
21 | (entry.updateClocks[clientPublicKey] !== undefined &&
22 | entry.updateClocks[clientPublicKey] < newClock))
23 | ) {
24 | return {
25 | ...entry,
26 | updateClocks: {
27 | ...entry.updateClocks,
28 | [clientPublicKey]: newClock,
29 | },
30 | };
31 | }
32 | return entry;
33 | });
34 | };
35 |
--------------------------------------------------------------------------------
/packages/secsync/test/config/jestTestSetup.ts:
--------------------------------------------------------------------------------
1 | import sodium from "libsodium-wrappers";
2 | import { WebSocket } from "mock-socket";
3 |
4 | // @ts-expect-error
5 | global.setImmediate = jest.useRealTimers;
6 | global.WebSocket = WebSocket;
7 |
8 | jest.setTimeout(5000);
9 |
10 | beforeEach(async () => {
11 | await sodium.ready;
12 | });
13 |
--------------------------------------------------------------------------------
/packages/secsync/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 |
4 | "compilerOptions": {
5 | "outDir": "./dist",
6 | "types": ["jest", "node"]
7 | },
8 |
9 | "include": ["src/**/*"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/secsync/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | export default defineConfig({
4 | entry: ["src/index.ts"],
5 | clean: true,
6 | dts: true,
7 | format: ["cjs", "esm"],
8 | });
9 |
--------------------------------------------------------------------------------
/packages/tiptap-extension-y-awareness/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.5.0] - 2024-06-04
11 |
12 | - Version bump due secsync core version bump
13 |
14 | ## [0.4.0] - 2024-06-01
15 |
16 | ### Changed
17 |
18 | - dependency updates
19 |
20 | ## [0.3.0] - 2024-05-29
21 |
22 | ### Changed
23 |
24 | - Upgrade secsync dependency
25 |
26 | ## [0.2.0] - 2023-09-29
27 |
28 | ### Added
29 |
30 | - Initial version
31 |
--------------------------------------------------------------------------------
/packages/tiptap-extension-y-awareness/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ["@babel/preset-env", { targets: { node: "current" } }],
4 | "@babel/preset-typescript",
5 | ],
6 | };
7 |
--------------------------------------------------------------------------------
/packages/tiptap-extension-y-awareness/package-json-build-script.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const data = fs.readFileSync("./package.json", { encoding: "utf8", flag: "r" });
3 |
4 | // Display the file data
5 | const dataJson = JSON.parse(data);
6 |
7 | dataJson.module = "index.mjs";
8 | dataJson.types = "index.d.ts";
9 | dataJson.main = "index.js";
10 | dataJson.browser = "index.mjs";
11 |
12 | fs.writeFileSync("./dist/package.json", JSON.stringify(dataJson, null, 2));
13 |
--------------------------------------------------------------------------------
/packages/tiptap-extension-y-awareness/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tiptap-extension-y-awareness",
3 | "version": "0.5.0",
4 | "main": "src/index",
5 | "types": "src/index",
6 | "scripts": {
7 | "build": "pnpm tsup && pnpm build:package-json",
8 | "build:package-json": "node package-json-build-script.js",
9 | "prepublishOnly": "pnpm run build",
10 | "test": "echo \"No tests\"",
11 | "ts:check": "pnpm tsc --noEmit",
12 | "lint": "echo \"No linting configured\""
13 | },
14 | "dependencies": {
15 | "y-protocols": "^1.0.6"
16 | },
17 | "peerDependencies": {
18 | "@tiptap/core": "*",
19 | "y-prosemirror": "*",
20 | "yjs": "^13.6.14"
21 | },
22 | "devDependencies": {
23 | "@babel/core": "^7.24.6",
24 | "@babel/preset-env": "^7.24.6",
25 | "@babel/preset-typescript": "^7.24.6",
26 | "@types/jest": "^29.5.12",
27 | "@types/react": "^18.3.3",
28 | "jest": "^29.7.0"
29 | },
30 | "jest": {
31 | "setupFilesAfterEnv": [
32 | "/test/config/jestTestSetup.ts"
33 | ]
34 | },
35 | "publishConfig": {
36 | "directory": "dist",
37 | "linkDirectory": false
38 | },
39 | "repository": {
40 | "type": "git",
41 | "url": "git+https://github.com/serenity-kit/secsync.git"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/tiptap-extension-y-awareness/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./tiptapExtensionYAwareness";
2 |
--------------------------------------------------------------------------------
/packages/tiptap-extension-y-awareness/src/tiptapExtensionYAwareness.ts:
--------------------------------------------------------------------------------
1 | import { Extension } from "@tiptap/core";
2 | import { yCursorPlugin } from "y-prosemirror";
3 | import { Awareness } from "y-protocols/awareness";
4 |
5 | export interface YAwarenessExtensionOptions {
6 | awareness?: Awareness;
7 | // user: Record;
8 | render(user: Record): HTMLElement;
9 | }
10 |
11 | type YAwarenessExtensionStorage = {
12 | users: { clientId: number; [key: string]: any }[];
13 | };
14 |
15 | export const YAwarenessExtension = Extension.create<
16 | YAwarenessExtensionOptions,
17 | YAwarenessExtensionStorage
18 | >({
19 | name: "yAwarenessExtension",
20 |
21 | addOptions() {
22 | return {
23 | awareness: undefined,
24 | render: (user) => {
25 | const cursor = document.createElement("span");
26 | cursor.style.setProperty("--collab-color", "#444");
27 | cursor.classList.add("collaboration-cursor__caret");
28 |
29 | const label = document.createElement("div");
30 | label.classList.add("collaboration-cursor__label");
31 |
32 | label.insertBefore(
33 | document.createTextNode(`Client publicKey: ${user.publicKey}`),
34 | null
35 | );
36 | cursor.insertBefore(label, null);
37 |
38 | return cursor;
39 | },
40 | };
41 | },
42 |
43 | addStorage() {
44 | return {
45 | users: [],
46 | };
47 | },
48 |
49 | addProseMirrorPlugins() {
50 | if (!this.options.awareness) return [];
51 | return [
52 | yCursorPlugin(
53 | this.options.awareness,
54 | // @ts-ignore
55 | {
56 | cursorBuilder: this.options.render,
57 | }
58 | ),
59 | ];
60 | },
61 | });
62 |
--------------------------------------------------------------------------------
/packages/tiptap-extension-y-awareness/test/config/jestTestSetup.ts:
--------------------------------------------------------------------------------
1 | import sodium from "libsodium-wrappers";
2 |
3 | // @ts-expect-error
4 | global.setImmediate = jest.useRealTimers;
5 |
6 | jest.setTimeout(25000);
7 |
8 | beforeEach(async () => {
9 | await sodium.ready;
10 | });
11 |
--------------------------------------------------------------------------------
/packages/tiptap-extension-y-awareness/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 |
4 | "compilerOptions": {
5 | "outDir": "./dist",
6 | "types": ["jest"]
7 | },
8 |
9 | "include": ["src/**/*"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/tiptap-extension-y-awareness/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | export default defineConfig({
4 | entry: ["src/index.ts"],
5 | clean: true,
6 | dts: true,
7 | format: ["cjs", "esm"],
8 | });
9 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - packages/*
3 | - examples/*
4 | - benchmarks/*
5 | - documentation
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "module": "commonjs",
5 | "target": "es2021",
6 | "sourceMap": true,
7 | "declaration": true,
8 | "declarationMap": true,
9 | "noEmitOnError": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "types": [],
13 | "jsx": "react",
14 | "baseUrl": ".",
15 | "paths": {
16 | "secsync": ["packages/secsync"],
17 | "secsync-server": ["packages/secsync-server"],
18 | "secsync-react-yjs": ["packages/secsync-react-yjs"],
19 | "secsync-react-automerge": ["secsync-react-automerge"],
20 | "secsync-react-devtool": ["secsync-react-devtool"],
21 | "tiptap-extension-y-awareness": ["tiptap-extension-y-awareness"]
22 | },
23 | "noEmit": true
24 | },
25 | "exclude": ["node_modules", "dist"]
26 | }
27 |
--------------------------------------------------------------------------------
/verifpal_and_threat_library/secsync_client_server.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/verifpal_and_threat_library/secsync_client_server.png
--------------------------------------------------------------------------------
/verifpal_and_threat_library/secsync_client_server.vp:
--------------------------------------------------------------------------------
1 | attacker[active]
2 |
3 | principal Alice[
4 | knows private enc_key
5 | knows private document_id
6 | knows private alice_privatekey
7 | alice_publickey = G^alice_privatekey
8 | ]
9 |
10 | principal Server[
11 | knows private document_id
12 | ]
13 |
14 | principal Alice[
15 | knows private msg
16 | generates snapshot_or_update_id
17 | additonal_data = CONCAT(alice_publickey, document_id, snapshot_or_update_id)
18 | ciphertext = AEAD_ENC(enc_key, msg, additonal_data)
19 | sig = SIGN(alice_privatekey, HASH(ciphertext, additonal_data))
20 | ]
21 |
22 | Alice -> Server: ciphertext, sig, additonal_data
23 |
24 | principal Server[
25 | alice_publickey_s, document_id_s, snapshot_or_update_id_s = SPLIT(additonal_data)
26 | _ = ASSERT(document_id, document_id_s)?
27 | ]
28 |
29 | queries[
30 | confidentiality? enc_key
31 | confidentiality? alice_privatekey
32 | confidentiality? msg
33 | freshness? ciphertext
34 | freshness? sig
35 | ]
36 |
--------------------------------------------------------------------------------
/verifpal_and_threat_library/secsync_clients.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/verifpal_and_threat_library/secsync_clients.png
--------------------------------------------------------------------------------
/verifpal_and_threat_library/secsync_clients.vp:
--------------------------------------------------------------------------------
1 | attacker[active]
2 |
3 | principal Alice[
4 | knows private enc_key
5 | knows public document_id
6 | knows private alice_privatekey
7 | alice_publickey = G^alice_privatekey
8 | ]
9 |
10 | principal Bob[
11 | knows private enc_key
12 | knows public document_id
13 | knows private bob_privatekey
14 | ]
15 |
16 | Alice -> Bob: [alice_publickey]
17 |
18 | principal Alice[
19 | knows private msg
20 | generates snapshot_or_update_id
21 | additonal_data = CONCAT(alice_publickey, snapshot_or_update_id)
22 | ciphertext = AEAD_ENC(enc_key, msg, additonal_data)
23 | sig = SIGN(alice_privatekey, HASH(ciphertext, additonal_data))
24 | ]
25 |
26 | Alice -> Bob: ciphertext, sig, additonal_data
27 |
28 | principal Bob[
29 | ciphertext_dec = AEAD_DEC(enc_key, ciphertext, additonal_data)?
30 | alice_publickey_1b, snapshot_or_update_id_b = SPLIT(additonal_data)
31 | _ = ASSERT(alice_publickey, alice_publickey_1b)?
32 | ]
33 |
34 | queries[
35 | confidentiality? enc_key
36 | confidentiality? alice_privatekey
37 | confidentiality? msg
38 | freshness? ciphertext
39 | freshness? additonal_data
40 | freshness? sig
41 | ]
42 |
--------------------------------------------------------------------------------
/verifpal_and_threat_library/secsync_clients_ephemeral.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/verifpal_and_threat_library/secsync_clients_ephemeral.png
--------------------------------------------------------------------------------
/verifpal_and_threat_library/secsync_clients_ephemeral.vp:
--------------------------------------------------------------------------------
1 | attacker[active]
2 |
3 | principal Alice[
4 | generates alice_session_id
5 | knows private alice_privatekey
6 | alice_publickey = G^alice_privatekey
7 | ]
8 |
9 | principal Bob[
10 | generates bob_session_id
11 | knows private bob_privatekey
12 | bob_publickey = G^bob_privatekey
13 | ]
14 |
15 | Alice -> Bob: [alice_publickey]
16 |
17 | Bob -> Alice: [bob_publickey]
18 |
19 | Alice -> Bob: alice_session_id
20 |
21 | principal Bob[
22 | session_hash = HASH(bob_publickey, alice_session_id, bob_session_id)
23 | session_proof = SIGN(bob_privatekey, session_hash)
24 | ]
25 |
26 | Bob -> Alice: bob_session_id, session_proof
27 |
28 | principal Alice[
29 | session_hash_2 = HASH(bob_publickey, alice_session_id, bob_session_id)
30 | sig = SIGNVERIF(bob_publickey, session_hash_2, session_proof)?
31 | ]
32 |
33 | principal Alice[
34 | session_hash_3 = HASH(alice_publickey, alice_session_id, bob_session_id)
35 | session_proof_2 = SIGN(alice_privatekey, session_hash_3)
36 | ]
37 |
38 | Alice -> Bob: session_proof_2
39 |
40 | principal Bob[
41 | session_hash_4 = HASH(alice_publickey, alice_session_id, bob_session_id)
42 | sig2 = SIGNVERIF(alice_publickey, session_hash_4, session_proof_2)?
43 | ]
44 |
45 | queries[
46 | confidentiality? alice_privatekey
47 | confidentiality? bob_privatekey
48 | authentication? Bob -> Alice: session_proof
49 | authentication? Alice -> Bob: session_proof_2
50 | ]
51 |
--------------------------------------------------------------------------------
/verifpal_and_threat_library/secsync_verifsign_client_server.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/verifpal_and_threat_library/secsync_verifsign_client_server.png
--------------------------------------------------------------------------------
/verifpal_and_threat_library/secsync_verifsign_client_server.vp:
--------------------------------------------------------------------------------
1 | attacker[active]
2 |
3 | principal Alice[
4 | knows private enc_key
5 | knows public document_id
6 | knows private alice_privatekey
7 | alice_publickey = G^alice_privatekey
8 | ]
9 |
10 | principal Server[
11 | knows public document_id
12 | ]
13 |
14 | Alice -> Server: [alice_publickey]
15 |
16 | principal Alice[
17 | knows private msg
18 | generates snapshot_or_update_id
19 | additonal_data = CONCAT(alice_publickey, snapshot_or_update_id)
20 | ciphertext = AEAD_ENC(enc_key, msg, additonal_data)
21 | sig = SIGN(alice_privatekey, HASH(ciphertext, additonal_data))
22 | ]
23 |
24 | Alice -> Server: ciphertext, sig, additonal_data
25 |
26 | principal Server[
27 | sig_valid = SIGNVERIF(alice_publickey, HASH(ciphertext, additonal_data), sig)?
28 | alice_publickey_1b, snapshot_or_update_id_b = SPLIT(additonal_data)
29 | _ = ASSERT(alice_publickey, alice_publickey_1b)?
30 | ]
31 |
32 | queries[
33 | confidentiality? enc_key
34 | confidentiality? alice_privatekey
35 | confidentiality? msg
36 | authentication? Alice -> Server: ciphertext
37 | authentication? Alice -> Server: additonal_data
38 | freshness? ciphertext
39 | freshness? additonal_data
40 | freshness? sig
41 | ]
42 |
--------------------------------------------------------------------------------
/verifpal_and_threat_library/secsync_verifsign_clients.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/verifpal_and_threat_library/secsync_verifsign_clients.png
--------------------------------------------------------------------------------
/verifpal_and_threat_library/secsync_verifsign_clients.vp:
--------------------------------------------------------------------------------
1 | attacker[active]
2 |
3 | principal Alice[
4 | knows private enc_key
5 | knows public document_id
6 | knows private alice_privatekey
7 | alice_publickey = G^alice_privatekey
8 | ]
9 |
10 | principal Bob[
11 | knows private enc_key
12 | knows public document_id
13 | knows private bob_privatekey
14 | ]
15 |
16 | Alice -> Bob: [alice_publickey]
17 |
18 | principal Alice[
19 | knows private msg
20 | generates snapshot_or_update_id
21 | additonal_data = CONCAT(alice_publickey, snapshot_or_update_id)
22 | ciphertext = AEAD_ENC(enc_key, msg, additonal_data)
23 | sig = SIGN(alice_privatekey, HASH(ciphertext, additonal_data))
24 | ]
25 |
26 | Alice -> Bob: ciphertext, sig, additonal_data
27 |
28 | principal Bob[
29 | sig_valid = SIGNVERIF(alice_publickey, HASH(ciphertext, additonal_data), sig)?
30 | ciphertext_dec = AEAD_DEC(enc_key, ciphertext, additonal_data)?
31 | alice_publickey_1b, snapshot_or_update_id_b = SPLIT(additonal_data)
32 | _ = ASSERT(alice_publickey, alice_publickey_1b)?
33 | ]
34 |
35 | queries[
36 | confidentiality? enc_key
37 | confidentiality? alice_privatekey
38 | confidentiality? msg
39 | authentication? Alice -> Bob: ciphertext
40 | authentication? Alice -> Bob: additonal_data
41 | freshness? ciphertext
42 | freshness? additonal_data
43 | freshness? sig
44 | ]
45 |
--------------------------------------------------------------------------------
/verifpal_and_threat_library/threat_library.numbers:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikgraf/secsync/03202ba91b847a19a5d94f09a1d5cc12207a5c42/verifpal_and_threat_library/threat_library.numbers
--------------------------------------------------------------------------------