├── .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 | 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 |
{ 67 | event.preventDefault(); 68 | yTodos.push([newTodoText]); 69 | setNewTodoText(""); 70 | }} 71 | > 72 | setNewTodoText(event.target.value)} 75 | value={newTodoText} 76 | className="new-todo" 77 | /> 78 | 79 | 80 | 81 |
    82 | {todos.map((entry, index) => { 83 | return ( 84 |
  • 85 |
    {entry}
    86 |
  • 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 |
{ 21 | event.preventDefault(); 22 | yTodos.push([newTodoText]); 23 | setNewTodoText(""); 24 | }} 25 | > 26 | setNewTodoText(event.target.value)} 29 | value={newTodoText} 30 | className="new-todo" 31 | /> 32 | 33 | 34 | 35 |
    36 | {todos.map((entry, index) => { 37 | return ( 38 |
  • 39 |
    {entry}
    40 |
  • 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 --------------------------------------------------------------------------------

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 |