├── demos ├── backend │ ├── .dockerignore │ ├── .env.example │ ├── src │ │ ├── database │ │ │ ├── prisma.ts │ │ │ ├── createUser.ts │ │ │ ├── getUserFromSession.ts │ │ │ ├── requestAuthenticationChallenge.ts │ │ │ ├── deleteOrganizationEventProposal.ts │ │ │ ├── addEventProposalToOrganization.ts │ │ │ ├── authenticate.ts │ │ │ ├── updateOrganizationEventProposal.ts │ │ │ ├── updateOrganizationMember.ts │ │ │ ├── getOrganizations.ts │ │ │ ├── createOrganization.ts │ │ │ ├── addMemberToOrganization.ts │ │ │ └── removeMemberFromOrganization.ts │ │ ├── schema.ts │ │ ├── graphql │ │ │ ├── Query.ts │ │ │ └── Mutation.ts │ │ └── index.ts │ ├── Dockerfile │ ├── prisma │ │ ├── migrations │ │ │ ├── migration_lock.toml │ │ │ └── 20220124041905_init │ │ │ │ └── migration.sql │ │ └── schema.prisma │ ├── tsconfig.json │ ├── README.md │ └── package.json └── frontend │ ├── .eslintrc.json │ ├── public │ ├── favicon.ico │ └── vercel.svg │ ├── next.config.js │ ├── graphql │ ├── queries │ │ ├── me.ts │ │ └── organizations.ts │ └── mutations │ │ ├── logout.ts │ │ ├── createUser.ts │ │ ├── authenticate.ts │ │ ├── createOrganization.ts │ │ ├── addMemberToOrganization.ts │ │ ├── updateOrganizationMember.ts │ │ ├── removeMemberFromOrganization.ts │ │ ├── addEventProposalToOrganization.ts │ │ ├── requestAuthenticationChallenge.ts │ │ ├── deleteOrganizationEventProposal.ts │ │ └── updateOrganizationEventProposal.ts │ ├── README.md │ ├── netlify.toml │ ├── next-env.d.ts │ ├── utils │ ├── convertToSigningKeyPair.ts │ └── createNewUserKeys.ts │ ├── .gitignore │ ├── tsconfig.json │ ├── package.json │ ├── hooks │ └── useLocalStorage.tsx │ └── pages │ └── index.tsx ├── .gitignore ├── lerna.json ├── packages ├── migrate │ ├── tsconfig.json │ ├── tsconfig.build.json │ ├── babel.config.js │ ├── package.json │ ├── README.md │ └── src │ │ ├── index.ts │ │ └── index.test.ts └── trust-chain │ ├── tsconfig.json │ ├── tsconfig.build.json │ ├── src │ ├── state │ │ ├── createKey.ts │ │ ├── decryptLockbox.ts │ │ ├── createLockboxes.ts │ │ ├── createLockbox.ts │ │ ├── applyStateUpdates.ts │ │ ├── crypto.ts │ │ ├── encryptState.ts │ │ ├── verifyAndApplyEncryptedState.test.ts │ │ ├── verifyAndApplyEncryptedState.ts │ │ ├── resolveEncryptedState.ts │ │ ├── createLockboxes.test.ts │ │ ├── resolveEncryptedState.test.ts │ │ └── resolveEncryptedState.addMember.test.ts │ ├── errors.ts │ ├── createChain.test.ts │ ├── resolveState.ts │ ├── index.ts │ ├── removeMember.ts │ ├── addAuthorToEvent.ts │ ├── createChain.ts │ ├── updateMember.ts │ ├── addMember.ts │ ├── applyCreateChainEvent.ts │ ├── resolveState.test.ts │ ├── testUtils.ts │ ├── utils.ts │ ├── resolveState.createChain.test.ts │ ├── types.ts │ ├── applyEvent.ts │ ├── resolveState.addMember.test.ts │ ├── resolveState.removeMember.test.ts │ └── resolveState.updateMember.test.ts │ ├── babel.config.js │ ├── package.json │ └── README.md ├── tsconfig.json ├── docker-compose.yml ├── tsconfig.build.json ├── README.md ├── package.json ├── .github └── workflows │ ├── deploy.yml │ └── test.yml └── LICENSE /demos/backend/.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /demos/frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/dist 3 | lerna-debug.log 4 | yarn-error.log 5 | build 6 | 7 | generated 8 | .vscode 9 | .env -------------------------------------------------------------------------------- /demos/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serenity-kit/serenity-tools/HEAD/demos/frontend/public/favicon.ico -------------------------------------------------------------------------------- /demos/backend/.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_URL="postgresql://prisma:prisma@localhost:5432/trust-chain" 2 | EXPRESS_SESSION_SECRET="REPLACE THIS VALUE" -------------------------------------------------------------------------------- /demos/frontend/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true, 3 | experimental: { 4 | externalDir: true, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /demos/backend/src/database/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "../../prisma/generated/output"; 2 | 3 | export const prisma = new PrismaClient(); 4 | -------------------------------------------------------------------------------- /demos/frontend/graphql/queries/me.ts: -------------------------------------------------------------------------------- 1 | export const meQueryString = ` 2 | query { 3 | me { 4 | signingPublicKey 5 | } 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*", "demos/*"], 3 | "version": "independent", 4 | "useWorkspaces": true, 5 | "npmClient": "yarn" 6 | } 7 | -------------------------------------------------------------------------------- /demos/frontend/graphql/mutations/logout.ts: -------------------------------------------------------------------------------- 1 | export const logoutMutationString = ` 2 | mutation { 3 | logout { 4 | success 5 | } 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /demos/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 2 | 3 | # Create app directory 4 | WORKDIR /usr/src/app 5 | 6 | COPY . . 7 | 8 | EXPOSE $PORT 9 | CMD ["npm", "run", "start:prod" ] -------------------------------------------------------------------------------- /demos/frontend/README.md: -------------------------------------------------------------------------------- 1 | ## Dev 2 | 3 | ```bash 4 | yarn dev 5 | ``` 6 | 7 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 8 | -------------------------------------------------------------------------------- /demos/frontend/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = ".next" 3 | 4 | [[plugins]] 5 | package = "@netlify/plugin-nextjs" 6 | 7 | [build.environment] 8 | NODE_VERSION = "16" -------------------------------------------------------------------------------- /packages/migrate/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | 4 | "compilerOptions": { 5 | "outDir": "./dist", 6 | "types": ["jest"] 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /packages/trust-chain/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | 4 | "compilerOptions": { 5 | "outDir": "./dist", 6 | "types": ["jest"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /demos/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" -------------------------------------------------------------------------------- /demos/frontend/graphql/mutations/createUser.ts: -------------------------------------------------------------------------------- 1 | export const createUserMutationString = ` 2 | mutation ($input: CreateUserInput!) { 3 | createUser (input: $input) { 4 | success 5 | } 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /demos/frontend/graphql/mutations/authenticate.ts: -------------------------------------------------------------------------------- 1 | export const authenticateMutationString = ` 2 | mutation ($input: AuthenticateInput!) { 3 | authenticate (input: $input) { 4 | success 5 | } 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /packages/trust-chain/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | 4 | "compilerOptions": { 5 | "outDir": "./dist", 6 | "types": ["jest"] 7 | }, 8 | 9 | "include": ["src/**/*"] 10 | } 11 | -------------------------------------------------------------------------------- /demos/frontend/graphql/mutations/createOrganization.ts: -------------------------------------------------------------------------------- 1 | export const createOrganizationMutationString = ` 2 | mutation ($input: CreateOrganizationInput!) { 3 | createOrganization (input: $input) { 4 | success 5 | } 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "@serenity-tools/*": ["packages/*/src"] 8 | }, 9 | "noEmit": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/migrate/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | 4 | "compilerOptions": { 5 | "outDir": "./dist", 6 | "types": ["jest"] 7 | }, 8 | 9 | "include": [ 10 | "src/**/*" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /demos/backend/src/database/createUser.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "./prisma"; 2 | 3 | export async function createUser(signingPublicKey: string) { 4 | return await prisma.user.create({ 5 | data: { publicSigningKey: signingPublicKey }, 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /demos/frontend/graphql/mutations/addMemberToOrganization.ts: -------------------------------------------------------------------------------- 1 | export const addMemberToOrganizationMutationString = ` 2 | mutation ($input: AddMemberToOrganizationInput!) { 3 | addMemberToOrganization (input: $input) { 4 | success 5 | } 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /demos/frontend/graphql/mutations/updateOrganizationMember.ts: -------------------------------------------------------------------------------- 1 | export const updateOrganizationMemberMutationString = ` 2 | mutation ($input: UpdateOrganizationMemberInput!) { 3 | updateOrganizationMember (input: $input) { 4 | success 5 | } 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /demos/frontend/graphql/mutations/removeMemberFromOrganization.ts: -------------------------------------------------------------------------------- 1 | export const removeMemberFromOrganizationMutationString = ` 2 | mutation ($input: RemoveMemberFromOrganizationInput!) { 3 | removeMemberFromOrganization (input: $input) { 4 | success 5 | } 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /demos/frontend/graphql/mutations/addEventProposalToOrganization.ts: -------------------------------------------------------------------------------- 1 | export const addEventProposalToOrganizationMutationString = ` 2 | mutation ($input: AddEventProposalToOrganizationInput!) { 3 | addEventProposalToOrganization (input: $input) { 4 | success 5 | } 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /demos/frontend/graphql/mutations/requestAuthenticationChallenge.ts: -------------------------------------------------------------------------------- 1 | export const requestAuthenticationChallengeMutationString = ` 2 | mutation ($input: RequestAuthenticationChallengeInput!) { 3 | requestAuthenticationChallenge (input: $input) { 4 | nonce 5 | } 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /demos/frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /demos/frontend/graphql/mutations/deleteOrganizationEventProposal.ts: -------------------------------------------------------------------------------- 1 | export const deleteOrganizationEventProposalMutationString = ` 2 | mutation ($input: DeleteOrganizationEventProposalInput!) { 3 | deleteOrganizationEventProposal (input: $input) { 4 | success 5 | } 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /demos/frontend/graphql/mutations/updateOrganizationEventProposal.ts: -------------------------------------------------------------------------------- 1 | export const updateOrganizationEventProposalMutationString = ` 2 | mutation ($input: UpdateOrganizationEventProposalInput!) { 3 | updateOrganizationEventProposal (input: $input) { 4 | success 5 | } 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /demos/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 | -------------------------------------------------------------------------------- /packages/trust-chain/src/state/createKey.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | import sodium from "libsodium-wrappers"; 3 | import { Key } from "../types"; 4 | 5 | export const createKey = (): Key => { 6 | const key = sodium.crypto_secretbox_keygen(); 7 | 8 | return { 9 | keyId: uuidv4(), 10 | key: sodium.to_base64(key), 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /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: -------------------------------------------------------------------------------- /packages/migrate/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | 7 | plugins: [ 8 | [ 9 | "module-resolver", 10 | { 11 | alias: { 12 | "^@serenity-tools/(.+)": "../../packages/\\1/src", 13 | }, 14 | }, 15 | ], 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /packages/trust-chain/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | 7 | plugins: [ 8 | [ 9 | "module-resolver", 10 | { 11 | alias: { 12 | "^@serenity-tools/(.+)": "../../packages/\\1/src", 13 | }, 14 | }, 15 | ], 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /demos/backend/README.md: -------------------------------------------------------------------------------- 1 | ## Setup 2 | 3 | ``` 4 | cp .env.example .env 5 | ``` 6 | 7 | ## Dev 8 | 9 | ```sh 10 | # in the project root run 11 | docker-compose up 12 | 13 | # in backend root 14 | yarn prisma migrate dev 15 | yarn prisma generate 16 | yarn dev 17 | ``` 18 | 19 | ## Production DB Migrations 20 | 21 | ```sh 22 | export POSTGRES_URL= 23 | yarn prisma:prod:migrate 24 | ``` 25 | -------------------------------------------------------------------------------- /demos/frontend/utils/convertToSigningKeyPair.ts: -------------------------------------------------------------------------------- 1 | import { KeyPairBase64 } from "@serenity-tools/trust-chain"; 2 | import sodium from "libsodium-wrappers"; 3 | 4 | export function convertToSigningKeyPair(props: KeyPairBase64): sodium.KeyPair { 5 | return { 6 | privateKey: sodium.from_base64(props.privateKey), 7 | publicKey: sodium.from_base64(props.publicKey), 8 | keyType: "ed25519", 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2016", 5 | "sourceMap": true, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "noEmitOnError": true, 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "types": [], 12 | "jsx": "react", 13 | "noEmit": false 14 | }, 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | -------------------------------------------------------------------------------- /demos/backend/src/schema.ts: -------------------------------------------------------------------------------- 1 | import { makeSchema } from "nexus"; 2 | import path from "path"; 3 | import * as QueryTypes from "./graphql/Query"; 4 | import * as MutationTypes from "./graphql/Mutation"; 5 | 6 | export const schema = makeSchema({ 7 | plugins: [], 8 | types: [QueryTypes, MutationTypes], 9 | outputs: { 10 | schema: path.join(__dirname, "/generated/schema.graphql"), 11 | typegen: path.join(__dirname, "/generated/typings.ts"), 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serenity Tools 2 | 3 | Collection of tools to build secure Apps. 4 | 5 | ## Trust Chain 6 | 7 | A cryptographically verifyable chain of events to determine a list team members. 8 | 9 | For usage details see the [Trust Chain README](./packages/trust-chain/README.md) 10 | 11 | ## Migrate 12 | 13 | Migrate can be used to run migrations on Databases using WebSQL API. The migrate function accepts a list migrations that should run. 14 | 15 | For usage details see the [Migrate README](./packages/migrate/README.md) 16 | -------------------------------------------------------------------------------- /packages/trust-chain/src/state/decryptLockbox.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { Lockbox } from "../types"; 3 | 4 | export const decryptLockbox = (privateKey: string, lockbox: Lockbox) => { 5 | const decrypted = sodium.crypto_box_open_easy( 6 | sodium.from_base64(lockbox.ciphertext), 7 | sodium.from_base64(lockbox.nonce), 8 | sodium.from_base64(lockbox.senderLockboxPublicKey), 9 | sodium.from_base64(privateKey) 10 | ); 11 | 12 | return JSON.parse(sodium.to_string(decrypted)); 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serenity-tools", 3 | "description": "Collection of tools to build secure Apps", 4 | "private": true, 5 | "license": "MIT", 6 | "workspaces": [ 7 | "packages/*", 8 | "demos/*" 9 | ], 10 | "scripts": { 11 | "clean": "lerna run clean", 12 | "build": "lerna run build", 13 | "pub": "lerna publish", 14 | "test": "lerna run test" 15 | }, 16 | "devDependencies": { 17 | "lerna": "^4.0.0", 18 | "prettier": "^2.4.1", 19 | "typescript": "^4.4.4" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /demos/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /demos/frontend/utils/createNewUserKeys.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | 3 | export function createNewUserKeys() { 4 | const signingKeys = sodium.crypto_sign_keypair(); 5 | const boxKeypair = sodium.crypto_box_keypair(); 6 | return { 7 | sign: { 8 | privateKey: sodium.to_base64(signingKeys.privateKey), 9 | publicKey: sodium.to_base64(signingKeys.publicKey), 10 | }, 11 | lockbox: { 12 | privateKey: sodium.to_base64(boxKeypair.privateKey), 13 | publicKey: sodium.to_base64(boxKeypair.publicKey), 14 | }, 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: "Deploy API to Heroku" 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@v2 13 | - run: cd demos/backend && yarn build 14 | - uses: akhileshns/heroku-deploy@v3.12.12 15 | with: 16 | heroku_api_key: ${{secrets.HEROKU_API_KEY}} 17 | heroku_app_name: ${{secrets.HEROKU_APP_NAME}} 18 | heroku_email: ${{secrets.HEROKU_EMAIL}} 19 | usedocker: true 20 | appdir: "demos/backend" 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: yarn 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - npm 7 | pull_request: 8 | branches: 9 | - $default-branch 10 | 11 | jobs: 12 | tests: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | 19 | - name: Install Node 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: 14 23 | cache: 'yarn' 24 | 25 | - name: Install dependencies 26 | run: yarn --frozen-lockfile 27 | 28 | - name: Test 29 | run: yarn test 30 | -------------------------------------------------------------------------------- /demos/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | 4 | "compilerOptions": { 5 | "target": "es5", 6 | "lib": ["dom", "dom.iterable", "esnext"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve" 19 | }, 20 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/trust-chain/src/errors.ts: -------------------------------------------------------------------------------- 1 | export class InvalidTrustChainError extends Error { 2 | constructor(message) { 3 | super(message); 4 | 5 | this.name = this.constructor.name; 6 | 7 | // capturing the stack trace keeps the reference to your error class 8 | Error.captureStackTrace(this, this.constructor); 9 | } 10 | } 11 | 12 | export class InvalidEncryptedStateError extends Error { 13 | constructor(message) { 14 | super(message); 15 | 16 | this.name = this.constructor.name; 17 | 18 | // capturing the stack trace keeps the reference to your error class 19 | Error.captureStackTrace(this, this.constructor); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/trust-chain/src/createChain.test.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { createChain } from "./index"; 3 | import { getKeyPairsA, KeyPairs } from "./testUtils"; 4 | import { isValidCreateChainEvent } from "./utils"; 5 | 6 | let keyPairsA: KeyPairs = null; 7 | 8 | beforeAll(async () => { 9 | await sodium.ready; 10 | keyPairsA = getKeyPairsA(); 11 | }); 12 | 13 | test("should create a new chain event", async () => { 14 | const event = createChain(keyPairsA.sign, { 15 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 16 | }); 17 | expect(event.prevHash).toBeNull(); 18 | expect(isValidCreateChainEvent(event)).toBe(true); 19 | }); 20 | -------------------------------------------------------------------------------- /demos/frontend/graphql/queries/organizations.ts: -------------------------------------------------------------------------------- 1 | export const organizationsQueryString = ` 2 | query { 3 | organizations { 4 | id 5 | events { 6 | content 7 | } 8 | eventProposals { 9 | id 10 | content 11 | } 12 | encryptedStates { 13 | keyId 14 | ciphertext 15 | nonce 16 | publicData 17 | author { 18 | publicKey 19 | signature 20 | } 21 | lockbox { 22 | keyId 23 | receiverSigningPublicKey 24 | senderLockboxPublicKey 25 | ciphertext 26 | nonce 27 | } 28 | } 29 | } 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /demos/backend/src/database/getUserFromSession.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from "apollo-server-express"; 2 | import { prisma } from "./prisma"; 3 | 4 | export async function getUserFromSession(session: any) { 5 | if (!session.userSigningPublicKey) { 6 | throw new AuthenticationError("Failed"); 7 | } 8 | try { 9 | const currentUser = prisma.user.findUnique({ 10 | where: { publicSigningKey: session.userSigningPublicKey }, 11 | }); 12 | if (!currentUser) { 13 | throw new AuthenticationError("Failed"); 14 | } 15 | return currentUser; 16 | } catch (err) { 17 | console.error(err); 18 | throw new AuthenticationError("Failed"); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /demos/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@serenity-tools/trust-chain": "0.0.1", 13 | "graphql": "^16.2.0", 14 | "next": "12.0.4", 15 | "react": "17.0.2", 16 | "react-dom": "17.0.2", 17 | "urql": "^2.0.6", 18 | "uuid": "^8.3.2" 19 | }, 20 | "devDependencies": { 21 | "@netlify/plugin-nextjs": "^4.1.1", 22 | "@types/react": "^17.0.37", 23 | "eslint": "7.32.0", 24 | "eslint-config-next": "12.0.4" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/trust-chain/src/resolveState.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TrustChainEvent, 3 | TrustChainState, 4 | CreateChainTrustChainEvent, 5 | } from "./types"; 6 | import { InvalidTrustChainError } from "./errors"; 7 | import { applyEvent } from "."; 8 | import { applyCreateChainEvent } from "./applyCreateChainEvent"; 9 | 10 | export const resolveState = (events: TrustChainEvent[]): TrustChainState => { 11 | if (events.length === 0) { 12 | throw new InvalidTrustChainError("No events"); 13 | } 14 | let state = applyCreateChainEvent(events[0] as CreateChainTrustChainEvent); 15 | events.slice(1).forEach((event) => { 16 | state = applyEvent(state, event); 17 | }); 18 | return state; 19 | }; 20 | -------------------------------------------------------------------------------- /demos/backend/src/database/requestAuthenticationChallenge.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | import { prisma } from "./prisma"; 3 | 4 | export async function requestAuthenticationChallenge(signingPublicKey: string) { 5 | try { 6 | const nonce = `server-auth-${uuidv4()}`; 7 | const validUntil = new Date(); 8 | validUntil.setSeconds(validUntil.getSeconds() + 60); 9 | return await prisma.authenticationChallenge.create({ 10 | data: { 11 | nonce, 12 | user: { connect: { publicSigningKey: signingPublicKey } }, 13 | validUntil, 14 | }, 15 | }); 16 | } catch (err) { 17 | console.error(err); 18 | throw new Error("Failed"); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/migrate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@serenity-tools/migrate", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "main": "dist/index", 6 | "types": "dist/index", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "build": "rimraf -rf ./dist && tsc -p tsconfig.build.json", 12 | "prepublishOnly": "yarn run build", 13 | "test": "jest" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.15.8", 17 | "@babel/preset-env": "^7.15.8", 18 | "@babel/preset-typescript": "^7.15.0", 19 | "@types/jest": "^27.0.2", 20 | "babel-plugin-module-resolver": "^4.1.0", 21 | "jest": "^27.3.1", 22 | "rimraf": "^3.0.2", 23 | "typescript": "^4.4.4", 24 | "websql": "^2.0.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/trust-chain/src/state/createLockboxes.ts: -------------------------------------------------------------------------------- 1 | import { Key, Lockbox, TrustChainState } from "../types"; 2 | import { createLockbox } from "./createLockbox"; 3 | 4 | export const createLockboxes = ( 5 | key: Key, 6 | lockboxPrivateKey: string, 7 | lockboxPublicKey: string, 8 | state: TrustChainState 9 | ) => { 10 | const lockboxes: { [signingPublicKey: string]: Lockbox } = {}; 11 | 12 | Object.keys(state.members).forEach((signingPublicKey) => { 13 | lockboxes[signingPublicKey] = createLockbox( 14 | key, 15 | lockboxPrivateKey, 16 | lockboxPublicKey, 17 | signingPublicKey, 18 | state.members[signingPublicKey].lockboxPublicKey 19 | ); 20 | }); 21 | 22 | return { 23 | keyId: key.keyId, 24 | lockboxes, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /packages/trust-chain/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./createChain"; 2 | export * from "./addMember"; 3 | export * from "./removeMember"; 4 | export * from "./updateMember"; 5 | export * from "./addAuthorToEvent"; 6 | export * from "./applyEvent"; 7 | export * from "./resolveState"; 8 | 9 | export * from "./state/createKey"; 10 | export * from "./state/createLockbox"; 11 | export * from "./state/createLockboxes"; 12 | export * from "./state/decryptLockbox"; 13 | export * from "./state/encryptState"; 14 | export * from "./state/resolveEncryptedState"; 15 | export * from "./state/verifyAndApplyEncryptedState"; 16 | 17 | export * from "./errors"; 18 | export * from "./types"; 19 | export { isValidAdminDecision, getAdminCount, canonicalize } from "./utils"; 20 | export { verifySignature, sign } from "./state/crypto"; 21 | -------------------------------------------------------------------------------- /packages/trust-chain/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@serenity-tools/trust-chain", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "main": "src/index", 6 | "types": "dist/index", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "build": "rimraf -rf ./dist && tsc -p tsconfig.build.json", 12 | "prepublishOnly": "yarn run build", 13 | "test": "jest" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.15.8", 17 | "@babel/preset-env": "^7.15.8", 18 | "@babel/preset-typescript": "^7.15.0", 19 | "@types/jest": "^27.0.2", 20 | "@types/libsodium-wrappers": "^0.7.9", 21 | "babel-plugin-module-resolver": "^4.1.0", 22 | "jest": "^27.3.1", 23 | "rimraf": "^3.0.2", 24 | "typescript": "^4.4.4" 25 | }, 26 | "dependencies": { 27 | "libsodium-wrappers": "^0.7.9", 28 | "uuid": "^8.3.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/trust-chain/src/state/createLockbox.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { Key, Lockbox } from "../types"; 3 | 4 | export const createLockbox = ( 5 | key: Key, 6 | lockboxPrivateKey: string, 7 | senderLockboxPublicKey: string, 8 | receiverSigningPublicKey: string, 9 | receiverLockboxPublicKey: string 10 | ): Lockbox => { 11 | const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES); 12 | const ciphertext = sodium.crypto_box_easy( 13 | JSON.stringify(key), 14 | nonce, 15 | sodium.from_base64(receiverLockboxPublicKey), 16 | sodium.from_base64(lockboxPrivateKey) 17 | ); 18 | return { 19 | receiverSigningPublicKey: receiverSigningPublicKey, 20 | senderLockboxPublicKey: senderLockboxPublicKey, 21 | ciphertext: sodium.to_base64(ciphertext), 22 | nonce: sodium.to_base64(nonce), 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /packages/trust-chain/src/removeMember.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { RemoveMemberTransaction, DefaultTrustChainEvent } from "./types"; 3 | import { hashTransaction } from "./utils"; 4 | 5 | export const removeMember = ( 6 | prevHash: string, 7 | authorKeyPair: sodium.KeyPair, 8 | memberSigningPublicKey: string 9 | ): DefaultTrustChainEvent => { 10 | const transaction: RemoveMemberTransaction = { 11 | type: "remove-member", 12 | memberSigningPublicKey, 13 | }; 14 | 15 | const hash = hashTransaction(transaction); 16 | return { 17 | transaction, 18 | authors: [ 19 | { 20 | publicKey: sodium.to_base64(authorKeyPair.publicKey), 21 | signature: sodium.to_base64( 22 | sodium.crypto_sign_detached( 23 | `${prevHash}${hash}`, 24 | authorKeyPair.privateKey 25 | ) 26 | ), 27 | }, 28 | ], 29 | prevHash, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /packages/trust-chain/src/addAuthorToEvent.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { TrustChainEvent } from "./types"; 3 | import { hashTransaction } from "./utils"; 4 | 5 | export const addAuthorToEvent = ( 6 | event: TrustChainEvent, 7 | authorKeyPair: sodium.KeyPair 8 | ): TrustChainEvent => { 9 | const hash = hashTransaction(event.transaction); 10 | return { 11 | ...event, 12 | authors: [ 13 | ...event.authors, 14 | { 15 | publicKey: sodium.to_base64(authorKeyPair.publicKey), 16 | signature: 17 | event.prevHash === null 18 | ? sodium.to_base64( 19 | sodium.crypto_sign_detached(hash, authorKeyPair.privateKey) 20 | ) 21 | : sodium.to_base64( 22 | sodium.crypto_sign_detached( 23 | `${event.prevHash}${hash}`, 24 | authorKeyPair.privateKey 25 | ) 26 | ), 27 | }, 28 | ], 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /packages/trust-chain/src/createChain.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | import sodium from "libsodium-wrappers"; 3 | import { 4 | CreateChainTransaction, 5 | CreateChainTrustChainEvent, 6 | KeyPairBase64, 7 | } from "./types"; 8 | import { hashTransaction } from "./utils"; 9 | 10 | export const createChain = ( 11 | authorKeyPair: KeyPairBase64, 12 | lockboxPublicKeys: { [signingPublicKey: string]: string } 13 | ): CreateChainTrustChainEvent => { 14 | const transaction: CreateChainTransaction = { 15 | type: "create", 16 | id: uuidv4(), 17 | lockboxPublicKeys, 18 | }; 19 | const hash = hashTransaction(transaction); 20 | return { 21 | transaction, 22 | authors: [ 23 | { 24 | publicKey: authorKeyPair.publicKey, 25 | signature: sodium.to_base64( 26 | sodium.crypto_sign_detached( 27 | hash, 28 | sodium.from_base64(authorKeyPair.privateKey) 29 | ) 30 | ), 31 | }, 32 | ], 33 | prevHash: null, 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /demos/backend/src/database/deleteOrganizationEventProposal.ts: -------------------------------------------------------------------------------- 1 | import { getUserFromSession } from "./getUserFromSession"; 2 | import { prisma } from "./prisma"; 3 | 4 | export async function deleteOrganizationEventProposal( 5 | session: any, 6 | eventProposalId: string 7 | ) { 8 | const currentUser = await getUserFromSession(session); 9 | 10 | try { 11 | const eventProposal = await prisma.eventProposal.findUnique({ 12 | where: { id: eventProposalId }, 13 | include: { 14 | organization: { 15 | select: { 16 | members: { 17 | where: { publicSigningKey: currentUser.publicSigningKey }, 18 | }, 19 | }, 20 | }, 21 | }, 22 | }); 23 | if (eventProposal.organization.members.length === 0) { 24 | throw new Error("Failed"); 25 | } 26 | 27 | await prisma.eventProposal.delete({ 28 | where: { id: eventProposalId }, 29 | }); 30 | } catch (err) { 31 | console.error(err); 32 | throw new Error("Failed"); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/trust-chain/src/updateMember.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { 3 | DefaultTrustChainEvent, 4 | MemberAuthorization, 5 | UpdateMemberTransaction, 6 | } from "./types"; 7 | import { hashTransaction } from "./utils"; 8 | 9 | export const updateMember = ( 10 | prevHash: string, 11 | authorKeyPair: sodium.KeyPair, 12 | memberSigningPublicKey: string, 13 | memberAuthorization: MemberAuthorization 14 | ): DefaultTrustChainEvent => { 15 | const transaction: UpdateMemberTransaction = { 16 | type: "update-member", 17 | memberSigningPublicKey, 18 | ...memberAuthorization, 19 | }; 20 | 21 | const hash = hashTransaction(transaction); 22 | return { 23 | authors: [ 24 | { 25 | publicKey: sodium.to_base64(authorKeyPair.publicKey), 26 | signature: sodium.to_base64( 27 | sodium.crypto_sign_detached( 28 | `${prevHash}${hash}`, 29 | authorKeyPair.privateKey 30 | ) 31 | ), 32 | }, 33 | ], 34 | transaction, 35 | prevHash, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /demos/frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /packages/trust-chain/src/addMember.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { 3 | AddMemberTransaction, 4 | DefaultTrustChainEvent, 5 | MemberAuthorization, 6 | } from "./types"; 7 | import { hashTransaction } from "./utils"; 8 | 9 | export const addMember = ( 10 | prevHash: string, 11 | authorKeyPair: sodium.KeyPair, 12 | memberSigningPublicKey: string, 13 | memberLockboxPublicKey: string, 14 | memberAuthorization: MemberAuthorization 15 | ): DefaultTrustChainEvent => { 16 | const transaction: AddMemberTransaction = { 17 | ...memberAuthorization, 18 | type: "add-member", 19 | memberSigningPublicKey, 20 | memberLockboxPublicKey, 21 | }; 22 | 23 | const hash = hashTransaction(transaction); 24 | return { 25 | authors: [ 26 | { 27 | publicKey: sodium.to_base64(authorKeyPair.publicKey), 28 | signature: sodium.to_base64( 29 | sodium.crypto_sign_detached( 30 | `${prevHash}${hash}`, 31 | authorKeyPair.privateKey 32 | ) 33 | ), 34 | }, 35 | ], 36 | transaction, 37 | prevHash, 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /packages/trust-chain/src/applyCreateChainEvent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateChainTrustChainEvent, 3 | MemberProperties, 4 | TrustChainState, 5 | } from "./types"; 6 | import { hashTransaction, isValidCreateChainEvent } from "./utils"; 7 | import { InvalidTrustChainError } from "./errors"; 8 | 9 | export const applyCreateChainEvent = ( 10 | event: CreateChainTrustChainEvent 11 | ): TrustChainState => { 12 | if (!isValidCreateChainEvent(event)) { 13 | throw new InvalidTrustChainError("Invalid chain creation event."); 14 | } 15 | 16 | let members: { [publicKey: string]: MemberProperties } = {}; 17 | event.authors.forEach((author) => { 18 | members[author.publicKey] = { 19 | lockboxPublicKey: event.transaction.lockboxPublicKeys[author.publicKey], 20 | isAdmin: true, 21 | canAddMembers: true, 22 | canRemoveMembers: true, 23 | addedBy: event.authors.map((author) => author.publicKey), 24 | }; 25 | }); 26 | 27 | return { 28 | id: event.transaction.id, 29 | members, 30 | lastEventHash: hashTransaction(event.transaction), 31 | encryptedStateClock: 0, 32 | trustChainVersion: 1, 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /demos/backend/src/database/addEventProposalToOrganization.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "./prisma"; 2 | import { DefaultTrustChainEvent } from "@serenity-tools/trust-chain"; 3 | import { getUserFromSession } from "./getUserFromSession"; 4 | 5 | export async function addEventProposalToOrganization( 6 | session: any, 7 | organizationId: string, 8 | event: DefaultTrustChainEvent 9 | ) { 10 | const currentUser = await getUserFromSession(session); 11 | 12 | try { 13 | return await prisma.$transaction(async (prisma) => { 14 | const org = await prisma.organization.findUnique({ 15 | where: { id: organizationId }, 16 | select: { 17 | id: true, 18 | members: { 19 | where: { publicSigningKey: currentUser.publicSigningKey }, 20 | }, 21 | }, 22 | }); 23 | 24 | if (org.members.length === 0) { 25 | throw new Error("Failed"); 26 | } 27 | 28 | return await prisma.organization.update({ 29 | where: { id: org.id }, 30 | data: { 31 | eventProposal: { create: { content: event } }, 32 | }, 33 | }); 34 | }); 35 | } catch (err) { 36 | console.error(err); 37 | throw new Error("Failed"); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/trust-chain/src/state/applyStateUpdates.ts: -------------------------------------------------------------------------------- 1 | import { RawEncryptedStateUpdate, TrustChainState } from "../types"; 2 | 3 | export const applyStateUpdates = ( 4 | currentState: TrustChainState, 5 | stateUpdates: RawEncryptedStateUpdate, 6 | authorPublicKey: string, 7 | clock: number 8 | ): TrustChainState => { 9 | const newState = { ...currentState }; 10 | if (stateUpdates.hasOwnProperty("members")) { 11 | Object.keys(stateUpdates.members).forEach((key) => { 12 | if ( 13 | newState.members[authorPublicKey]?.isAdmin || 14 | (newState.members[key]?.addedBy.includes(authorPublicKey) && 15 | // the member to be updated is not an admin 16 | !newState.members[key].isAdmin && 17 | // prevent overwritting when already updated by an admin 18 | !( 19 | newState.members[key]?.profileUpdatedBy && 20 | newState.members[newState.members[key].profileUpdatedBy].isAdmin 21 | )) 22 | ) { 23 | newState.members[key] = { 24 | ...newState.members[key], 25 | name: stateUpdates.members[key].name, 26 | profileUpdatedBy: authorPublicKey, 27 | }; 28 | } 29 | }); 30 | } 31 | 32 | return { 33 | ...newState, 34 | encryptedStateClock: clock, 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /packages/trust-chain/src/state/crypto.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | 3 | export function encryptAead(message, additionalData: string, key: Uint8Array) { 4 | const secretNonce = sodium.randombytes_buf( 5 | sodium.crypto_aead_xchacha20poly1305_ietf_NSECBYTES 6 | ); 7 | const publicNonce = sodium.randombytes_buf( 8 | sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES 9 | ); 10 | return { 11 | publicNonce, 12 | ciphertext: sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( 13 | message, 14 | additionalData, 15 | secretNonce, 16 | publicNonce, 17 | key 18 | ), 19 | }; 20 | } 21 | 22 | export function decryptAead( 23 | ciphertext, 24 | additionalData: string, 25 | key: Uint8Array, 26 | publicNonce: Uint8Array 27 | ) { 28 | if (ciphertext.length < sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES) { 29 | throw "The ciphertext was too short"; 30 | } 31 | 32 | return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( 33 | new Uint8Array(0), 34 | ciphertext, 35 | additionalData, 36 | publicNonce, 37 | key 38 | ); 39 | } 40 | 41 | export function sign(message, privateKey) { 42 | return sodium.crypto_sign_detached(message, privateKey); 43 | } 44 | 45 | export function verifySignature(message, signature, publicKey) { 46 | return sodium.crypto_sign_verify_detached(signature, message, publicKey); 47 | } 48 | -------------------------------------------------------------------------------- /packages/migrate/README.md: -------------------------------------------------------------------------------- 1 | # Migrate 2 | 3 | This tools can be used to run migrations on Databases using WebSQL API. The migrate function accepts a list migrations that should run. 4 | 5 | The name is important to define the order and therefor the names must comply to simple JavaScript based string comparison `>` and `<`. The example has string resembling a datetime string and a description e.g. `"202110211000_init"`. 6 | 7 | ```sh 8 | yarn add @serenity-tools/migrate 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import Db from "react-native-sqlcipher"; // also works with node-websql 15 | import { migrate } from "@serenity-tools/migrate"; 16 | 17 | const db = await Db.openDatabase({ name: "data" }); 18 | 19 | // during App initialization 20 | try { 21 | await migrate({ 22 | db, 23 | migrations: [ 24 | { 25 | name: "202110211000_init", 26 | statements: [ 27 | `CREATE TABLE "User" ( 28 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 29 | "name" TEXT 30 | );`, 31 | ], 32 | }, 33 | { 34 | name: "202201010000_add_notes", 35 | statements: [ 36 | `CREATE TABLE "Note" ( 37 | "id" TEXT NOT NULL PRIMARY KEY, 38 | "content" TEXT 39 | );`, 40 | ], 41 | }, 42 | ], 43 | }); 44 | } catch (err) { 45 | // Inform user about the error 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /demos/backend/src/database/authenticate.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | import sodium from "libsodium-wrappers"; 3 | import { prisma } from "./prisma"; 4 | import { verifySignature } from "@serenity-tools/trust-chain"; 5 | import { AuthenticationError } from "apollo-server-express"; 6 | 7 | export async function authenticate( 8 | signingPublicKey: string, 9 | nonce: string, 10 | nonceSignature: string, 11 | session: any 12 | ) { 13 | try { 14 | const validSignature = verifySignature( 15 | nonce, 16 | sodium.from_base64(nonceSignature), 17 | sodium.from_base64(signingPublicKey) 18 | ); 19 | if (!validSignature) { 20 | throw new Error("Failed"); 21 | } 22 | 23 | const authenticationChallenge = 24 | await prisma.authenticationChallenge.findUnique({ where: { nonce } }); 25 | 26 | if (!authenticationChallenge) { 27 | throw new Error("Failed"); 28 | } 29 | 30 | if ( 31 | authenticationChallenge.userPublicSigningKey !== signingPublicKey || 32 | authenticationChallenge.validUntil < new Date() 33 | ) { 34 | throw new Error("Failed"); 35 | } 36 | 37 | await prisma.authenticationChallenge.delete({ where: { nonce } }); 38 | 39 | session.userSigningPublicKey = signingPublicKey; 40 | return { success: true }; 41 | } catch (err) { 42 | console.error(err); 43 | throw new Error("Failed"); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /demos/backend/src/database/updateOrganizationEventProposal.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "./prisma"; 2 | import { DefaultTrustChainEvent } from "@serenity-tools/trust-chain"; 3 | import { getUserFromSession } from "./getUserFromSession"; 4 | 5 | export async function updateOrganizationEventProposal( 6 | session: any, 7 | eventProposalId: string, 8 | event: DefaultTrustChainEvent 9 | ) { 10 | try { 11 | const currentUser = await getUserFromSession(session); 12 | if ( 13 | !event.authors.some( 14 | (author) => author.publicKey === currentUser.publicSigningKey 15 | ) 16 | ) { 17 | throw new Error("Failed"); 18 | } 19 | 20 | const eventProposal = await prisma.eventProposal.findUnique({ 21 | where: { id: eventProposalId }, 22 | include: { 23 | organization: { 24 | select: { 25 | members: { 26 | where: { publicSigningKey: currentUser.publicSigningKey }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | }); 32 | if (eventProposal.organization.members.length === 0) { 33 | throw new Error("Failed"); 34 | } 35 | 36 | // TODO event validation 37 | return await prisma.eventProposal.update({ 38 | where: { id: eventProposalId }, 39 | data: { content: event }, 40 | }); 41 | } catch (err) { 42 | console.error(err); 43 | throw new Error("Failed"); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /demos/frontend/hooks/useLocalStorage.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | // from https://usehooks.com/useLocalStorage/ 4 | export default function useLocalStorage( 5 | key: string, 6 | initialValue: T | null 7 | ): [T, Function] | null { 8 | // State to store our value 9 | // Pass initial state function to useState so logic is only executed once 10 | const [storedValue, setStoredValue] = useState(() => { 11 | try { 12 | // Get from local storage by key 13 | const item = window.localStorage.getItem(key); 14 | // Parse stored json or if none return initialValue 15 | return item ? JSON.parse(item) : initialValue; 16 | } catch (error) { 17 | // If error also return initialValue 18 | console.log(error); 19 | return initialValue; 20 | } 21 | }); 22 | // Return a wrapped version of useState's setter function that ... 23 | // ... persists the new value to localStorage. 24 | const setValue = (value) => { 25 | try { 26 | // Allow value to be a function so we have same API as useState 27 | const valueToStore = 28 | value instanceof Function ? value(storedValue) : value; 29 | // Save state 30 | setStoredValue(valueToStore); 31 | // Save to local storage 32 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 33 | } catch (error) { 34 | // A more advanced implementation would handle the error case 35 | console.log(error); 36 | } 37 | }; 38 | return [storedValue, setValue]; 39 | } 40 | -------------------------------------------------------------------------------- /demos/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "NODE_ENV=development ts-node-dev --transpile-only --no-notify ./src/index.ts", 7 | "clean": "rm -rf build", 8 | "build": "yarn && yarn clean && yarn prisma:prod:generate && yarn ncc build ./src/index.ts -o build", 9 | "deploy": "yarn build && DOCKER_DEFAULT_PLATFORM=linux/amd64 heroku container:push web --app trust-chain && heroku container:release web --app trust-chain", 10 | "prisma:prod:migrate": "POSTGRES_URL=$POSTGRES_URL prisma migrate deploy", 11 | "prisma:prod:generate": "POSTGRES_URL=$POSTGRES_URL prisma generate", 12 | "prisma:prod:studio": "POSTGRES_URL=$POSTGRES_URL prisma studio", 13 | "start:prod": "PORT=$PORT POSTGRES_URL=$POSTGRES_URL NODE_ENV=production node ./build" 14 | }, 15 | "dependencies": { 16 | "@prisma/client": "3.8.1", 17 | "@serenity-tools/trust-chain": "0.0.1", 18 | "apollo-server-express": "^3.6.2", 19 | "express": "^4.17.1", 20 | "express-session": "^1.17.2", 21 | "graphql": "^15.8.0", 22 | "make-promises-safe": "^5.1.0", 23 | "nexus": "^1.1.0", 24 | "uuid": "^8.3.2", 25 | "ws": "^8.3.0" 26 | }, 27 | "devDependencies": { 28 | "@types/node": "16.11.9", 29 | "@types/uuid": "^8.3.3", 30 | "@types/ws": "^8.2.1", 31 | "@vercel/ncc": "^0.33.1", 32 | "prettier": "^2.5.0", 33 | "prisma": "3.8.1", 34 | "ts-node": "10.4.0", 35 | "ts-node-dev": "^1.1.8", 36 | "typescript": "4.5.4" 37 | }, 38 | "engines": { 39 | "node": ">=12.2.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/trust-chain/src/state/encryptState.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { Key, RawEncryptedStateUpdate, TrustChainState } from "../types"; 3 | import { canonicalize, hashTransaction } from "../utils"; 4 | import { applyStateUpdates } from "./applyStateUpdates"; 5 | import { encryptAead, sign } from "./crypto"; 6 | 7 | export const encryptState = ( 8 | currentState: TrustChainState, 9 | stateUpdates: RawEncryptedStateUpdate, 10 | key: Key, 11 | author: sodium.KeyPair 12 | ) => { 13 | const clock = currentState.encryptedStateClock + 1; 14 | const newState = applyStateUpdates( 15 | currentState, 16 | stateUpdates, 17 | sodium.to_base64(author.publicKey), 18 | clock 19 | ); 20 | const hash = hashTransaction(newState); 21 | const encryptedStateUpdateWithHash = { ...stateUpdates, hash }; 22 | 23 | const publicData = { clock }; 24 | const publicDataAsBase64 = sodium.to_base64(canonicalize(publicData)); 25 | const { ciphertext, publicNonce } = encryptAead( 26 | JSON.stringify(encryptedStateUpdateWithHash), 27 | publicDataAsBase64, 28 | sodium.from_base64(key.key) 29 | ); 30 | const ciphertextAsBase64 = sodium.to_base64(ciphertext); 31 | const nonceAsBase64 = sodium.to_base64(publicNonce); 32 | return { 33 | ciphertext: ciphertextAsBase64, 34 | nonce: nonceAsBase64, 35 | keyId: key.keyId, 36 | publicData, 37 | author: { 38 | publicKey: sodium.to_base64(author.publicKey), 39 | signature: sodium.to_base64( 40 | sign( 41 | `${nonceAsBase64}${ciphertextAsBase64}${publicDataAsBase64}`, 42 | author.privateKey 43 | ) 44 | ), 45 | }, 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /packages/trust-chain/src/resolveState.test.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { InvalidTrustChainError } from "./errors"; 3 | import { createChain, resolveState, addMember } from "./index"; 4 | import { 5 | getKeyPairA, 6 | getKeyPairB, 7 | getKeyPairsA, 8 | getKeyPairsB, 9 | getKeyPairsC, 10 | KeyPairs, 11 | } from "./testUtils"; 12 | import { hashTransaction } from "./utils"; 13 | 14 | let keyPairA: sodium.KeyPair = null; 15 | let keyPairsA: KeyPairs = null; 16 | let keyPairB: sodium.KeyPair = null; 17 | let keyPairsB: KeyPairs = null; 18 | let keyPairsC: KeyPairs = null; 19 | 20 | beforeAll(async () => { 21 | await sodium.ready; 22 | keyPairA = getKeyPairA(); 23 | keyPairsA = getKeyPairsA(); 24 | keyPairB = getKeyPairB(); 25 | keyPairsB = getKeyPairsB(); 26 | keyPairsC = getKeyPairsC(); 27 | }); 28 | 29 | test("should fail in case the chain is not correctly ordered", async () => { 30 | const createEvent = createChain(keyPairsA.sign, { 31 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 32 | }); 33 | const addMemberEvent = addMember( 34 | hashTransaction(createEvent.transaction), 35 | keyPairA, 36 | keyPairsB.sign.publicKey, 37 | keyPairsB.box.publicKey, 38 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 39 | ); 40 | const addMemberEvent2 = addMember( 41 | hashTransaction(addMemberEvent.transaction), 42 | keyPairB, 43 | keyPairsC.sign.publicKey, 44 | keyPairsC.box.publicKey, 45 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 46 | ); 47 | const chain = [createEvent, addMemberEvent2, addMemberEvent]; 48 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 49 | expect(() => resolveState(chain)).toThrow( 50 | "Invalid signature for MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY." 51 | ); 52 | }); 53 | -------------------------------------------------------------------------------- /packages/trust-chain/src/state/verifyAndApplyEncryptedState.test.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { getKeyPairA, getKeyPairsA, KeyPairs } from "../testUtils"; 3 | import { verifyAndApplyEncryptedState } from "./verifyAndApplyEncryptedState"; 4 | import { createKey } from "./createKey"; 5 | import { encryptState } from "./encryptState"; 6 | import { createChain, resolveState } from ".."; 7 | 8 | let keyPairA: sodium.KeyPair = null; 9 | let keyPairsA: KeyPairs = null; 10 | let keyPairAPublicKey: string = null; 11 | 12 | beforeAll(async () => { 13 | await sodium.ready; 14 | keyPairA = getKeyPairA(); 15 | keyPairsA = getKeyPairsA(); 16 | keyPairAPublicKey = sodium.to_base64(keyPairA.publicKey); 17 | }); 18 | 19 | test("should add name to member", async () => { 20 | const key = createKey(); 21 | const event = createChain(keyPairsA.sign, { 22 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 23 | }); 24 | const state = resolveState([event]); 25 | const encryptedState = encryptState( 26 | state, 27 | { members: { [keyPairAPublicKey]: { name: "Jane Doe" } } }, 28 | key, 29 | keyPairA 30 | ); 31 | const { state: newState } = verifyAndApplyEncryptedState( 32 | state, 33 | encryptedState, 34 | key.key 35 | ); 36 | expect(newState.members).toMatchInlineSnapshot(` 37 | Object { 38 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 39 | "addedBy": Array [ 40 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 41 | ], 42 | "canAddMembers": true, 43 | "canRemoveMembers": true, 44 | "isAdmin": true, 45 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 46 | "name": "Jane Doe", 47 | "profileUpdatedBy": "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 48 | }, 49 | } 50 | `); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/trust-chain/src/state/verifyAndApplyEncryptedState.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { 3 | EncryptedState, 4 | EncryptedStateUpdate, 5 | TrustChainState, 6 | } from "../types"; 7 | import { canonicalize } from "../utils"; 8 | import { applyStateUpdates } from "./applyStateUpdates"; 9 | import { decryptAead, verifySignature } from "./crypto"; 10 | 11 | export const verifyAndApplyEncryptedState = ( 12 | currentState: TrustChainState, 13 | { nonce, ciphertext, publicData, author }: EncryptedState, 14 | key: string 15 | ) => { 16 | // A single user should not be possible to break the entry state. 17 | try { 18 | if ( 19 | !verifySignature( 20 | `${nonce}${ciphertext}${sodium.to_base64(canonicalize(publicData))}`, 21 | sodium.from_base64(author.signature), 22 | sodium.from_base64(author.publicKey) 23 | ) 24 | ) { 25 | throw new Error("Invalid Signature"); // TODO convert to custom error 26 | } 27 | 28 | const decryptedContent = decryptAead( 29 | sodium.from_base64(ciphertext), 30 | sodium.to_base64(canonicalize(publicData)), 31 | sodium.from_base64(key), 32 | sodium.from_base64(nonce) 33 | ); 34 | const stateUpdates: EncryptedStateUpdate = JSON.parse( 35 | sodium.to_string(decryptedContent) 36 | ); 37 | const newState = applyStateUpdates( 38 | currentState, 39 | stateUpdates, 40 | author.publicKey, 41 | publicData.clock 42 | ); 43 | return { 44 | state: newState, 45 | stateUpdates, 46 | hash: stateUpdates.hash, 47 | failed: false, 48 | }; 49 | } catch (err) { 50 | console.error("verifyAndApplyEncryptedState Error", err); 51 | return { 52 | state: currentState, 53 | hash: null, 54 | failed: false, 55 | stateUpdates: { members: {} }, 56 | }; 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /demos/backend/src/database/updateOrganizationMember.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "./prisma"; 2 | import { 3 | DefaultTrustChainEvent, 4 | applyEvent, 5 | TrustChainState, 6 | } from "@serenity-tools/trust-chain"; 7 | import { getUserFromSession } from "./getUserFromSession"; 8 | 9 | export async function updateOrganizationMember( 10 | session: any, 11 | organizationId: string, 12 | event: DefaultTrustChainEvent, 13 | eventProposalId?: string 14 | ) { 15 | const currentUser = await getUserFromSession(session); 16 | if ( 17 | !event.authors.some( 18 | (author) => author.publicKey === currentUser.publicSigningKey 19 | ) 20 | ) { 21 | throw new Error("Failed"); 22 | } 23 | 24 | try { 25 | return await prisma.$transaction(async (prisma) => { 26 | // verify the user has access to this organization 27 | const org = await prisma.organization.findUnique({ 28 | where: { id: organizationId }, 29 | }); 30 | 31 | if (event.transaction.type !== "update-member") { 32 | throw new Error("Not an update-member event"); 33 | } 34 | 35 | const newState = applyEvent( 36 | org.serializedState as TrustChainState, 37 | event 38 | ); 39 | 40 | if (eventProposalId) { 41 | // TODO verify that the eventPropsal and event transaction is identical and the user has access to it's organization 42 | await prisma.eventProposal.delete({ 43 | where: { id: eventProposalId }, 44 | }); 45 | } 46 | 47 | return await prisma.organization.update({ 48 | where: { id: org.id }, 49 | data: { 50 | serializedState: newState, 51 | lastEventHash: newState.lastEventHash, 52 | events: { create: { content: event } }, 53 | }, 54 | }); 55 | }); 56 | } catch (err) { 57 | console.error(err); 58 | throw new Error("Failed"); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /demos/frontend/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import React, { useEffect, useState } from "react"; 3 | import sodium from "libsodium-wrappers"; 4 | import { 5 | createClient, 6 | Provider, 7 | defaultExchanges, 8 | errorExchange, 9 | Client, 10 | } from "urql"; 11 | import App from "../app/App"; 12 | import { meQueryString } from "../graphql/queries/me"; 13 | 14 | let client: Client = null; 15 | 16 | export default function Home() { 17 | const [ready, setReady] = useState(false); 18 | const [isAuthenticated, setIsAuthenticated] = useState(false); 19 | 20 | useEffect(() => { 21 | async function loadApp() { 22 | client = createClient({ 23 | url: 24 | process.env.NODE_ENV === "production" 25 | ? "https://api.serenity.li/graphql" 26 | : "http://localhost:4000/graphql", 27 | fetchOptions: { credentials: "include" }, // necessary for the cookie to be included 28 | exchanges: [ 29 | errorExchange({ 30 | onError: (error) => { 31 | const isAuthError = error.graphQLErrors.some((e) => { 32 | return e.extensions?.code === "UNAUTHENTICATED"; 33 | }); 34 | 35 | if (isAuthError) { 36 | setIsAuthenticated(false); 37 | } 38 | }, 39 | }), 40 | ...defaultExchanges, 41 | ], 42 | }); 43 | 44 | const result = await client.query(meQueryString).toPromise(); 45 | if (result?.data?.me) { 46 | setIsAuthenticated(true); 47 | } 48 | 49 | await sodium.ready; 50 | setReady(true); 51 | } 52 | loadApp(); 53 | }, []); 54 | 55 | return ( 56 | <> 57 | 58 | Trust Chain 59 | 60 | 61 | 62 | 63 | {ready ? ( 64 | 65 | 69 | 70 | ) : ( 71 |
Loading …
72 | )} 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /demos/backend/src/graphql/Query.ts: -------------------------------------------------------------------------------- 1 | import { queryType, objectType, arg } from "nexus"; 2 | import { getOrganizations } from "../database/getOrganizations"; 3 | import { getUserFromSession } from "../database/getUserFromSession"; 4 | 5 | const Event = objectType({ 6 | name: "Event", 7 | definition(t) { 8 | t.string("content"); 9 | }, 10 | }); 11 | 12 | const EventProposal = objectType({ 13 | name: "EventProposal", 14 | definition(t) { 15 | t.string("id"); 16 | t.string("content"); 17 | }, 18 | }); 19 | 20 | const Lockbox = objectType({ 21 | name: "Lockbox", 22 | definition(t) { 23 | t.string("keyId"); 24 | t.string("receiverSigningPublicKey"); 25 | t.string("senderLockboxPublicKey"); 26 | t.string("ciphertext"); 27 | t.string("nonce"); 28 | }, 29 | }); 30 | 31 | const Author = objectType({ 32 | name: "Author", 33 | definition(t) { 34 | t.string("publicKey"); 35 | t.string("signature"); 36 | }, 37 | }); 38 | 39 | const EncryptedState = objectType({ 40 | name: "EncryptedState", 41 | definition(t) { 42 | t.string("keyId"); 43 | t.string("ciphertext"); 44 | t.string("nonce"); 45 | t.string("publicData"); 46 | t.field("lockbox", { type: Lockbox }); 47 | t.field("author", { type: Author }); 48 | }, 49 | }); 50 | 51 | const Organization = objectType({ 52 | name: "Organization", 53 | definition(t) { 54 | t.id("id"); 55 | t.list.field("events", { type: Event }); 56 | t.list.field("eventProposals", { type: EventProposal }); 57 | t.list.field("encryptedStates", { type: EncryptedState }); 58 | }, 59 | }); 60 | 61 | const User = objectType({ 62 | name: "User", 63 | definition(t) { 64 | t.string("signingPublicKey"); 65 | }, 66 | }); 67 | 68 | export const Query = queryType({ 69 | definition(t) { 70 | t.list.field("organizations", { 71 | type: Organization, 72 | async resolve(root, args, ctx) { 73 | return await getOrganizations(ctx.session); 74 | }, 75 | }); 76 | 77 | t.field("me", { 78 | type: User, 79 | async resolve(root, args, ctx) { 80 | const user = await getUserFromSession(ctx.session); 81 | if (!user) return null; 82 | return { 83 | signingPublicKey: user.publicSigningKey, 84 | }; 85 | }, 86 | }); 87 | }, 88 | }); 89 | -------------------------------------------------------------------------------- /packages/trust-chain/src/state/resolveEncryptedState.ts: -------------------------------------------------------------------------------- 1 | import { InvalidEncryptedStateError, Key, RawEncryptedStateUpdate } from ".."; 2 | import { EncryptedState, TrustChainState } from "../types"; 3 | import { hashTransaction } from "../utils"; 4 | import { verifyAndApplyEncryptedState } from "./verifyAndApplyEncryptedState"; 5 | 6 | function compareByClock(a: EncryptedState, b: EncryptedState) { 7 | if (a.publicData.clock < b.publicData.clock) return -1; 8 | if (a.publicData.clock > b.publicData.clock) return 1; 9 | return 0; 10 | } 11 | 12 | function verifyClockInSortedArray(sortedEncryptedState: EncryptedState[]) { 13 | let currentClock = 0; 14 | sortedEncryptedState.forEach((encryptedState) => { 15 | if (!Number.isInteger(encryptedState.publicData.clock)) { 16 | throw new InvalidEncryptedStateError("Missing clock in the public data."); 17 | } 18 | if (encryptedState.publicData.clock === currentClock) { 19 | throw new InvalidEncryptedStateError( 20 | "Identical clock values dedected for encrypted states." 21 | ); 22 | } 23 | currentClock = encryptedState.publicData.clock; 24 | }); 25 | } 26 | 27 | export const resolveEncryptedState = ( 28 | currentState: TrustChainState, 29 | encryptedState: EncryptedState[], 30 | keys: { [keyId: string]: string }, // TODO make sure they are signed, 31 | currentUserSigningPublicKey: string 32 | ) => { 33 | let failedToApplyAllUpdates = false; 34 | const sortedEncryptedState = encryptedState.sort(compareByClock); 35 | verifyClockInSortedArray(sortedEncryptedState); 36 | 37 | let state: TrustChainState = { ...currentState }; 38 | let lastHash = null; 39 | let lastKey: Key = null; 40 | let currentUserEncryptedState: RawEncryptedStateUpdate = null; 41 | sortedEncryptedState.forEach((encryptedStateUpdate) => { 42 | const result = verifyAndApplyEncryptedState( 43 | state, 44 | encryptedStateUpdate, 45 | keys[encryptedStateUpdate.keyId] 46 | ); 47 | if (encryptedStateUpdate.author.publicKey === currentUserSigningPublicKey) { 48 | currentUserEncryptedState = result.stateUpdates; 49 | } 50 | lastHash = result.hash; 51 | state = result.state; 52 | lastKey = { 53 | keyId: encryptedStateUpdate.keyId, 54 | key: keys[encryptedStateUpdate.keyId], 55 | }; 56 | if (result.failed) { 57 | failedToApplyAllUpdates = true; 58 | } 59 | }); 60 | 61 | const hash = hashTransaction(state); 62 | return { 63 | state, 64 | failedToApplyAllUpdates, 65 | isIdenticalContent: hash === lastHash, 66 | lastKey, 67 | currentUserEncryptedState, 68 | }; 69 | }; 70 | -------------------------------------------------------------------------------- /demos/backend/src/database/getOrganizations.ts: -------------------------------------------------------------------------------- 1 | import { canonicalize } from "@serenity-tools/trust-chain"; 2 | import { getUserFromSession } from "./getUserFromSession"; 3 | import { prisma } from "./prisma"; 4 | 5 | export async function getOrganizations(session: any) { 6 | const currentUser = await getUserFromSession(session); 7 | 8 | try { 9 | const organizations = await prisma.organization.findMany({ 10 | where: { 11 | members: { 12 | some: { publicSigningKey: { equals: currentUser.publicSigningKey } }, 13 | }, 14 | }, 15 | include: { 16 | events: { select: { content: true }, orderBy: { id: "asc" } }, 17 | eventProposal: { 18 | select: { content: true, id: true }, 19 | orderBy: { id: "asc" }, 20 | }, 21 | encryptedStates: { 22 | include: { 23 | key: { 24 | include: { 25 | lockbox: { 26 | where: { 27 | receiverSigningPublicKey: currentUser.publicSigningKey, 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | }); 36 | return organizations.map((organization) => { 37 | return { 38 | ...organization, 39 | events: organization.events.map((event) => { 40 | return { 41 | ...event, 42 | content: JSON.stringify(event.content), 43 | }; 44 | }), 45 | eventProposals: organization.eventProposal.map((eventProposal) => { 46 | return { 47 | ...eventProposal, 48 | content: JSON.stringify(eventProposal.content), 49 | }; 50 | }), 51 | encryptedStates: organization.encryptedStates.map((encryptedState) => { 52 | return { 53 | keyId: encryptedState.keyId, 54 | ciphertext: encryptedState.ciphertext, 55 | nonce: encryptedState.nonce, 56 | publicData: canonicalize(encryptedState.publicData), 57 | author: { 58 | publicKey: encryptedState.authorPublicSigningKey, 59 | signature: encryptedState.authorSignature, 60 | }, 61 | lockbox: { 62 | keyId: encryptedState.key.lockbox[0].keyId, 63 | ciphertext: encryptedState.key.lockbox[0].ciphertext, 64 | receiverSigningPublicKey: 65 | encryptedState.key.lockbox[0].receiverSigningPublicKey, 66 | senderLockboxPublicKey: 67 | encryptedState.key.lockbox[0].senderLockboxPublicKey, 68 | nonce: encryptedState.key.lockbox[0].nonce, 69 | }, 70 | }; 71 | }), 72 | }; 73 | }); 74 | } catch (err) { 75 | console.error(err); 76 | throw new Error("Failed"); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/migrate/src/index.ts: -------------------------------------------------------------------------------- 1 | export type Migration = { 2 | name: string; 3 | statements: string[]; 4 | }; 5 | 6 | export type MigrateParams = { 7 | db: any; // db is not defined to avoid type issues with different libs 8 | migrations: Migration[]; 9 | }; 10 | 11 | const createMigrationsTable = ` 12 | CREATE TABLE IF NOT EXISTS "_migrations" ( 13 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 14 | "migration_name" TEXT NOT NULL, 15 | "created_at" DATETIME NOT NULL DEFAULT current_timestamp 16 | ); 17 | `; 18 | 19 | const insertMigration = ` 20 | INSERT INTO "_migrations" (migration_name) VALUES (:migration_name); 21 | `; 22 | 23 | const getLastMigration = ` 24 | SELECT * FROM "_migrations" ORDER BY "migration_name" DESC LIMIT 1; 25 | `; 26 | 27 | function migrationComparator(a, b) { 28 | if (a.name < b.name) { 29 | return -1; 30 | } 31 | if (a.name > b.name) { 32 | return 1; 33 | } 34 | return 0; 35 | } 36 | 37 | export function migrate({ db, migrations }: MigrateParams) { 38 | return new Promise((resolve, reject) => { 39 | try { 40 | migrations.sort(migrationComparator); 41 | 42 | db.transaction( 43 | (tx) => { 44 | tx.executeSql(createMigrationsTable, []); 45 | tx.executeSql(getLastMigration, [], (_txn, res) => { 46 | if (res.rows.length === 0) { 47 | // apply all migrations 48 | migrations.forEach((migration) => { 49 | migration.statements.forEach((statement) => { 50 | tx.executeSql(statement, []); 51 | }); 52 | tx.executeSql(insertMigration, [migration.name]); 53 | }); 54 | } else if ( 55 | migrations[migrations.length - 1].name === 56 | res.rows.item(0).migration_name 57 | ) { 58 | // apply no migration 59 | } else { 60 | // identify and apply remaining migrations 61 | const migrationsToApply = []; 62 | migrations.forEach((migration) => { 63 | if (migration.name > res.rows.item(0).migration_name) { 64 | migrationsToApply.push(migration); 65 | } 66 | }); 67 | 68 | migrationsToApply.forEach((migration) => { 69 | migration.statements.forEach((statement) => { 70 | tx.executeSql(statement, []); 71 | }); 72 | tx.executeSql(insertMigration, [migration.name]); 73 | }); 74 | } 75 | }); 76 | }, 77 | (error) => { 78 | reject(error); 79 | }, 80 | () => { 81 | resolve(undefined); 82 | } 83 | ); 84 | } catch (error) { 85 | reject(error); 86 | } 87 | }); 88 | } 89 | -------------------------------------------------------------------------------- /packages/trust-chain/src/testUtils.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | 3 | export type KeyPairs = { 4 | sign: { privateKey: string; publicKey: string; keyType: "ed25519" }; 5 | box: { privateKey: string; publicKey: string; keyType: "x25519" }; 6 | }; 7 | 8 | export const getKeyPairA = (): sodium.KeyPair => { 9 | return { 10 | privateKey: sodium.from_base64( 11 | "g3dtwb9XzhSzZGkxTfg11t1KEIb4D8rO7K54R6dnxArvgg_OzZ2GgREtG7F5LvNp3MS8p9vsio4r6Mq7SZDEgw" 12 | ), 13 | publicKey: sodium.from_base64( 14 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM" 15 | ), 16 | keyType: "ed25519", 17 | }; 18 | }; 19 | 20 | export const getKeyPairB = (): sodium.KeyPair => { 21 | return { 22 | privateKey: sodium.from_base64( 23 | "JyI15wGDAmduTUfhmzkIYePqFdPaEG3QLUdRrkqC1dAxMOGpUgx-VMPQJqv4pI_UxYIgRiqzYsFpd9TbR2LS1g" 24 | ), 25 | publicKey: sodium.from_base64( 26 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY" 27 | ), 28 | keyType: "ed25519", 29 | }; 30 | }; 31 | 32 | export const getKeyPairC = (): sodium.KeyPair => { 33 | return { 34 | privateKey: sodium.from_base64( 35 | "W4EYSNTXQqkbv6_P1MF6T7gqRD6J7UyZikDxH9kwTOpkpzCMAwBpKKruTcxBVBRnppruQGt4r__mGQYjhIKW2Q" 36 | ), 37 | publicKey: sodium.from_base64( 38 | "ZKcwjAMAaSiq7k3MQVQUZ6aa7kBreK__5hkGI4SCltk" 39 | ), 40 | keyType: "ed25519", 41 | }; 42 | }; 43 | 44 | export const getKeyPairsA = (): KeyPairs => { 45 | return { 46 | sign: { 47 | privateKey: 48 | "g3dtwb9XzhSzZGkxTfg11t1KEIb4D8rO7K54R6dnxArvgg_OzZ2GgREtG7F5LvNp3MS8p9vsio4r6Mq7SZDEgw", 49 | publicKey: "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 50 | keyType: "ed25519", 51 | }, 52 | box: { 53 | privateKey: "JZFX98tVGO7tnagDgwkKUdnoKh5EI7FlYh8j2E2UtR4", 54 | publicKey: "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 55 | keyType: "x25519", 56 | }, 57 | }; 58 | }; 59 | 60 | export const getKeyPairsB = (): KeyPairs => { 61 | return { 62 | sign: { 63 | privateKey: 64 | "JyI15wGDAmduTUfhmzkIYePqFdPaEG3QLUdRrkqC1dAxMOGpUgx-VMPQJqv4pI_UxYIgRiqzYsFpd9TbR2LS1g", 65 | publicKey: "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY", 66 | keyType: "ed25519", 67 | }, 68 | box: { 69 | privateKey: "fec_R3XDXUW-w6NoWeygnf9NDFzOlJM2QNczm0_ztG8", 70 | publicKey: "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 71 | keyType: "x25519", 72 | }, 73 | }; 74 | }; 75 | 76 | export const getKeyPairsC = (): KeyPairs => { 77 | return { 78 | sign: { 79 | privateKey: 80 | "W4EYSNTXQqkbv6_P1MF6T7gqRD6J7UyZikDxH9kwTOpkpzCMAwBpKKruTcxBVBRnppruQGt4r__mGQYjhIKW2Q", 81 | publicKey: "ZKcwjAMAaSiq7k3MQVQUZ6aa7kBreK__5hkGI4SCltk", 82 | keyType: "ed25519", 83 | }, 84 | box: { 85 | privateKey: "Z5apAnVoYXmKbbF8xXdxW2lz6I8TV8KbiSmwQLcJ24I", 86 | publicKey: "0hUuO22MoTa8X65ZvpR9KcfUwF_B2aIvLORPjuaofBg", 87 | keyType: "x25519", 88 | }, 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /packages/trust-chain/src/state/createLockboxes.test.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { addMember, createChain, resolveState } from ".."; 3 | import { 4 | getKeyPairA, 5 | getKeyPairB, 6 | getKeyPairC, 7 | getKeyPairsA, 8 | getKeyPairsB, 9 | getKeyPairsC, 10 | KeyPairs, 11 | } from "../testUtils"; 12 | import { TrustChainState } from "../types"; 13 | import { hashTransaction } from "../utils"; 14 | import { createLockboxes } from "./createLockboxes"; 15 | import { decryptLockbox } from "./decryptLockbox"; 16 | 17 | let keyPairA: sodium.KeyPair = null; 18 | let keyPairsA: KeyPairs = null; 19 | let keyPairB: sodium.KeyPair = null; 20 | let keyPairsB: KeyPairs = null; 21 | let keyPairsC: KeyPairs = null; 22 | let state: TrustChainState = null; 23 | 24 | beforeAll(async () => { 25 | await sodium.ready; 26 | keyPairA = getKeyPairA(); 27 | keyPairsA = getKeyPairsA(); 28 | keyPairB = getKeyPairB(); 29 | keyPairsB = getKeyPairsB(); 30 | keyPairsC = getKeyPairsC(); 31 | 32 | const createEvent = createChain(keyPairsA.sign, { 33 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 34 | }); 35 | const addMemberEvent = addMember( 36 | hashTransaction(createEvent.transaction), 37 | keyPairA, 38 | keyPairsB.sign.publicKey, 39 | keyPairsB.box.publicKey, 40 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 41 | ); 42 | const addMemberEvent2 = addMember( 43 | hashTransaction(addMemberEvent.transaction), 44 | keyPairB, 45 | keyPairsC.sign.publicKey, 46 | keyPairsC.box.publicKey, 47 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 48 | ); 49 | state = resolveState([createEvent, addMemberEvent, addMemberEvent2]); 50 | }); 51 | 52 | test.only("should create valid lockboxes", async () => { 53 | const key = { 54 | key: "3luB8v0t9n8a6QwgLYXl3Ib98wCqWqdOcxRU2bU7cy4", 55 | keyId: "182ce6ae-369d-48e0-b615-9a12fb9dfc75", 56 | }; 57 | const result = createLockboxes( 58 | key, 59 | keyPairsA.box.privateKey, 60 | keyPairsA.box.publicKey, 61 | state 62 | ); 63 | 64 | const decrypedResulA = decryptLockbox( 65 | keyPairsA.box.privateKey, 66 | result.lockboxes[keyPairsA.sign.publicKey] 67 | ); 68 | expect(decrypedResulA).toMatchInlineSnapshot(` 69 | Object { 70 | "key": "3luB8v0t9n8a6QwgLYXl3Ib98wCqWqdOcxRU2bU7cy4", 71 | "keyId": "182ce6ae-369d-48e0-b615-9a12fb9dfc75", 72 | } 73 | `); 74 | 75 | const decrypedResultB = decryptLockbox( 76 | keyPairsB.box.privateKey, 77 | result.lockboxes[keyPairsB.sign.publicKey] 78 | ); 79 | expect(decrypedResultB).toMatchInlineSnapshot(` 80 | Object { 81 | "key": "3luB8v0t9n8a6QwgLYXl3Ib98wCqWqdOcxRU2bU7cy4", 82 | "keyId": "182ce6ae-369d-48e0-b615-9a12fb9dfc75", 83 | } 84 | `); 85 | 86 | const decrypedResultC = decryptLockbox( 87 | keyPairsC.box.privateKey, 88 | result.lockboxes[keyPairsC.sign.publicKey] 89 | ); 90 | expect(decrypedResultC).toMatchInlineSnapshot(` 91 | Object { 92 | "key": "3luB8v0t9n8a6QwgLYXl3Ib98wCqWqdOcxRU2bU7cy4", 93 | "keyId": "182ce6ae-369d-48e0-b615-9a12fb9dfc75", 94 | } 95 | `); 96 | }); 97 | -------------------------------------------------------------------------------- /demos/backend/src/database/createOrganization.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "./prisma"; 2 | import { 3 | CreateChainTrustChainEvent, 4 | EncryptedState, 5 | Lockbox, 6 | resolveState, 7 | } from "@serenity-tools/trust-chain"; 8 | import { getUserFromSession } from "./getUserFromSession"; 9 | 10 | export async function createOrganization( 11 | session: any, 12 | event: CreateChainTrustChainEvent, 13 | keyId: string, 14 | lockboxes: { [signingPublicKey: string]: Lockbox }, 15 | encryptedState: EncryptedState 16 | ) { 17 | const currentUser = await getUserFromSession(session); 18 | if (encryptedState.author.publicKey !== currentUser.publicSigningKey) { 19 | throw new Error("Failed"); 20 | } 21 | if ( 22 | !event.authors.some( 23 | (author) => author.publicKey === currentUser.publicSigningKey 24 | ) 25 | ) { 26 | throw new Error("Failed"); 27 | } 28 | 29 | try { 30 | const state = resolveState([event]); 31 | 32 | return await prisma.$transaction(async (prisma) => { 33 | await prisma.key.create({ data: { id: keyId } }); 34 | 35 | const memberKeys = Object.keys(state.members); 36 | 37 | // TODO move encryptedState.publicData.clock into Organization model 38 | if ( 39 | encryptedState.publicData.clock === undefined || 40 | encryptedState.publicData.clock === null 41 | ) { 42 | throw new Error("EncryptedState clock not present"); 43 | } 44 | 45 | await prisma.organization.create({ 46 | data: { 47 | id: state.id, 48 | members: { 49 | // TODO change to connect only 50 | connectOrCreate: memberKeys.map((publicSigningKey) => ({ 51 | where: { publicSigningKey }, 52 | create: { publicSigningKey }, 53 | })), 54 | }, 55 | events: { create: { content: event } }, 56 | lastEventHash: state.lastEventHash, 57 | serializedState: state, 58 | encryptedStates: { 59 | create: { 60 | ciphertext: encryptedState.ciphertext, 61 | nonce: encryptedState.nonce, 62 | publicData: encryptedState.publicData, 63 | author: { 64 | connect: { 65 | publicSigningKey: encryptedState.author.publicKey, 66 | }, 67 | }, 68 | authorSignature: encryptedState.author.signature, 69 | key: { connect: { id: keyId } }, 70 | lockboxes: { 71 | create: Object.keys(lockboxes).map((key) => { 72 | const lockbox = lockboxes[key]; 73 | return { 74 | ciphertext: lockbox.ciphertext, 75 | nonce: lockbox.nonce, 76 | keyId, 77 | senderLockboxPublicKey: lockbox.senderLockboxPublicKey, 78 | receiverSigningPublicKey: lockbox.receiverSigningPublicKey, 79 | }; 80 | }), 81 | }, 82 | }, 83 | }, 84 | }, 85 | }); 86 | }); 87 | } catch (err) { 88 | console.error(err); 89 | throw new Error("Failed"); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /demos/backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "postgresql" 3 | url = env("POSTGRES_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | previewFeatures = ["interactiveTransactions"] 9 | binaryTargets = ["native", "debian-openssl-1.1.x"] 10 | output = "./generated/output" 11 | } 12 | 13 | model EncryptedState { 14 | id String @id @default(uuid()) 15 | ciphertext String 16 | nonce String 17 | publicData Json 18 | organization Organization @relation(fields: [organizationId], references: [id]) 19 | organizationId String 20 | author User @relation(fields: [authorPublicSigningKey], references: [publicSigningKey]) 21 | authorPublicSigningKey String 22 | authorSignature String 23 | key Key @relation(fields: [keyId], references: [id]) 24 | keyId String 25 | lockboxes Lockbox[] 26 | 27 | // there is only one EncryptedState per user per organization allowed 28 | @@unique([organizationId, authorPublicSigningKey]) 29 | } 30 | 31 | model Key { 32 | id String @id 33 | encryptedStates EncryptedState[] 34 | lockbox Lockbox[] 35 | } 36 | 37 | model Lockbox { 38 | id String @id @default(uuid()) 39 | receiver User @relation(fields: [receiverSigningPublicKey], references: [publicSigningKey]) 40 | receiverSigningPublicKey String 41 | senderLockboxPublicKey String 42 | ciphertext String 43 | nonce String 44 | key Key @relation(fields: [keyId], references: [id]) 45 | keyId String 46 | encryptedStates EncryptedState[] 47 | 48 | @@unique([keyId, receiverSigningPublicKey]) 49 | } 50 | 51 | model Organization { 52 | id String @id 53 | members User[] 54 | events Event[] 55 | eventProposal EventProposal[] 56 | lastEventHash String 57 | serializedState Json 58 | encryptedStates EncryptedState[] // the current encrypted states 59 | } 60 | 61 | model User { 62 | publicSigningKey String @id 63 | organizations Organization[] 64 | encryptedStates EncryptedState[] 65 | lockboxes Lockbox[] 66 | authenticationChallenge AuthenticationChallenge[] 67 | } 68 | 69 | model AuthenticationChallenge { 70 | nonce String @id 71 | user User @relation(fields: [userPublicSigningKey], references: [publicSigningKey]) 72 | userPublicSigningKey String 73 | validUntil DateTime 74 | } 75 | 76 | model Event { 77 | id Int @id @default(autoincrement()) 78 | content Json 79 | organization Organization @relation(fields: [organizationId], references: [id]) 80 | organizationId String 81 | } 82 | 83 | model EventProposal { 84 | id String @id @default(uuid()) 85 | content Json 86 | organization Organization @relation(fields: [organizationId], references: [id]) 87 | organizationId String 88 | } 89 | -------------------------------------------------------------------------------- /demos/backend/src/index.ts: -------------------------------------------------------------------------------- 1 | require("make-promises-safe"); // installs an 'unhandledRejection' handler 2 | import { ApolloServer } from "apollo-server-express"; 3 | import { 4 | ApolloServerPluginLandingPageGraphQLPlayground, 5 | ApolloServerPluginLandingPageDisabled, 6 | } from "apollo-server-core"; 7 | import sodium from "libsodium-wrappers"; 8 | import express from "express"; 9 | import expressSession from "express-session"; 10 | import cors from "cors"; 11 | // import { WebSocketServer } from "ws"; 12 | import { createServer } from "http"; 13 | import { schema } from "./schema"; 14 | 15 | async function main() { 16 | const allowedOrigin = 17 | process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test" 18 | ? "http://localhost:3000" 19 | : "https://www.serenity.li"; 20 | const corsOptions = { credentials: true, origin: allowedOrigin }; 21 | 22 | const apolloServer = new ApolloServer({ 23 | schema, 24 | plugins: [ 25 | process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test" 26 | ? ApolloServerPluginLandingPageGraphQLPlayground() 27 | : ApolloServerPluginLandingPageDisabled(), 28 | ], 29 | context: (request) => { 30 | return { 31 | // @ts-expect-error 32 | session: request.req.session, 33 | // @ts-expect-error 34 | currentUserSigningPublicKey: request.req.session.userSigningPublicKey, 35 | }; 36 | }, 37 | }); 38 | await apolloServer.start(); 39 | await sodium.ready; 40 | 41 | const app = express(); 42 | app.use(cors(corsOptions)); 43 | const sessionConfig = { 44 | secret: process.env.EXPRESS_SESSION_SECRET, 45 | cookie: { secure: false }, 46 | maxAge: 604800000, // 7 days 47 | sameSite: "lax", 48 | httpOnly: true, 49 | resave: false, 50 | // saveUninitialized: false 51 | }; 52 | 53 | if ( 54 | !(process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") 55 | ) { 56 | app.set("trust proxy", 1); // trust first proxy 57 | sessionConfig.cookie.secure = true; // serve secure cookies 58 | } 59 | 60 | app.use(expressSession(sessionConfig)); 61 | 62 | apolloServer.applyMiddleware({ app, cors: corsOptions }); 63 | 64 | const server = createServer(app); 65 | 66 | // const webSocketServer = new WebSocketServer({ noServer: true }); 67 | // webSocketServer.on( 68 | // "connection", 69 | // async function connection(connection, request) { 70 | // // unique id for each client connection 71 | 72 | // console.log("connected"); 73 | // // connection.send(JSON.stringify({ type: "document", ...doc })); 74 | 75 | // connection.on("message", async function message(messageContent) { 76 | // const data = JSON.parse(messageContent.toString()); 77 | // }); 78 | 79 | // connection.on("close", function () { 80 | // console.log("close connection"); 81 | // }); 82 | // } 83 | // ); 84 | 85 | // server.on("upgrade", (request, socket, head) => { 86 | // // @ts-ignore 87 | // webSocketServer.handleUpgrade(request, socket, head, (ws) => { 88 | // webSocketServer.emit("connection", ws, request); 89 | // }); 90 | // }); 91 | 92 | const port = process.env.PORT ? parseInt(process.env.PORT) : 4000; 93 | server.listen(port, () => { 94 | console.log(`🚀 App ready at http://localhost:${port}/`); 95 | console.log(`🚀 GraphQL service ready at http://localhost:${port}/graphql`); 96 | // console.log(`🚀 Websocket service ready at ws://localhost:${port}`); 97 | }); 98 | } 99 | 100 | main(); 101 | -------------------------------------------------------------------------------- /packages/trust-chain/src/utils.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { 3 | TrustChainEvent, 4 | Permission, 5 | DefaultTrustChainEvent, 6 | TrustChainState, 7 | } from "./types"; 8 | 9 | // vendored from https://github.com/erdtman/canonicalize 10 | export const canonicalize = (object: any) => { 11 | if (object === null || typeof object !== "object") { 12 | return JSON.stringify(object); 13 | } 14 | 15 | if (object.toJSON instanceof Function) { 16 | return canonicalize(object.toJSON()); 17 | } 18 | 19 | if (Array.isArray(object)) { 20 | const values = object.reduce((t, cv, ci) => { 21 | const comma = ci === 0 ? "" : ","; 22 | const value = cv === undefined || typeof cv === "symbol" ? null : cv; 23 | return `${t}${comma}${canonicalize(value)}`; 24 | }, ""); 25 | return `[${values}]`; 26 | } 27 | 28 | const values = Object.keys(object) 29 | .sort() 30 | .reduce((t, cv) => { 31 | if (object[cv] === undefined || typeof object[cv] === "symbol") { 32 | return t; 33 | } 34 | const comma = t.length === 0 ? "" : ","; 35 | return `${t}${comma}${canonicalize(cv)}:${canonicalize(object[cv])}`; 36 | }, ""); 37 | return `{${values}}`; 38 | }; 39 | 40 | export const hashTransaction = (transaction) => { 41 | return sodium.to_base64( 42 | sodium.crypto_generichash(64, canonicalize(transaction)) 43 | ); 44 | }; 45 | 46 | export const isValidCreateChainEvent = (event: TrustChainEvent) => { 47 | if (event.transaction.type !== "create" || event.prevHash !== null) { 48 | return false; 49 | } 50 | if ( 51 | Object.keys(event.transaction.lockboxPublicKeys).length !== 52 | event.authors.length 53 | ) { 54 | return false; 55 | } 56 | const lockboxPublicKeys = event.transaction.lockboxPublicKeys; 57 | const hash = hashTransaction(event.transaction); 58 | return event.authors.every((author) => { 59 | if (!lockboxPublicKeys.hasOwnProperty(author.publicKey)) { 60 | return false; 61 | } 62 | return sodium.crypto_sign_verify_detached( 63 | sodium.from_base64(author.signature), 64 | hash, 65 | sodium.from_base64(author.publicKey) 66 | ); 67 | }); 68 | }; 69 | 70 | export const areValidPermissions = ( 71 | state: TrustChainState, 72 | event: DefaultTrustChainEvent, 73 | permission: Permission 74 | ) => { 75 | return event.authors.every((author) => { 76 | if (!state.members.hasOwnProperty(author.publicKey)) { 77 | return false; 78 | } 79 | if (!state.members[author.publicKey][permission]) { 80 | return false; 81 | } 82 | return true; 83 | }); 84 | }; 85 | 86 | export const allAuthorsAreValidAdmins = ( 87 | state: TrustChainState, 88 | event: DefaultTrustChainEvent 89 | ) => { 90 | return event.authors.every((author) => { 91 | if (!state.members.hasOwnProperty(author.publicKey)) { 92 | return false; 93 | } 94 | if (!state.members[author.publicKey].isAdmin) { 95 | return false; 96 | } 97 | return true; 98 | }); 99 | }; 100 | 101 | export const getAdminCount = (state: TrustChainState) => { 102 | let adminCount = 0; 103 | Object.keys(state.members).forEach((memberKey) => { 104 | if (state.members[memberKey].isAdmin) { 105 | adminCount = adminCount + 1; 106 | } 107 | }); 108 | return adminCount; 109 | }; 110 | 111 | export const isValidAdminDecision = ( 112 | state: TrustChainState, 113 | event: DefaultTrustChainEvent 114 | ) => { 115 | if (!allAuthorsAreValidAdmins(state, event as DefaultTrustChainEvent)) { 116 | return false; 117 | } 118 | const adminCount = getAdminCount(state); 119 | if (event.authors.length > adminCount / 2) { 120 | return true; 121 | } 122 | return false; 123 | }; 124 | -------------------------------------------------------------------------------- /packages/trust-chain/src/resolveState.createChain.test.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { addAuthorToEvent } from "./addAuthorToEvent"; 3 | import { InvalidTrustChainError } from "./errors"; 4 | import { createChain, resolveState } from "./index"; 5 | import { 6 | getKeyPairB, 7 | getKeyPairsA, 8 | getKeyPairsB, 9 | getKeyPairsC, 10 | KeyPairs, 11 | } from "./testUtils"; 12 | 13 | let keyPairsA: KeyPairs = null; 14 | let keyPairB: sodium.KeyPair = null; 15 | let keyPairsB: KeyPairs = null; 16 | let keyPairsC: KeyPairs = null; 17 | 18 | beforeAll(async () => { 19 | await sodium.ready; 20 | keyPairsA = getKeyPairsA(); 21 | keyPairB = getKeyPairB(); 22 | keyPairsB = getKeyPairsB(); 23 | keyPairsC = getKeyPairsC(); 24 | 25 | // const newKeyPair = sodium.crypto_sign_keypair(); 26 | // console.log("privateKey: ", sodium.to_base64(newKeyPair.privateKey)); 27 | // console.log("publicKey: ", sodium.to_base64(newKeyPair.publicKey)); 28 | }); 29 | 30 | test("should resolve to one admin after creating a chain", async () => { 31 | const event = createChain(keyPairsA.sign, { 32 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 33 | }); 34 | const state = resolveState([event]); 35 | expect(state.members).toMatchInlineSnapshot(` 36 | Object { 37 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 38 | "addedBy": Array [ 39 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 40 | ], 41 | "canAddMembers": true, 42 | "canRemoveMembers": true, 43 | "isAdmin": true, 44 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 45 | }, 46 | } 47 | `); 48 | }); 49 | 50 | test("should resolve to two admins after creating a chain with two authors", async () => { 51 | const event = createChain(keyPairsA.sign, { 52 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 53 | [keyPairsB.sign.publicKey]: keyPairsB.box.publicKey, 54 | }); 55 | const event2 = addAuthorToEvent(event, keyPairB); 56 | const state = resolveState([event2]); 57 | expect(state.members).toMatchInlineSnapshot(` 58 | Object { 59 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 60 | "addedBy": Array [ 61 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 62 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY", 63 | ], 64 | "canAddMembers": true, 65 | "canRemoveMembers": true, 66 | "isAdmin": true, 67 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 68 | }, 69 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 70 | "addedBy": Array [ 71 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 72 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY", 73 | ], 74 | "canAddMembers": true, 75 | "canRemoveMembers": true, 76 | "isAdmin": true, 77 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 78 | }, 79 | } 80 | `); 81 | }); 82 | 83 | test("should fail in case there are more authors than declared admins", async () => { 84 | const event = createChain(keyPairsA.sign, { 85 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 86 | }); 87 | const event2 = addAuthorToEvent(event, keyPairB); 88 | const chain = [event2]; 89 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 90 | expect(() => resolveState(chain)).toThrow("Invalid chain creation event."); 91 | }); 92 | 93 | test("should fail in case the authors and declared admins don't match up", async () => { 94 | const event = createChain(keyPairsA.sign, { 95 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 96 | [keyPairsC.sign.publicKey]: keyPairsC.box.publicKey, 97 | }); 98 | const event2 = addAuthorToEvent(event, keyPairB); 99 | const chain = [event2]; 100 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 101 | expect(() => resolveState(chain)).toThrow("Invalid chain creation event."); 102 | }); 103 | -------------------------------------------------------------------------------- /packages/trust-chain/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Permission = "canAddMembers" | "canRemoveMembers"; 2 | 3 | export type CreateChainTransaction = { 4 | type: "create"; 5 | id: string; 6 | lockboxPublicKeys: { [signingPublicKey: string]: string }; 7 | }; 8 | 9 | export type AddMemberTransaction = 10 | | { 11 | type: "add-member"; 12 | memberSigningPublicKey: string; 13 | memberLockboxPublicKey: string; 14 | isAdmin: true; 15 | canAddMembers: true; 16 | canRemoveMembers: true; 17 | } 18 | | { 19 | type: "add-member"; 20 | memberSigningPublicKey: string; 21 | memberLockboxPublicKey: string; 22 | isAdmin: false; 23 | canAddMembers: boolean; 24 | canRemoveMembers: boolean; 25 | }; 26 | 27 | export type UpdateMemberTransaction = 28 | | { 29 | type: "update-member"; 30 | memberSigningPublicKey: string; 31 | isAdmin: true; 32 | canAddMembers: true; 33 | canRemoveMembers: true; 34 | } 35 | | { 36 | type: "update-member"; 37 | memberSigningPublicKey: string; 38 | isAdmin: false; 39 | canAddMembers: boolean; 40 | canRemoveMembers: boolean; 41 | }; 42 | 43 | export type RemoveMemberTransaction = { 44 | type: "remove-member"; 45 | memberSigningPublicKey: string; 46 | }; 47 | 48 | export type Author = { 49 | publicKey: string; 50 | signature: string; 51 | }; 52 | 53 | export type CreateChainTrustChainEvent = { 54 | authors: Author[]; 55 | transaction: CreateChainTransaction; 56 | prevHash: null; 57 | }; 58 | 59 | export type DefaultTrustChainEvent = { 60 | authors: Author[]; 61 | transaction: 62 | | AddMemberTransaction 63 | | UpdateMemberTransaction 64 | | RemoveMemberTransaction; 65 | prevHash: string; 66 | }; 67 | 68 | export type TrustChainEvent = 69 | | CreateChainTrustChainEvent 70 | | DefaultTrustChainEvent; 71 | 72 | export type MemberAuthorization = 73 | | { 74 | isAdmin: true; 75 | canAddMembers: true; 76 | canRemoveMembers: true; 77 | } 78 | | { 79 | isAdmin: false; 80 | canAddMembers: boolean; 81 | canRemoveMembers: boolean; 82 | }; 83 | 84 | export type MemberProperties = 85 | | { 86 | lockboxPublicKey: string; 87 | isAdmin: true; 88 | canAddMembers: true; 89 | canRemoveMembers: true; 90 | addedBy: string[]; 91 | name?: string; 92 | profileUpdatedBy?: string; 93 | } 94 | | { 95 | lockboxPublicKey: string; 96 | isAdmin: false; 97 | canAddMembers: boolean; 98 | canRemoveMembers: boolean; 99 | addedBy: string[]; 100 | name?: string; 101 | profileUpdatedBy?: string; 102 | }; 103 | 104 | export type TrustChainState = { 105 | id: string; 106 | // TODO split up into a better structure 107 | members: { [publicKey: string]: MemberProperties }; 108 | lastEventHash: string; 109 | encryptedStateClock: number; 110 | trustChainVersion: number; // allows to know when to recompute the state after a bug fix 111 | }; 112 | 113 | export type KeyPairBase64 = { 114 | privateKey: string; 115 | publicKey: string; 116 | }; 117 | 118 | // encrypted state 119 | 120 | export type Key = { 121 | keyId: string; 122 | key: string; 123 | }; 124 | 125 | export type EncryptedState = { 126 | ciphertext: string; 127 | nonce: string; 128 | keyId: string; 129 | publicData: { clock: number }; 130 | author: Author; 131 | }; 132 | 133 | export type EncryptedMemberStateUpdate = { 134 | name: string; 135 | }; 136 | 137 | export type RawEncryptedStateUpdate = { 138 | members: { [publicKey: string]: EncryptedMemberStateUpdate }; 139 | }; 140 | 141 | export type EncryptedStateUpdate = { 142 | members: { [publicKey: string]: EncryptedMemberStateUpdate }; 143 | hash: string; // this hash ensures that all participants end up with the same state 144 | // TODO add the chain hash as well to ensure integrity? 145 | }; 146 | 147 | export type Lockbox = { 148 | receiverSigningPublicKey: string; 149 | senderLockboxPublicKey: string; 150 | ciphertext: string; 151 | nonce: string; 152 | }; 153 | -------------------------------------------------------------------------------- /demos/backend/src/database/addMemberToOrganization.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "./prisma"; 2 | import { 3 | DefaultTrustChainEvent, 4 | applyEvent, 5 | TrustChainState, 6 | Lockbox, 7 | } from "@serenity-tools/trust-chain"; 8 | import { getUserFromSession } from "./getUserFromSession"; 9 | 10 | export async function addMemberToOrganization( 11 | session: any, 12 | organizationId: string, 13 | event: DefaultTrustChainEvent, 14 | keyId: string, 15 | lockbox: Lockbox, 16 | encryptedState: any, 17 | eventProposalId?: string 18 | ) { 19 | const currentUser = await getUserFromSession(session); 20 | if (encryptedState.author.publicKey !== currentUser.publicSigningKey) { 21 | throw new Error("Failed"); 22 | } 23 | if ( 24 | !event.authors.some( 25 | (author) => author.publicKey === currentUser.publicSigningKey 26 | ) 27 | ) { 28 | throw new Error("Failed"); 29 | } 30 | 31 | try { 32 | return await prisma.$transaction(async (prisma) => { 33 | const org = await prisma.organization.findUnique({ 34 | where: { id: organizationId }, 35 | }); 36 | 37 | if (event.transaction.type !== "add-member") { 38 | throw new Error("Not an add-member event"); 39 | } 40 | 41 | const newState = applyEvent( 42 | org.serializedState as TrustChainState, 43 | event 44 | ); 45 | 46 | if (eventProposalId) { 47 | // TODO verify that the eventPropsal and event transaction is identical and the user has access to it 48 | await prisma.eventProposal.delete({ 49 | where: { id: eventProposalId }, 50 | }); 51 | } 52 | 53 | // TODO move encryptedState.publicData.clock into Organization model 54 | const lastEncryptedStateEntryForThisOrg = 55 | await prisma.encryptedState.findFirst({ 56 | where: { organizationId: org.id }, 57 | }); 58 | if ( 59 | encryptedState.publicData.clock === undefined || 60 | encryptedState.publicData.clock === null || 61 | (lastEncryptedStateEntryForThisOrg && 62 | encryptedState.publicData.clock <= 63 | // @ts-expect-error 64 | lastEncryptedStateEntryForThisOrg.publicData.clock) 65 | ) { 66 | throw new Error("EncryptedState clock not present or must increase"); 67 | } 68 | 69 | return await prisma.organization.update({ 70 | where: { id: org.id }, 71 | data: { 72 | members: { 73 | connect: { 74 | publicSigningKey: event.transaction.memberSigningPublicKey, 75 | }, 76 | }, 77 | serializedState: newState, 78 | lastEventHash: newState.lastEventHash, 79 | events: { create: { content: event } }, 80 | encryptedStates: { 81 | upsert: { 82 | where: { 83 | organizationId_authorPublicSigningKey: { 84 | authorPublicSigningKey: encryptedState.author.publicKey, 85 | organizationId, 86 | }, 87 | }, 88 | update: { 89 | ciphertext: encryptedState.ciphertext, 90 | nonce: encryptedState.nonce, 91 | publicData: encryptedState.publicData, 92 | authorSignature: encryptedState.author.signature, 93 | key: { connect: { id: keyId } }, 94 | lockboxes: { 95 | create: { 96 | ciphertext: lockbox.ciphertext, 97 | nonce: lockbox.nonce, 98 | keyId, 99 | senderLockboxPublicKey: lockbox.senderLockboxPublicKey, 100 | receiverSigningPublicKey: lockbox.receiverSigningPublicKey, 101 | }, 102 | }, 103 | }, 104 | create: { 105 | ciphertext: encryptedState.ciphertext, 106 | nonce: encryptedState.nonce, 107 | publicData: encryptedState.publicData, 108 | author: { 109 | connect: { 110 | publicSigningKey: encryptedState.author.publicKey, 111 | }, 112 | }, 113 | authorSignature: encryptedState.author.signature, 114 | key: { connect: { id: keyId } }, 115 | lockboxes: { 116 | create: { 117 | ciphertext: lockbox.ciphertext, 118 | nonce: lockbox.nonce, 119 | keyId, 120 | senderLockboxPublicKey: lockbox.senderLockboxPublicKey, 121 | receiverSigningPublicKey: lockbox.receiverSigningPublicKey, 122 | }, 123 | }, 124 | }, 125 | }, 126 | }, 127 | }, 128 | }); 129 | }); 130 | } catch (err) { 131 | console.error(err); 132 | throw new Error("Failed"); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /packages/migrate/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import openDatabase from "websql"; 2 | import { migrate } from "./index"; 3 | 4 | const queryMigrationsTable = ` 5 | SELECT name FROM sqlite_master WHERE type='table' AND name='_migrations'; 6 | `; 7 | 8 | const queryTables = ` 9 | SELECT name FROM sqlite_master WHERE type='table'; 10 | `; 11 | 12 | const queryMigrations = ` 13 | SELECT * FROM "_migrations" ORDER BY "migration_name" DESC; 14 | `; 15 | 16 | const migration1 = { 17 | name: "202110211000_init", 18 | statements: [ 19 | `CREATE TABLE "User" ( 20 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 21 | "name" TEXT 22 | );`, 23 | ], 24 | }; 25 | 26 | const migration2 = { 27 | name: "202201010000_add_notes", 28 | statements: [ 29 | `CREATE TABLE "Note" ( 30 | "id" TEXT NOT NULL PRIMARY KEY, 31 | "content" TEXT 32 | );`, 33 | ], 34 | }; 35 | 36 | const migrationFail2 = { 37 | name: "202201010000_add_notes", 38 | statements: [ 39 | `FAIL CREATE TABLE "Note" ( 40 | "id" TEXT NOT NULL PRIMARY KEY, 41 | "content" TEXT 42 | );`, 43 | ], 44 | }; 45 | 46 | function aysncQuery(db, query) { 47 | return new Promise((resolve, reject) => { 48 | db.readTransaction( 49 | (tx) => { 50 | tx.executeSql(query, [], (_txn, res) => { 51 | resolve(res); 52 | }); 53 | }, 54 | (error) => { 55 | reject(error); 56 | } 57 | ); 58 | }); 59 | } 60 | 61 | function rowsToArray(rows) { 62 | const entries = []; 63 | for (let i = 0; i < rows.length; ++i) { 64 | entries.push(rows.item(i)); 65 | } 66 | return entries; 67 | } 68 | 69 | test("should succeed when no migrations are applied", async () => { 70 | expect.assertions(2); 71 | const db = openDatabase(":memory:", "1.0", "", 1); 72 | await migrate({ db, migrations: [] }); 73 | const data: any = await aysncQuery(db, queryMigrationsTable); 74 | 75 | expect(data.rows.length).toBe(1); 76 | expect(data.rows.item(0).name).toBe("_migrations"); 77 | }); 78 | 79 | test("should succeed when with one migration", async () => { 80 | expect.assertions(5); 81 | const db = openDatabase(":memory:", "1.0", "", 1); 82 | await migrate({ db, migrations: [migration1] }); 83 | const data: any = await aysncQuery(db, queryMigrations); 84 | 85 | expect(data.rows.length).toBe(1); 86 | expect(data.rows.item(0).id).toBe(1); 87 | expect(data.rows.item(0).migration_name).toBe("202110211000_init"); 88 | expect(data.rows.item(0).created_at).toBeDefined(); 89 | 90 | const tablesResult: any = await aysncQuery(db, queryTables); 91 | const tables = rowsToArray(tablesResult.rows); 92 | 93 | expect(tables).toContainEqual({ name: "User" }); 94 | }); 95 | 96 | test("should succeed when running multiple migrations at once", async () => { 97 | expect.assertions(6); 98 | const db = openDatabase(":memory:", "1.0", "", 1); 99 | await migrate({ db, migrations: [migration1, migration2] }); 100 | 101 | const data: any = await aysncQuery(db, queryMigrations); 102 | 103 | expect(data.rows.length).toBe(2); 104 | expect(data.rows.item(0).id).toBe(2); 105 | expect(data.rows.item(0).migration_name).toBe("202201010000_add_notes"); 106 | expect(data.rows.item(0).created_at).toBeDefined(); 107 | 108 | const tablesResult: any = await aysncQuery(db, queryTables); 109 | const tables = rowsToArray(tablesResult.rows); 110 | 111 | expect(tables).toContainEqual({ name: "User" }); 112 | expect(tables).toContainEqual({ name: "Note" }); 113 | }); 114 | 115 | test("should succeed when running migrations one after another", async () => { 116 | expect.assertions(6); 117 | const db = openDatabase(":memory:", "1.0", "", 1); 118 | await migrate({ db, migrations: [migration1] }); 119 | await migrate({ db, migrations: [migration2] }); 120 | 121 | const data: any = await aysncQuery(db, queryMigrations); 122 | 123 | expect(data.rows.length).toBe(2); 124 | expect(data.rows.item(0).id).toBe(2); 125 | expect(data.rows.item(0).migration_name).toBe("202201010000_add_notes"); 126 | expect(data.rows.item(0).created_at).toBeDefined(); 127 | 128 | const tablesResult: any = await aysncQuery(db, queryTables); 129 | const tables = rowsToArray(tablesResult.rows); 130 | 131 | expect(tables).toContainEqual({ name: "User" }); 132 | expect(tables).toContainEqual({ name: "Note" }); 133 | }); 134 | 135 | test("should commit no migrations if one fails", async () => { 136 | expect.assertions(4); 137 | const db = openDatabase(":memory:", "1.0", "", 1); 138 | 139 | await expect( 140 | migrate({ 141 | db, 142 | migrations: [migration1, migrationFail2], 143 | }) 144 | ).rejects.toThrow('SQLITE_ERROR: near "FAIL": syntax error'); 145 | 146 | const tablesResult: any = await aysncQuery(db, queryTables); 147 | const tables = rowsToArray(tablesResult.rows); 148 | 149 | expect(tables).not.toContainEqual({ name: "_migrations" }); 150 | expect(tables).not.toContainEqual({ name: "User" }); 151 | expect(tables).not.toContainEqual({ name: "Note" }); 152 | }); 153 | -------------------------------------------------------------------------------- /demos/backend/prisma/migrations/20220124041905_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "EncryptedState" ( 3 | "id" TEXT NOT NULL, 4 | "ciphertext" TEXT NOT NULL, 5 | "nonce" TEXT NOT NULL, 6 | "publicData" JSONB NOT NULL, 7 | "organizationId" TEXT NOT NULL, 8 | "authorPublicSigningKey" TEXT NOT NULL, 9 | "authorSignature" TEXT NOT NULL, 10 | "keyId" TEXT NOT NULL, 11 | 12 | CONSTRAINT "EncryptedState_pkey" PRIMARY KEY ("id") 13 | ); 14 | 15 | -- CreateTable 16 | CREATE TABLE "Key" ( 17 | "id" TEXT NOT NULL, 18 | 19 | CONSTRAINT "Key_pkey" PRIMARY KEY ("id") 20 | ); 21 | 22 | -- CreateTable 23 | CREATE TABLE "Lockbox" ( 24 | "id" TEXT NOT NULL, 25 | "receiverSigningPublicKey" TEXT NOT NULL, 26 | "senderLockboxPublicKey" TEXT NOT NULL, 27 | "ciphertext" TEXT NOT NULL, 28 | "nonce" TEXT NOT NULL, 29 | "keyId" TEXT NOT NULL, 30 | 31 | CONSTRAINT "Lockbox_pkey" PRIMARY KEY ("id") 32 | ); 33 | 34 | -- CreateTable 35 | CREATE TABLE "Organization" ( 36 | "id" TEXT NOT NULL, 37 | "lastEventHash" TEXT NOT NULL, 38 | "serializedState" JSONB NOT NULL, 39 | 40 | CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") 41 | ); 42 | 43 | -- CreateTable 44 | CREATE TABLE "User" ( 45 | "publicSigningKey" TEXT NOT NULL, 46 | 47 | CONSTRAINT "User_pkey" PRIMARY KEY ("publicSigningKey") 48 | ); 49 | 50 | -- CreateTable 51 | CREATE TABLE "AuthenticationChallenge" ( 52 | "nonce" TEXT NOT NULL, 53 | "userPublicSigningKey" TEXT NOT NULL, 54 | "validUntil" TIMESTAMP(3) NOT NULL, 55 | 56 | CONSTRAINT "AuthenticationChallenge_pkey" PRIMARY KEY ("nonce") 57 | ); 58 | 59 | -- CreateTable 60 | CREATE TABLE "Event" ( 61 | "id" SERIAL NOT NULL, 62 | "content" JSONB NOT NULL, 63 | "organizationId" TEXT NOT NULL, 64 | 65 | CONSTRAINT "Event_pkey" PRIMARY KEY ("id") 66 | ); 67 | 68 | -- CreateTable 69 | CREATE TABLE "EventProposal" ( 70 | "id" TEXT NOT NULL, 71 | "content" JSONB NOT NULL, 72 | "organizationId" TEXT NOT NULL, 73 | 74 | CONSTRAINT "EventProposal_pkey" PRIMARY KEY ("id") 75 | ); 76 | 77 | -- CreateTable 78 | CREATE TABLE "_EncryptedStateToLockbox" ( 79 | "A" TEXT NOT NULL, 80 | "B" TEXT NOT NULL 81 | ); 82 | 83 | -- CreateTable 84 | CREATE TABLE "_OrganizationToUser" ( 85 | "A" TEXT NOT NULL, 86 | "B" TEXT NOT NULL 87 | ); 88 | 89 | -- CreateIndex 90 | CREATE UNIQUE INDEX "EncryptedState_organizationId_authorPublicSigningKey_key" ON "EncryptedState"("organizationId", "authorPublicSigningKey"); 91 | 92 | -- CreateIndex 93 | CREATE UNIQUE INDEX "Lockbox_keyId_receiverSigningPublicKey_key" ON "Lockbox"("keyId", "receiverSigningPublicKey"); 94 | 95 | -- CreateIndex 96 | CREATE UNIQUE INDEX "_EncryptedStateToLockbox_AB_unique" ON "_EncryptedStateToLockbox"("A", "B"); 97 | 98 | -- CreateIndex 99 | CREATE INDEX "_EncryptedStateToLockbox_B_index" ON "_EncryptedStateToLockbox"("B"); 100 | 101 | -- CreateIndex 102 | CREATE UNIQUE INDEX "_OrganizationToUser_AB_unique" ON "_OrganizationToUser"("A", "B"); 103 | 104 | -- CreateIndex 105 | CREATE INDEX "_OrganizationToUser_B_index" ON "_OrganizationToUser"("B"); 106 | 107 | -- AddForeignKey 108 | ALTER TABLE "EncryptedState" ADD CONSTRAINT "EncryptedState_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 109 | 110 | -- AddForeignKey 111 | ALTER TABLE "EncryptedState" ADD CONSTRAINT "EncryptedState_authorPublicSigningKey_fkey" FOREIGN KEY ("authorPublicSigningKey") REFERENCES "User"("publicSigningKey") ON DELETE RESTRICT ON UPDATE CASCADE; 112 | 113 | -- AddForeignKey 114 | ALTER TABLE "EncryptedState" ADD CONSTRAINT "EncryptedState_keyId_fkey" FOREIGN KEY ("keyId") REFERENCES "Key"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 115 | 116 | -- AddForeignKey 117 | ALTER TABLE "Lockbox" ADD CONSTRAINT "Lockbox_receiverSigningPublicKey_fkey" FOREIGN KEY ("receiverSigningPublicKey") REFERENCES "User"("publicSigningKey") ON DELETE RESTRICT ON UPDATE CASCADE; 118 | 119 | -- AddForeignKey 120 | ALTER TABLE "Lockbox" ADD CONSTRAINT "Lockbox_keyId_fkey" FOREIGN KEY ("keyId") REFERENCES "Key"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 121 | 122 | -- AddForeignKey 123 | ALTER TABLE "AuthenticationChallenge" ADD CONSTRAINT "AuthenticationChallenge_userPublicSigningKey_fkey" FOREIGN KEY ("userPublicSigningKey") REFERENCES "User"("publicSigningKey") ON DELETE RESTRICT ON UPDATE CASCADE; 124 | 125 | -- AddForeignKey 126 | ALTER TABLE "Event" ADD CONSTRAINT "Event_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 127 | 128 | -- AddForeignKey 129 | ALTER TABLE "EventProposal" ADD CONSTRAINT "EventProposal_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 130 | 131 | -- AddForeignKey 132 | ALTER TABLE "_EncryptedStateToLockbox" ADD FOREIGN KEY ("A") REFERENCES "EncryptedState"("id") ON DELETE CASCADE ON UPDATE CASCADE; 133 | 134 | -- AddForeignKey 135 | ALTER TABLE "_EncryptedStateToLockbox" ADD FOREIGN KEY ("B") REFERENCES "Lockbox"("id") ON DELETE CASCADE ON UPDATE CASCADE; 136 | 137 | -- AddForeignKey 138 | ALTER TABLE "_OrganizationToUser" ADD FOREIGN KEY ("A") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; 139 | 140 | -- AddForeignKey 141 | ALTER TABLE "_OrganizationToUser" ADD FOREIGN KEY ("B") REFERENCES "User"("publicSigningKey") ON DELETE CASCADE ON UPDATE CASCADE; 142 | -------------------------------------------------------------------------------- /demos/backend/src/database/removeMemberFromOrganization.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "./prisma"; 2 | import { 3 | DefaultTrustChainEvent, 4 | applyEvent, 5 | TrustChainState, 6 | EncryptedState, 7 | Lockbox, 8 | } from "@serenity-tools/trust-chain"; 9 | import { getUserFromSession } from "./getUserFromSession"; 10 | 11 | export async function removeMemberFromOrganization( 12 | session: any, 13 | organizationId: string, 14 | event: DefaultTrustChainEvent, 15 | keyId: string, 16 | lockboxes: { [signingPublicKey: string]: Lockbox }, 17 | // TODO could be made optional if the keyId is committed to the chain 18 | encryptedState: EncryptedState, 19 | eventProposalId?: string 20 | ) { 21 | const currentUser = await getUserFromSession(session); 22 | if (encryptedState.author.publicKey !== currentUser.publicSigningKey) { 23 | throw new Error("Failed"); 24 | } 25 | if ( 26 | !event.authors.some( 27 | (author) => author.publicKey === currentUser.publicSigningKey 28 | ) 29 | ) { 30 | throw new Error("Failed"); 31 | } 32 | 33 | try { 34 | return await prisma.$transaction(async (prisma) => { 35 | const org = await prisma.organization.findUnique({ 36 | where: { id: organizationId }, 37 | }); 38 | 39 | if (event.transaction.type !== "remove-member") { 40 | throw new Error("Not an remove-member event"); 41 | } 42 | 43 | const newState = applyEvent( 44 | org.serializedState as TrustChainState, 45 | event 46 | ); 47 | 48 | await prisma.key.create({ data: { id: keyId } }); 49 | 50 | if (eventProposalId) { 51 | // TODO verify that the eventPropsal and event transaction is identical and the user has access to it 52 | await prisma.eventProposal.delete({ 53 | where: { id: eventProposalId }, 54 | }); 55 | } 56 | 57 | // TODO move encryptedState.publicData.clock into Organization model 58 | const lastEncryptedStateEntryForThisOrg = 59 | await prisma.encryptedState.findFirst({ 60 | where: { organizationId: org.id }, 61 | }); 62 | if ( 63 | encryptedState.publicData.clock === undefined || 64 | encryptedState.publicData.clock === null || 65 | (lastEncryptedStateEntryForThisOrg && 66 | encryptedState.publicData.clock <= 67 | // @ts-expect-error 68 | lastEncryptedStateEntryForThisOrg.publicData.clock) 69 | ) { 70 | throw new Error("EncryptedState clock not present or must increase"); 71 | } 72 | 73 | const encryptedStateEntry = await prisma.encryptedState.findUnique({ 74 | where: { 75 | organizationId_authorPublicSigningKey: { 76 | authorPublicSigningKey: encryptedState.author.publicKey, 77 | organizationId, 78 | }, 79 | }, 80 | include: { lockboxes: { select: { id: true } } }, 81 | }); 82 | 83 | return await prisma.organization.update({ 84 | where: { id: org.id }, 85 | data: { 86 | members: { 87 | disconnect: { 88 | publicSigningKey: event.transaction.memberSigningPublicKey, 89 | }, 90 | }, 91 | serializedState: newState, 92 | lastEventHash: newState.lastEventHash, 93 | events: { create: { content: event } }, 94 | encryptedStates: { 95 | upsert: { 96 | where: { 97 | organizationId_authorPublicSigningKey: { 98 | authorPublicSigningKey: encryptedState.author.publicKey, 99 | organizationId, 100 | }, 101 | }, 102 | update: { 103 | ciphertext: encryptedState.ciphertext, 104 | nonce: encryptedState.nonce, 105 | publicData: encryptedState.publicData, 106 | authorSignature: encryptedState.author.signature, 107 | key: { connect: { id: keyId } }, 108 | lockboxes: { 109 | disconnect: encryptedStateEntry.lockboxes.map((lockbox) => ({ 110 | id: lockbox.id, 111 | })), 112 | create: Object.keys(lockboxes).map((key) => { 113 | const lockbox = lockboxes[key]; 114 | return { 115 | ciphertext: lockbox.ciphertext, 116 | nonce: lockbox.nonce, 117 | keyId, 118 | senderLockboxPublicKey: lockbox.senderLockboxPublicKey, 119 | receiverSigningPublicKey: 120 | lockbox.receiverSigningPublicKey, 121 | }; 122 | }), 123 | }, 124 | }, 125 | create: { 126 | ciphertext: encryptedState.ciphertext, 127 | nonce: encryptedState.nonce, 128 | publicData: encryptedState.publicData, 129 | author: { 130 | connect: { 131 | publicSigningKey: encryptedState.author.publicKey, // TODO todo verify that this matches with the current authentication 132 | }, 133 | }, 134 | authorSignature: encryptedState.author.signature, 135 | key: { connect: { id: keyId } }, 136 | lockboxes: { 137 | create: Object.keys(lockboxes).map((key) => { 138 | const lockbox = lockboxes[key]; 139 | return { 140 | ciphertext: lockbox.ciphertext, 141 | nonce: lockbox.nonce, 142 | keyId, 143 | senderLockboxPublicKey: lockbox.senderLockboxPublicKey, 144 | receiverSigningPublicKey: 145 | lockbox.receiverSigningPublicKey, 146 | }; 147 | }), 148 | }, 149 | }, 150 | }, 151 | }, 152 | }, 153 | }); 154 | }); 155 | } catch (err) { 156 | console.error(err); 157 | throw new Error("Failed"); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /packages/trust-chain/src/state/resolveEncryptedState.test.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { createChain, InvalidEncryptedStateError, resolveState } from ".."; 3 | import { getKeyPairA, getKeyPairsA, KeyPairs } from "../testUtils"; 4 | import { createKey } from "./createKey"; 5 | import { encryptState } from "./encryptState"; 6 | import { resolveEncryptedState } from "./resolveEncryptedState"; 7 | 8 | let keyPairA: sodium.KeyPair = null; 9 | let keyPairsA: KeyPairs = null; 10 | let keyPairAPublicKey: string = null; 11 | 12 | beforeAll(async () => { 13 | await sodium.ready; 14 | keyPairA = getKeyPairA(); 15 | keyPairsA = getKeyPairsA(); 16 | keyPairAPublicKey = sodium.to_base64(keyPairA.publicKey); 17 | }); 18 | 19 | test("should add the name to the user", async () => { 20 | const key = createKey(); 21 | const event = createChain(keyPairsA.sign, { 22 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 23 | }); 24 | const state = resolveState([event]); 25 | const encryptedState = encryptState( 26 | state, 27 | { members: { [keyPairAPublicKey]: { name: "Jane Doe" } } }, 28 | key, 29 | keyPairA 30 | ); 31 | 32 | const keys = { [key.keyId]: key.key }; 33 | // TODO order encyrpted states by the clock 34 | const result = resolveEncryptedState( 35 | state, 36 | [encryptedState], 37 | keys, 38 | keyPairsA.sign.publicKey 39 | ); 40 | expect(result.state.members).toMatchInlineSnapshot(` 41 | Object { 42 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 43 | "addedBy": Array [ 44 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 45 | ], 46 | "canAddMembers": true, 47 | "canRemoveMembers": true, 48 | "isAdmin": true, 49 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 50 | "name": "Jane Doe", 51 | "profileUpdatedBy": "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 52 | }, 53 | } 54 | `); 55 | }); 56 | 57 | test("should overwrite the name", async () => { 58 | const key = createKey(); 59 | const keys = { [key.keyId]: key.key }; 60 | const event = createChain(keyPairsA.sign, { 61 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 62 | }); 63 | const state = resolveState([event]); 64 | const encryptedState = encryptState( 65 | state, 66 | { members: { [keyPairAPublicKey]: { name: "Jane Doe" } } }, 67 | key, 68 | keyPairA 69 | ); 70 | const result2 = resolveEncryptedState( 71 | state, 72 | [encryptedState], 73 | keys, 74 | keyPairsA.sign.publicKey 75 | ); 76 | const encryptedState2 = encryptState( 77 | result2.state, 78 | { members: { [keyPairAPublicKey]: { name: "John Doe" } } }, 79 | key, 80 | keyPairA 81 | ); 82 | 83 | const result3 = resolveEncryptedState( 84 | result2.state, 85 | [encryptedState, encryptedState2], 86 | keys, 87 | keyPairsA.sign.publicKey 88 | ); 89 | expect(result3.state.members).toMatchInlineSnapshot(` 90 | Object { 91 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 92 | "addedBy": Array [ 93 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 94 | ], 95 | "canAddMembers": true, 96 | "canRemoveMembers": true, 97 | "isAdmin": true, 98 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 99 | "name": "John Doe", 100 | "profileUpdatedBy": "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 101 | }, 102 | } 103 | `); 104 | }); 105 | 106 | test("should fail in case of two encryptedState clocks are identical", async () => { 107 | const key = createKey(); 108 | const event = createChain(keyPairsA.sign, { 109 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 110 | }); 111 | const state = resolveState([event]); 112 | const encryptedState = encryptState( 113 | state, 114 | { members: { [keyPairAPublicKey]: { name: "Jane Doe" } } }, 115 | key, 116 | keyPairA 117 | ); 118 | const encryptedState2 = encryptState( 119 | state, 120 | { members: { [keyPairAPublicKey]: { name: "John Doe" } } }, 121 | key, 122 | keyPairA 123 | ); 124 | 125 | const keys = { [key.keyId]: key.key }; 126 | const resolve = () => 127 | resolveEncryptedState( 128 | state, 129 | [encryptedState2, encryptedState], 130 | keys, 131 | keyPairsA.sign.publicKey 132 | ); 133 | 134 | expect(resolve).toThrow(InvalidEncryptedStateError); 135 | expect(resolve).toThrow( 136 | "Identical clock values dedected for encrypted states." 137 | ); 138 | }); 139 | 140 | test("should order events by encryptedStateClock", async () => { 141 | const key = createKey(); 142 | const keys = { [key.keyId]: key.key }; 143 | const event = createChain(keyPairsA.sign, { 144 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 145 | }); 146 | const state = resolveState([event]); 147 | const encryptedState = encryptState( 148 | state, 149 | { members: { [keyPairAPublicKey]: { name: "Jane Doe" } } }, 150 | key, 151 | keyPairA 152 | ); 153 | const result2 = resolveEncryptedState( 154 | state, 155 | [encryptedState], 156 | keys, 157 | keyPairsA.sign.publicKey 158 | ); 159 | const encryptedState2 = encryptState( 160 | result2.state, 161 | { members: { [keyPairAPublicKey]: { name: "John Doe" } } }, 162 | key, 163 | keyPairA 164 | ); 165 | 166 | const result3 = resolveEncryptedState( 167 | result2.state, 168 | [encryptedState2, encryptedState], 169 | keys, 170 | keyPairsA.sign.publicKey 171 | ); 172 | expect(result3.state.members).toMatchInlineSnapshot(` 173 | Object { 174 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 175 | "addedBy": Array [ 176 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 177 | ], 178 | "canAddMembers": true, 179 | "canRemoveMembers": true, 180 | "isAdmin": true, 181 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 182 | "name": "John Doe", 183 | "profileUpdatedBy": "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 184 | }, 185 | } 186 | `); 187 | }); 188 | -------------------------------------------------------------------------------- /packages/trust-chain/src/applyEvent.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { 3 | TrustChainEvent, 4 | TrustChainState, 5 | DefaultTrustChainEvent, 6 | MemberProperties, 7 | } from "./types"; 8 | import { 9 | allAuthorsAreValidAdmins, 10 | areValidPermissions, 11 | getAdminCount, 12 | hashTransaction, 13 | isValidAdminDecision, 14 | } from "./utils"; 15 | import { InvalidTrustChainError } from "./errors"; 16 | 17 | export const applyEvent = ( 18 | state: TrustChainState, 19 | event: TrustChainEvent 20 | ): TrustChainState => { 21 | let members: { [publicKey: string]: MemberProperties } = { 22 | ...state.members, 23 | }; 24 | const hash = hashTransaction(event.transaction); 25 | 26 | event.authors.forEach((author) => { 27 | if ( 28 | !sodium.crypto_sign_verify_detached( 29 | sodium.from_base64(author.signature), 30 | `${state.lastEventHash}${hash}`, 31 | sodium.from_base64(author.publicKey) 32 | ) 33 | ) { 34 | throw new InvalidTrustChainError( 35 | `Invalid signature for ${author.publicKey}.` 36 | ); 37 | } 38 | }); 39 | 40 | const publicKeys = event.authors.map((author) => author.publicKey); 41 | const hasDuplicatedAuthors = publicKeys.some((publicKey, index) => { 42 | return publicKeys.indexOf(publicKey) != index; 43 | }); 44 | if (hasDuplicatedAuthors) { 45 | throw new InvalidTrustChainError("An author can sign the event only once."); 46 | } 47 | 48 | if (event.transaction.type === "create") { 49 | throw new InvalidTrustChainError("Only one create event is allowed."); 50 | } 51 | 52 | if (event.transaction.type === "add-member") { 53 | if (event.transaction.isAdmin === true) { 54 | if (!isValidAdminDecision(state, event as DefaultTrustChainEvent)) { 55 | throw new InvalidTrustChainError("Not allowed to add an admin."); 56 | } 57 | members[event.transaction.memberSigningPublicKey] = { 58 | lockboxPublicKey: event.transaction.memberLockboxPublicKey, 59 | isAdmin: true, 60 | canAddMembers: true, 61 | canRemoveMembers: true, 62 | addedBy: event.authors.map((author) => author.publicKey), 63 | }; 64 | } else { 65 | if (event.authors.length > 1) { 66 | // TODO add test for this 67 | throw new InvalidTrustChainError( 68 | "Only one author allowed when adding a non-admin member." 69 | ); 70 | } 71 | if ( 72 | !areValidPermissions( 73 | state, 74 | event as DefaultTrustChainEvent, 75 | "canAddMembers" 76 | ) 77 | ) { 78 | throw new InvalidTrustChainError("Not allowed to add a member."); 79 | } 80 | if ( 81 | event.transaction.canAddMembers === true && 82 | !allAuthorsAreValidAdmins(state, event as DefaultTrustChainEvent) 83 | ) { 84 | throw new InvalidTrustChainError( 85 | "Not allowed to add a member with canAddMembers." 86 | ); 87 | } 88 | if ( 89 | event.transaction.canRemoveMembers === true && 90 | !allAuthorsAreValidAdmins(state, event as DefaultTrustChainEvent) 91 | ) { 92 | throw new InvalidTrustChainError( 93 | "Not allowed to add a member with canRemoveMembers." 94 | ); 95 | } 96 | members[event.transaction.memberSigningPublicKey] = { 97 | lockboxPublicKey: event.transaction.memberLockboxPublicKey, 98 | isAdmin: false, 99 | canAddMembers: event.transaction.canAddMembers, 100 | canRemoveMembers: event.transaction.canRemoveMembers, 101 | addedBy: [event.authors[0].publicKey], 102 | }; 103 | } 104 | } 105 | 106 | if (event.transaction.type === "update-member") { 107 | if ( 108 | !state.members.hasOwnProperty(event.transaction.memberSigningPublicKey) 109 | ) { 110 | throw new InvalidTrustChainError("Failed to update non-existing member."); 111 | } 112 | if (!allAuthorsAreValidAdmins(state, event as DefaultTrustChainEvent)) { 113 | throw new InvalidTrustChainError("Not allowed to update a member."); 114 | } 115 | 116 | if ( 117 | state.members[event.transaction.memberSigningPublicKey].isAdmin && 118 | event.transaction.isAdmin === false && 119 | isValidAdminDecision(state, event as DefaultTrustChainEvent) 120 | ) { 121 | if (getAdminCount(state) <= 1) { 122 | throw new InvalidTrustChainError( 123 | "Not allowed to demote the last admin." 124 | ); 125 | } 126 | 127 | // demote the admin to a member 128 | members[event.transaction.memberSigningPublicKey] = { 129 | lockboxPublicKey: 130 | members[event.transaction.memberSigningPublicKey].lockboxPublicKey, 131 | isAdmin: false, 132 | canAddMembers: event.transaction.canAddMembers, 133 | canRemoveMembers: event.transaction.canRemoveMembers, 134 | addedBy: members[event.transaction.memberSigningPublicKey].addedBy, 135 | }; 136 | } else if ( 137 | !state.members[event.transaction.memberSigningPublicKey].isAdmin && 138 | event.transaction.isAdmin === true && 139 | isValidAdminDecision(state, event as DefaultTrustChainEvent) 140 | ) { 141 | // promote the member to an admin 142 | members[event.transaction.memberSigningPublicKey] = { 143 | lockboxPublicKey: 144 | members[event.transaction.memberSigningPublicKey].lockboxPublicKey, 145 | isAdmin: true, 146 | canAddMembers: true, 147 | canRemoveMembers: true, 148 | addedBy: members[event.transaction.memberSigningPublicKey].addedBy, 149 | }; 150 | } else if ( 151 | !state.members[event.transaction.memberSigningPublicKey].isAdmin && 152 | (state.members[event.transaction.memberSigningPublicKey].canAddMembers !== 153 | event.transaction.canAddMembers || 154 | state.members[event.transaction.memberSigningPublicKey] 155 | .canRemoveMembers !== event.transaction.canRemoveMembers) 156 | ) { 157 | // promote the member to an admin 158 | members[event.transaction.memberSigningPublicKey] = { 159 | lockboxPublicKey: 160 | members[event.transaction.memberSigningPublicKey].lockboxPublicKey, 161 | isAdmin: false, 162 | canAddMembers: event.transaction.canAddMembers, 163 | canRemoveMembers: event.transaction.canRemoveMembers, 164 | addedBy: members[event.transaction.memberSigningPublicKey].addedBy, 165 | }; 166 | } else { 167 | throw new InvalidTrustChainError("Not allowed member update."); 168 | } 169 | } 170 | 171 | if (event.transaction.type === "remove-member") { 172 | if ( 173 | !state.members.hasOwnProperty(event.transaction.memberSigningPublicKey) 174 | ) { 175 | throw new InvalidTrustChainError("Failed to remove non-existing member."); 176 | } 177 | if (state.members[event.transaction.memberSigningPublicKey].isAdmin) { 178 | if (!isValidAdminDecision(state, event as DefaultTrustChainEvent)) { 179 | throw new InvalidTrustChainError("Not allowed to remove an admin."); 180 | } 181 | if (Object.keys(members).length <= 1) { 182 | throw new InvalidTrustChainError("Not allowed to remove last member."); 183 | } 184 | if (getAdminCount(state) <= 1) { 185 | throw new InvalidTrustChainError( 186 | "Not allowed to remove the last admin." 187 | ); 188 | } 189 | delete members[event.transaction.memberSigningPublicKey]; 190 | } else { 191 | if ( 192 | !areValidPermissions( 193 | state, 194 | event as DefaultTrustChainEvent, 195 | "canRemoveMembers" 196 | ) 197 | ) { 198 | throw new InvalidTrustChainError("Not allowed to remove a member."); 199 | } 200 | if (Object.keys(members).length <= 1) { 201 | throw new InvalidTrustChainError("Not allowed to remove last member."); 202 | } 203 | delete members[event.transaction.memberSigningPublicKey]; 204 | } 205 | } 206 | 207 | return { 208 | id: state.id, 209 | members, 210 | lastEventHash: hash, 211 | trustChainVersion: 1, 212 | encryptedStateClock: state.encryptedStateClock, 213 | }; 214 | }; 215 | -------------------------------------------------------------------------------- /packages/trust-chain/src/resolveState.addMember.test.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { addAuthorToEvent } from "./addAuthorToEvent"; 3 | import { InvalidTrustChainError } from "./errors"; 4 | import { createChain, resolveState, addMember, removeMember } from "./index"; 5 | import { 6 | getKeyPairA, 7 | getKeyPairB, 8 | getKeyPairsA, 9 | getKeyPairsB, 10 | getKeyPairsC, 11 | KeyPairs, 12 | } from "./testUtils"; 13 | import { hashTransaction } from "./utils"; 14 | 15 | let keyPairA: sodium.KeyPair = null; 16 | let keyPairsA: KeyPairs = null; 17 | let keyPairB: sodium.KeyPair = null; 18 | let keyPairsB: KeyPairs = null; 19 | let keyPairsC: KeyPairs = null; 20 | 21 | beforeAll(async () => { 22 | await sodium.ready; 23 | keyPairA = getKeyPairA(); 24 | keyPairsA = getKeyPairsA(); 25 | keyPairB = getKeyPairB(); 26 | keyPairsB = getKeyPairsB(); 27 | keyPairsC = getKeyPairsC(); 28 | }); 29 | 30 | test("should be able to add a member as member with the permission canAddMember", async () => { 31 | const createEvent = createChain(keyPairsA.sign, { 32 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 33 | }); 34 | const addMemberEvent = addMember( 35 | hashTransaction(createEvent.transaction), 36 | keyPairA, 37 | keyPairsB.sign.publicKey, 38 | keyPairsB.box.publicKey, 39 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 40 | ); 41 | const addMemberEvent2 = addMember( 42 | hashTransaction(addMemberEvent.transaction), 43 | keyPairB, 44 | keyPairsC.sign.publicKey, 45 | keyPairsC.box.publicKey, 46 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 47 | ); 48 | const state = resolveState([createEvent, addMemberEvent, addMemberEvent2]); 49 | expect(state.members).toMatchInlineSnapshot(` 50 | Object { 51 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 52 | "addedBy": Array [ 53 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 54 | ], 55 | "canAddMembers": true, 56 | "canRemoveMembers": true, 57 | "isAdmin": true, 58 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 59 | }, 60 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 61 | "addedBy": Array [ 62 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 63 | ], 64 | "canAddMembers": true, 65 | "canRemoveMembers": false, 66 | "isAdmin": false, 67 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 68 | }, 69 | "ZKcwjAMAaSiq7k3MQVQUZ6aa7kBreK__5hkGI4SCltk": Object { 70 | "addedBy": Array [ 71 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY", 72 | ], 73 | "canAddMembers": false, 74 | "canRemoveMembers": false, 75 | "isAdmin": false, 76 | "lockboxPublicKey": "0hUuO22MoTa8X65ZvpR9KcfUwF_B2aIvLORPjuaofBg", 77 | }, 78 | } 79 | `); 80 | }); 81 | 82 | test("should not be able to add a member as member without the permission canAddMember", async () => { 83 | const createEvent = createChain(keyPairsA.sign, { 84 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 85 | }); 86 | const addMemberEvent = addMember( 87 | hashTransaction(createEvent.transaction), 88 | keyPairA, 89 | keyPairsB.sign.publicKey, 90 | keyPairsB.box.publicKey, 91 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 92 | ); 93 | const addMemberEvent2 = addMember( 94 | hashTransaction(addMemberEvent.transaction), 95 | keyPairB, 96 | keyPairsC.sign.publicKey, 97 | keyPairsC.box.publicKey, 98 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 99 | ); 100 | const chain = [createEvent, addMemberEvent, addMemberEvent2]; 101 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 102 | expect(() => resolveState(chain)).toThrow("Not allowed to add a member."); 103 | }); 104 | 105 | test("should not be able to add an admin as member with the permission canAddMember", async () => { 106 | const createEvent = createChain(keyPairsA.sign, { 107 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 108 | }); 109 | const addMemberEvent = addMember( 110 | hashTransaction(createEvent.transaction), 111 | keyPairA, 112 | keyPairsB.sign.publicKey, 113 | keyPairsB.box.publicKey, 114 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 115 | ); 116 | const addMemberEvent2 = addMember( 117 | hashTransaction(addMemberEvent.transaction), 118 | keyPairB, 119 | keyPairsC.sign.publicKey, 120 | keyPairsC.box.publicKey, 121 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 122 | ); 123 | const chain = [createEvent, addMemberEvent, addMemberEvent2]; 124 | 125 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 126 | expect(() => resolveState(chain)).toThrow("Not allowed to add an admin."); 127 | }); 128 | 129 | test("should not be able to add a member with canAddMember as member with the permission canAddMember", async () => { 130 | const createEvent = createChain(keyPairsA.sign, { 131 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 132 | }); 133 | const addMemberEvent = addMember( 134 | hashTransaction(createEvent.transaction), 135 | keyPairA, 136 | keyPairsB.sign.publicKey, 137 | keyPairsB.box.publicKey, 138 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 139 | ); 140 | const addMemberEvent2 = addMember( 141 | hashTransaction(addMemberEvent.transaction), 142 | keyPairB, 143 | keyPairsC.sign.publicKey, 144 | keyPairsC.box.publicKey, 145 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 146 | ); 147 | const chain = [createEvent, addMemberEvent, addMemberEvent2]; 148 | 149 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 150 | expect(() => resolveState(chain)).toThrow( 151 | "Not allowed to add a member with canAddMembers." 152 | ); 153 | }); 154 | 155 | test("should be able to add an admin as admins", async () => { 156 | const createEvent = createChain(keyPairsA.sign, { 157 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 158 | }); 159 | const addAdminEvent = addMember( 160 | hashTransaction(createEvent.transaction), 161 | keyPairA, 162 | keyPairsB.sign.publicKey, 163 | keyPairsB.box.publicKey, 164 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 165 | ); 166 | const addAdminEvent2 = addMember( 167 | hashTransaction(addAdminEvent.transaction), 168 | keyPairA, 169 | keyPairsC.sign.publicKey, 170 | keyPairsC.box.publicKey, 171 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 172 | ); 173 | const addAdminEvent3 = addAuthorToEvent(addAdminEvent2, keyPairB); 174 | const state = resolveState([createEvent, addAdminEvent, addAdminEvent3]); 175 | expect(state.members).toMatchInlineSnapshot(` 176 | Object { 177 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 178 | "addedBy": Array [ 179 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 180 | ], 181 | "canAddMembers": true, 182 | "canRemoveMembers": true, 183 | "isAdmin": true, 184 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 185 | }, 186 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 187 | "addedBy": Array [ 188 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 189 | ], 190 | "canAddMembers": true, 191 | "canRemoveMembers": true, 192 | "isAdmin": true, 193 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 194 | }, 195 | "ZKcwjAMAaSiq7k3MQVQUZ6aa7kBreK__5hkGI4SCltk": Object { 196 | "addedBy": Array [ 197 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 198 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY", 199 | ], 200 | "canAddMembers": true, 201 | "canRemoveMembers": true, 202 | "isAdmin": true, 203 | "lockboxPublicKey": "0hUuO22MoTa8X65ZvpR9KcfUwF_B2aIvLORPjuaofBg", 204 | }, 205 | } 206 | `); 207 | }); 208 | 209 | test("should not be able to add an admin if no more than 50% of admins signed the transaction", async () => { 210 | const createEvent = createChain(keyPairsA.sign, { 211 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 212 | }); 213 | const addAdminEvent = addMember( 214 | hashTransaction(createEvent.transaction), 215 | keyPairA, 216 | keyPairsB.sign.publicKey, 217 | keyPairsB.box.publicKey, 218 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 219 | ); 220 | const addAdminEvent2 = addMember( 221 | hashTransaction(addAdminEvent.transaction), 222 | keyPairB, 223 | keyPairsC.sign.publicKey, 224 | keyPairsC.box.publicKey, 225 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 226 | ); 227 | const chain = [createEvent, addAdminEvent, addAdminEvent2]; 228 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 229 | expect(() => resolveState(chain)).toThrow("Not allowed to add an admin."); 230 | }); 231 | 232 | test("should not be able to add the same admin twice as author", async () => { 233 | const createEvent = createChain(keyPairsA.sign, { 234 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 235 | }); 236 | const addAdminEvent = addMember( 237 | hashTransaction(createEvent.transaction), 238 | keyPairA, 239 | keyPairsB.sign.publicKey, 240 | keyPairsB.box.publicKey, 241 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 242 | ); 243 | const addAdminEvent2 = addMember( 244 | hashTransaction(addAdminEvent.transaction), 245 | keyPairA, 246 | keyPairsC.sign.publicKey, 247 | keyPairsC.box.publicKey, 248 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 249 | ); 250 | const addAdminEvent3 = addAuthorToEvent(addAdminEvent2, keyPairA); 251 | const chain = [createEvent, addAdminEvent, addAdminEvent3]; 252 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 253 | expect(() => resolveState(chain)).toThrow( 254 | "An author can sign the event only once." 255 | ); 256 | }); 257 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 Nikolaus Graf 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /packages/trust-chain/src/resolveState.removeMember.test.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { addAuthorToEvent } from "./addAuthorToEvent"; 3 | import { InvalidTrustChainError } from "./errors"; 4 | import { createChain, resolveState, addMember, removeMember } from "./index"; 5 | import { 6 | getKeyPairA, 7 | getKeyPairB, 8 | getKeyPairC, 9 | getKeyPairsA, 10 | getKeyPairsB, 11 | getKeyPairsC, 12 | KeyPairs, 13 | } from "./testUtils"; 14 | import { hashTransaction } from "./utils"; 15 | 16 | let keyPairA: sodium.KeyPair = null; 17 | let keyPairsA: KeyPairs = null; 18 | let keyPairB: sodium.KeyPair = null; 19 | let keyPairC: sodium.KeyPair = null; 20 | let keyPairsB: KeyPairs = null; 21 | let keyPairsC: KeyPairs = null; 22 | 23 | beforeAll(async () => { 24 | await sodium.ready; 25 | keyPairA = getKeyPairA(); 26 | keyPairsA = getKeyPairsA(); 27 | keyPairB = getKeyPairB(); 28 | keyPairsB = getKeyPairsB(); 29 | keyPairC = getKeyPairC(); 30 | keyPairsC = getKeyPairsC(); 31 | }); 32 | 33 | test("should be able to remove a member as member with the permission canRemoveMember", async () => { 34 | const createEvent = createChain(keyPairsA.sign, { 35 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 36 | }); 37 | const addMemberEvent = addMember( 38 | hashTransaction(createEvent.transaction), 39 | keyPairA, 40 | keyPairsB.sign.publicKey, 41 | keyPairsB.box.publicKey, 42 | { isAdmin: false, canAddMembers: false, canRemoveMembers: true } 43 | ); 44 | const addMemberEvent2 = addMember( 45 | hashTransaction(addMemberEvent.transaction), 46 | keyPairA, 47 | keyPairsC.sign.publicKey, 48 | keyPairsC.box.publicKey, 49 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 50 | ); 51 | const removeMemberEvent = removeMember( 52 | hashTransaction(addMemberEvent2.transaction), 53 | keyPairB, 54 | sodium.to_base64(keyPairC.publicKey) 55 | ); 56 | const state = resolveState([ 57 | createEvent, 58 | addMemberEvent, 59 | addMemberEvent2, 60 | removeMemberEvent, 61 | ]); 62 | expect(state.members).toMatchInlineSnapshot(` 63 | Object { 64 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 65 | "addedBy": Array [ 66 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 67 | ], 68 | "canAddMembers": true, 69 | "canRemoveMembers": true, 70 | "isAdmin": true, 71 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 72 | }, 73 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 74 | "addedBy": Array [ 75 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 76 | ], 77 | "canAddMembers": false, 78 | "canRemoveMembers": true, 79 | "isAdmin": false, 80 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 81 | }, 82 | } 83 | `); 84 | }); 85 | 86 | test("should not be able to remove a member as member without the permission canRemoveMember", async () => { 87 | const createEvent = createChain(keyPairsA.sign, { 88 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 89 | }); 90 | const addMemberEvent = addMember( 91 | hashTransaction(createEvent.transaction), 92 | keyPairA, 93 | keyPairsB.sign.publicKey, 94 | keyPairsB.box.publicKey, 95 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 96 | ); 97 | const addMemberEvent2 = addMember( 98 | hashTransaction(addMemberEvent.transaction), 99 | keyPairA, 100 | keyPairsC.sign.publicKey, 101 | keyPairsC.box.publicKey, 102 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 103 | ); 104 | const removeMemberEvent = removeMember( 105 | hashTransaction(addMemberEvent2.transaction), 106 | keyPairB, 107 | sodium.to_base64(keyPairC.publicKey) 108 | ); 109 | const chain = [ 110 | createEvent, 111 | addMemberEvent, 112 | addMemberEvent2, 113 | removeMemberEvent, 114 | ]; 115 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 116 | expect(() => resolveState(chain)).toThrow("Not allowed to remove a member."); 117 | }); 118 | 119 | test("should not be able to remove an admin as member without the permission canRemoveMember", async () => { 120 | const createEvent = createChain(keyPairsA.sign, { 121 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 122 | }); 123 | const addMemberEvent = addMember( 124 | hashTransaction(createEvent.transaction), 125 | keyPairA, 126 | keyPairsB.sign.publicKey, 127 | keyPairsB.box.publicKey, 128 | { isAdmin: false, canAddMembers: false, canRemoveMembers: true } 129 | ); 130 | const removeMemberEvent = removeMember( 131 | hashTransaction(addMemberEvent.transaction), 132 | keyPairB, 133 | sodium.to_base64(keyPairA.publicKey) 134 | ); 135 | const chain = [createEvent, addMemberEvent, removeMemberEvent]; 136 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 137 | expect(() => resolveState(chain)).toThrow("Not allowed to remove an admin."); 138 | }); 139 | 140 | test("should be able to remove a member as admin", async () => { 141 | const createEvent = createChain(keyPairsA.sign, { 142 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 143 | }); 144 | const addMemberEvent = addMember( 145 | hashTransaction(createEvent.transaction), 146 | keyPairA, 147 | keyPairsB.sign.publicKey, 148 | keyPairsB.box.publicKey, 149 | { isAdmin: false, canAddMembers: false, canRemoveMembers: true } 150 | ); 151 | const removeMemberEvent = removeMember( 152 | hashTransaction(addMemberEvent.transaction), 153 | keyPairA, 154 | sodium.to_base64(keyPairB.publicKey) 155 | ); 156 | const state = resolveState([createEvent, addMemberEvent, removeMemberEvent]); 157 | expect(state.members).toMatchInlineSnapshot(` 158 | Object { 159 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 160 | "addedBy": Array [ 161 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 162 | ], 163 | "canAddMembers": true, 164 | "canRemoveMembers": true, 165 | "isAdmin": true, 166 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 167 | }, 168 | } 169 | `); 170 | }); 171 | 172 | test("should not be able to remove the last admin", async () => { 173 | const createEvent = createChain(keyPairsA.sign, { 174 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 175 | }); 176 | const addMemberEvent = addMember( 177 | hashTransaction(createEvent.transaction), 178 | keyPairA, 179 | keyPairsB.sign.publicKey, 180 | keyPairsB.box.publicKey, 181 | { isAdmin: false, canAddMembers: false, canRemoveMembers: true } 182 | ); 183 | const removeMemberEvent = removeMember( 184 | hashTransaction(addMemberEvent.transaction), 185 | keyPairA, 186 | sodium.to_base64(keyPairA.publicKey) 187 | ); 188 | const chain = [createEvent, addMemberEvent, removeMemberEvent]; 189 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 190 | expect(() => resolveState(chain)).toThrow( 191 | "Not allowed to remove the last admin." 192 | ); 193 | }); 194 | 195 | test("should be able to remove an admin as admin", async () => { 196 | const createEvent = createChain(keyPairsA.sign, { 197 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 198 | }); 199 | const addAdminEvent = addMember( 200 | hashTransaction(createEvent.transaction), 201 | keyPairA, 202 | keyPairsB.sign.publicKey, 203 | keyPairsB.box.publicKey, 204 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 205 | ); 206 | const addAdminEvent2 = addMember( 207 | hashTransaction(addAdminEvent.transaction), 208 | keyPairA, 209 | keyPairsC.sign.publicKey, 210 | keyPairsC.box.publicKey, 211 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 212 | ); 213 | const addAdminEvent3 = addAuthorToEvent(addAdminEvent2, keyPairB); 214 | const removeAdminEvent = removeMember( 215 | hashTransaction(addAdminEvent3.transaction), 216 | keyPairB, 217 | sodium.to_base64(keyPairA.publicKey) 218 | ); 219 | const removeAdminEvent2 = addAuthorToEvent(removeAdminEvent, keyPairC); 220 | const removeAdminEvent3 = removeMember( 221 | hashTransaction(removeAdminEvent2.transaction), 222 | keyPairB, 223 | sodium.to_base64(keyPairC.publicKey) 224 | ); 225 | const removeAdminEvent4 = addAuthorToEvent(removeAdminEvent3, keyPairC); 226 | const state = resolveState([ 227 | createEvent, 228 | addAdminEvent, 229 | addAdminEvent3, 230 | removeAdminEvent2, 231 | removeAdminEvent4, 232 | ]); 233 | expect(state.members).toMatchInlineSnapshot(` 234 | Object { 235 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 236 | "addedBy": Array [ 237 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 238 | ], 239 | "canAddMembers": true, 240 | "canRemoveMembers": true, 241 | "isAdmin": true, 242 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 243 | }, 244 | } 245 | `); 246 | }); 247 | 248 | test("should not be able to remove an admin if no more than 50% of admins signed the transaction", async () => { 249 | const createEvent = createChain(keyPairsA.sign, { 250 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 251 | }); 252 | const addAdminEvent = addMember( 253 | hashTransaction(createEvent.transaction), 254 | keyPairA, 255 | keyPairsB.sign.publicKey, 256 | keyPairsB.box.publicKey, 257 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 258 | ); 259 | const addAdminEvent2 = addMember( 260 | hashTransaction(addAdminEvent.transaction), 261 | keyPairA, 262 | keyPairsC.sign.publicKey, 263 | keyPairsC.box.publicKey, 264 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 265 | ); 266 | const addAdminEvent3 = addAuthorToEvent(addAdminEvent2, keyPairB); 267 | const removeAdminEvent = removeMember( 268 | hashTransaction(addAdminEvent3.transaction), 269 | keyPairB, 270 | sodium.to_base64(keyPairA.publicKey) 271 | ); 272 | const chain = [createEvent, addAdminEvent, addAdminEvent3, removeAdminEvent]; 273 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 274 | expect(() => resolveState(chain)).toThrow("Not allowed to remove an admin."); 275 | }); 276 | 277 | test("should throw in case the member does not exist", async () => { 278 | const createEvent = createChain(keyPairsA.sign, { 279 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 280 | }); 281 | const removeMemberEvent = removeMember( 282 | hashTransaction(createEvent.transaction), 283 | keyPairB, 284 | sodium.to_base64(keyPairB.publicKey) 285 | ); 286 | const chain = [createEvent, removeMemberEvent]; 287 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 288 | expect(() => resolveState(chain)).toThrow( 289 | "Failed to remove non-existing member." 290 | ); 291 | }); 292 | 293 | test("should not be able to remove the last member", async () => { 294 | const createEvent = createChain(keyPairsA.sign, { 295 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 296 | }); 297 | const eventRemoveMember = removeMember( 298 | hashTransaction(createEvent.transaction), 299 | keyPairA, 300 | sodium.to_base64(keyPairA.publicKey) 301 | ); 302 | expect(() => resolveState([createEvent, eventRemoveMember])).toThrow( 303 | InvalidTrustChainError 304 | ); 305 | expect(() => resolveState([createEvent, eventRemoveMember])).toThrow( 306 | "Not allowed to remove last member." 307 | ); 308 | }); 309 | -------------------------------------------------------------------------------- /demos/backend/src/graphql/Mutation.ts: -------------------------------------------------------------------------------- 1 | import { objectType, inputObjectType, arg, mutationField } from "nexus"; 2 | import { createOrganization as createOrganizationDb } from "../database/createOrganization"; 3 | import { createUser as createUserDb } from "../database/createUser"; 4 | import { addMemberToOrganization as addMemberToOrganizationDb } from "../database/addMemberToOrganization"; 5 | import { removeMemberFromOrganization as removeMemberFromOrganizationDb } from "../database/removeMemberFromOrganization"; 6 | import { updateOrganizationMember as updateOrganizationMemberDb } from "../database/updateOrganizationMember"; 7 | import { addEventProposalToOrganization as addEventProposalToOrganizationDb } from "../database/addEventProposalToOrganization"; 8 | import { updateOrganizationEventProposal as updateOrganizationEventProposalDb } from "../database/updateOrganizationEventProposal"; 9 | import { deleteOrganizationEventProposal as deleteOrganizationEventProposalDb } from "../database/deleteOrganizationEventProposal"; 10 | import { requestAuthenticationChallenge as requestAuthenticationChallengeDb } from "../database/requestAuthenticationChallenge"; 11 | import { authenticate as authenticateDb } from "../database/authenticate"; 12 | 13 | export const CreateOrganizationInput = inputObjectType({ 14 | name: "CreateOrganizationInput", 15 | definition(t) { 16 | t.nonNull.string("event"); 17 | t.nonNull.string("keyId"); 18 | t.nonNull.string("lockboxes"); 19 | t.nonNull.string("encryptedState"); 20 | }, 21 | }); 22 | 23 | export const CreateOrganizationResult = objectType({ 24 | name: "CreateOrganizationResult", 25 | definition(t) { 26 | t.boolean("success"); 27 | }, 28 | }); 29 | 30 | export const createOrganization = mutationField("createOrganization", { 31 | type: CreateOrganizationResult, 32 | args: { 33 | input: arg({ 34 | type: CreateOrganizationInput, 35 | }), 36 | }, 37 | async resolve(root, args, ctx) { 38 | await createOrganizationDb( 39 | ctx.session, 40 | JSON.parse(args.input.event), 41 | args.input.keyId, 42 | JSON.parse(args.input.lockboxes), 43 | JSON.parse(args.input.encryptedState) 44 | ); 45 | 46 | return { success: true }; 47 | }, 48 | }); 49 | 50 | export const CreateUserInput = inputObjectType({ 51 | name: "CreateUserInput", 52 | definition(t) { 53 | t.nonNull.string("signingPublicKey"); 54 | }, 55 | }); 56 | 57 | export const CreateUserResult = objectType({ 58 | name: "CreateUserResult", 59 | definition(t) { 60 | t.boolean("success"); 61 | }, 62 | }); 63 | 64 | export const createUser = mutationField("createUser", { 65 | type: CreateUserResult, 66 | args: { 67 | input: arg({ 68 | type: CreateUserInput, 69 | }), 70 | }, 71 | async resolve(root, args, ctx) { 72 | await createUserDb(args.input.signingPublicKey); 73 | return { success: true }; 74 | }, 75 | }); 76 | 77 | export const AddMemberToOrganizationInput = inputObjectType({ 78 | name: "AddMemberToOrganizationInput", 79 | definition(t) { 80 | t.nonNull.string("organizationId"); 81 | t.nonNull.string("event"); 82 | t.nonNull.string("keyId"); 83 | t.nonNull.string("lockbox"); 84 | t.nonNull.string("encryptedState"); 85 | t.string("eventProposalId"); 86 | }, 87 | }); 88 | 89 | export const AddMemberToOrganizationResult = objectType({ 90 | name: "AddMemberToOrganizationResult", 91 | definition(t) { 92 | t.boolean("success"); 93 | }, 94 | }); 95 | 96 | export const addMemberToOrganization = mutationField( 97 | "addMemberToOrganization", 98 | { 99 | type: AddMemberToOrganizationResult, 100 | args: { 101 | input: arg({ 102 | type: AddMemberToOrganizationInput, 103 | }), 104 | }, 105 | async resolve(root, args, ctx) { 106 | await addMemberToOrganizationDb( 107 | ctx.session, 108 | args.input.organizationId, 109 | JSON.parse(args.input.event), 110 | args.input.keyId, 111 | JSON.parse(args.input.lockbox), 112 | JSON.parse(args.input.encryptedState), 113 | args.input.eventProposalId 114 | ); 115 | return { success: true }; 116 | }, 117 | } 118 | ); 119 | 120 | export const RemoveMemberFromOrganizationInput = inputObjectType({ 121 | name: "RemoveMemberFromOrganizationInput", 122 | definition(t) { 123 | t.nonNull.string("organizationId"); 124 | t.nonNull.string("event"); 125 | t.nonNull.string("keyId"); 126 | t.nonNull.string("lockboxes"); 127 | t.nonNull.string("encryptedState"); 128 | t.string("eventProposalId"); 129 | }, 130 | }); 131 | 132 | export const RemoveMemberFromOrganizationResult = objectType({ 133 | name: "RemoveMemberFromOrganizationResult", 134 | definition(t) { 135 | t.boolean("success"); 136 | }, 137 | }); 138 | 139 | export const removeMemberFromOrganization = mutationField( 140 | "removeMemberFromOrganization", 141 | { 142 | type: RemoveMemberFromOrganizationResult, 143 | args: { 144 | input: arg({ 145 | type: RemoveMemberFromOrganizationInput, 146 | }), 147 | }, 148 | async resolve(root, args, ctx) { 149 | await removeMemberFromOrganizationDb( 150 | ctx.session, 151 | args.input.organizationId, 152 | JSON.parse(args.input.event), 153 | args.input.keyId, 154 | JSON.parse(args.input.lockboxes), 155 | JSON.parse(args.input.encryptedState), 156 | args.input.eventProposalId 157 | ); 158 | return { success: true }; 159 | }, 160 | } 161 | ); 162 | 163 | export const UpdateOrganizationMemberInput = inputObjectType({ 164 | name: "UpdateOrganizationMemberInput", 165 | definition(t) { 166 | t.nonNull.string("organizationId"); 167 | t.nonNull.string("event"); 168 | t.string("eventProposalId"); 169 | }, 170 | }); 171 | 172 | export const UpdateOrganizationMemberResult = objectType({ 173 | name: "UpdateOrganizationMemberResult", 174 | definition(t) { 175 | t.boolean("success"); 176 | }, 177 | }); 178 | 179 | export const updateOrganizationMember = mutationField( 180 | "updateOrganizationMember", 181 | { 182 | type: UpdateOrganizationMemberResult, 183 | args: { 184 | input: arg({ 185 | type: UpdateOrganizationMemberInput, 186 | }), 187 | }, 188 | async resolve(root, args, ctx) { 189 | await updateOrganizationMemberDb( 190 | ctx.session, 191 | args.input.organizationId, 192 | JSON.parse(args.input.event), 193 | args.input.eventProposalId 194 | ); 195 | return { success: true }; 196 | }, 197 | } 198 | ); 199 | 200 | export const AddEventProposalToOrganizationInput = inputObjectType({ 201 | name: "AddEventProposalToOrganizationInput", 202 | definition(t) { 203 | t.nonNull.string("organizationId"); 204 | t.nonNull.string("event"); 205 | }, 206 | }); 207 | 208 | export const AddEventProposalToOrganizationResult = objectType({ 209 | name: "AddEventProposalToOrganizationResult", 210 | definition(t) { 211 | t.boolean("success"); 212 | }, 213 | }); 214 | 215 | export const addEventProposalToOrganization = mutationField( 216 | "addEventProposalToOrganization", 217 | { 218 | type: AddEventProposalToOrganizationResult, 219 | args: { 220 | input: arg({ 221 | type: AddEventProposalToOrganizationInput, 222 | }), 223 | }, 224 | async resolve(root, args, ctx) { 225 | await addEventProposalToOrganizationDb( 226 | ctx.session, 227 | args.input.organizationId, 228 | JSON.parse(args.input.event) 229 | ); 230 | return { success: true }; 231 | }, 232 | } 233 | ); 234 | 235 | export const UpdateOrganizationEventProposalInput = inputObjectType({ 236 | name: "UpdateOrganizationEventProposalInput", 237 | definition(t) { 238 | t.nonNull.string("eventProposalId"); 239 | t.nonNull.string("event"); 240 | }, 241 | }); 242 | 243 | export const UpdateOrganizationEventProposalResult = objectType({ 244 | name: "UpdateOrganizationEventProposalResult", 245 | definition(t) { 246 | t.boolean("success"); 247 | }, 248 | }); 249 | 250 | export const updateOrganizationEventProposal = mutationField( 251 | "updateOrganizationEventProposal", 252 | { 253 | type: UpdateOrganizationEventProposalResult, 254 | args: { 255 | input: arg({ 256 | type: UpdateOrganizationEventProposalInput, 257 | }), 258 | }, 259 | async resolve(root, args, ctx) { 260 | await updateOrganizationEventProposalDb( 261 | ctx.session, 262 | args.input.eventProposalId, 263 | JSON.parse(args.input.event) 264 | ); 265 | return { success: true }; 266 | }, 267 | } 268 | ); 269 | 270 | export const DeleteOrganizationEventProposalInput = inputObjectType({ 271 | name: "DeleteOrganizationEventProposalInput", 272 | definition(t) { 273 | t.nonNull.string("eventProposalId"); 274 | }, 275 | }); 276 | 277 | export const DeleteOrganizationEventProposalResult = objectType({ 278 | name: "DeleteOrganizationEventProposalResult", 279 | definition(t) { 280 | t.boolean("success"); 281 | }, 282 | }); 283 | 284 | export const deleteOrganizationEventProposal = mutationField( 285 | "deleteOrganizationEventProposal", 286 | { 287 | type: DeleteOrganizationEventProposalResult, 288 | args: { 289 | input: arg({ 290 | type: DeleteOrganizationEventProposalInput, 291 | }), 292 | }, 293 | async resolve(root, args, ctx) { 294 | await deleteOrganizationEventProposalDb( 295 | ctx.session, 296 | args.input.eventProposalId 297 | ); 298 | return { success: true }; 299 | }, 300 | } 301 | ); 302 | 303 | export const RequestAuthenticationChallengeInput = inputObjectType({ 304 | name: "RequestAuthenticationChallengeInput", 305 | definition(t) { 306 | t.nonNull.string("signingPublicKey"); 307 | }, 308 | }); 309 | 310 | export const RequestAuthenticationChallengeResult = objectType({ 311 | name: "RequestAuthenticationChallengeResult", 312 | definition(t) { 313 | t.string("nonce"); 314 | }, 315 | }); 316 | 317 | export const requestAuthenticationChallenge = mutationField( 318 | "requestAuthenticationChallenge", 319 | { 320 | type: RequestAuthenticationChallengeResult, 321 | args: { 322 | input: arg({ 323 | type: RequestAuthenticationChallengeInput, 324 | }), 325 | }, 326 | async resolve(root, args, ctx) { 327 | return await requestAuthenticationChallengeDb( 328 | args.input.signingPublicKey 329 | ); 330 | }, 331 | } 332 | ); 333 | 334 | export const AuthenticateInput = inputObjectType({ 335 | name: "AuthenticateInput", 336 | definition(t) { 337 | t.nonNull.string("signingPublicKey"); 338 | t.nonNull.string("nonce"); 339 | t.nonNull.string("nonceSignature"); 340 | }, 341 | }); 342 | 343 | export const AuthenticateResult = objectType({ 344 | name: "AuthenticateResult", 345 | definition(t) { 346 | t.boolean("success"); 347 | }, 348 | }); 349 | 350 | export const authenticate = mutationField("authenticate", { 351 | type: AuthenticateResult, 352 | args: { 353 | input: arg({ 354 | type: AuthenticateInput, 355 | }), 356 | }, 357 | async resolve(root, args, ctx) { 358 | return await authenticateDb( 359 | args.input.signingPublicKey, 360 | args.input.nonce, 361 | args.input.nonceSignature, 362 | ctx.session 363 | ); 364 | }, 365 | }); 366 | 367 | export const LogoutResult = objectType({ 368 | name: "LogoutResult", 369 | definition(t) { 370 | t.boolean("success"); 371 | }, 372 | }); 373 | 374 | export const logout = mutationField("logout", { 375 | type: LogoutResult, 376 | async resolve(root, args, ctx) { 377 | delete ctx.session.userSigningPublicKey; 378 | 379 | return { 380 | success: true, 381 | }; 382 | }, 383 | }); 384 | -------------------------------------------------------------------------------- /packages/trust-chain/README.md: -------------------------------------------------------------------------------- 1 | ## Trust Chain 2 | 3 | A cryptographically verifyable chain of events to determine a list team members and encrypted data e.g. usernames only accessible to the members. 4 | 5 | ## Goal 6 | 7 | The goal of this project is to allow a group of participants (organization) to exchange data without the content being revealed to anyone except the organization members. 8 | 9 | The project is inspired by web of trust and blockchains to aim for the following behaviour: 10 | 11 | - Enable asynchronous exchange of data (participants don't have to be online at the same time) 12 | - Only one verification is necessary to establish trust between everyone in the organization 13 | - Organization access to a member can be revoked instantly 14 | - An organization can't be manipulated in hindsight 15 | - "Efficiently" be able download the current state by a client (e.g. not running a full blockchain node) 16 | 17 | ## High Level Architecture 18 | 19 | To achieve the defined goals this project relies on a central service in combination with asymmetric cryptography. 20 | 21 | The current state of an organization can be constructed from a series of events (chain) and multiple encrypted state entries (encrypted state). 22 | 23 | ### Chain 24 | 25 | The purpose of the chain is determine who is part of the organization and what permissions does this member have. 26 | 27 | The central service as well as the members must have full access to this information. 28 | 29 | ### EncryptedState 30 | 31 | The purpose of the encrypted-state is contain information like usernames to hide away from the central service. Only members have access to the content of the encrypted state. 32 | 33 | ## Design Decisions 34 | 35 | ### Why a central server? 36 | 37 | In decentralized systems there are two issues that didn't align with the goals because: 38 | 39 | 1. A change e.g. adding or removing a participant from a group needs to propagate to all participants before the take effect and therefor state is only [eventual consistent](https://en.wikipedia.org/wiki/Eventual_consistency). 40 | 2. Afaik asynchronous exchange and strong consistency is conflicting. For example blockchains require a lot of online nodes to verify the chain and to achieve a certain gurantee that the current version is the one that will be used and not another fork. There are some ideas and proposal how to tackle it though e.g. [local-first-web/auth discussion](https://github.com/local-first-web/auth/discussions/35) 41 | 42 | The three main objectives for the server are: 43 | 44 | 1. Be an always online instance for members to asynchronously exchange data 45 | 2. Prevent members to submit updates based on outdated state which ensures a correct data integrity (for the unencrypted state). 46 | 3. Instantly revoke access to removed members. 47 | 48 | ### How does it work? 49 | 50 | For example the encrypted state has a logical clock in the public, but authenticated data submitted by a user. This way the server can throw an error in case the user didn't have the latest state. The user's client can then fetch the latest information and retry. 51 | 52 | Note: Depending on the UX the user might want to re-review their changes. This might vary from case to case. 53 | 54 | ## Known Attack Vectors 55 | 56 | - Member 57 | - Admin 58 | - Server 59 | - Member collaborating with the central service 60 | - Admin collaborating with the central service 61 | 62 | ## Meta Data 63 | 64 | Since the chain is public all the meta data about who has access to the group and all their permissions are visible to the central service. This is a known trade-off and possibly an evolution of the protocol using zero knowledge proofs (like the [Signal Private Group System](https://eprint.iacr.org/2019/1416)) could reduce the meta data visible to the server while keep the functionality. 65 | 66 | ## Security Properties 67 | 68 | - [Forward Secrecy](https://en.wikipedia.org/wiki/Forward_secrecy) is currently not supported. A future evolution of the project ideally uses a [ratchet](https://en.wikipedia.org/wiki/Double_Ratchet_Algorithm) to enable forward secrecy. Inspirations could be [DCGKA](https://eprint.iacr.org/2020/1281). 69 | - [Post-compromise security](https://eprint.iacr.org/2016/221.pdf) is currently not supported. Only when a member gets removed the synchronous encryption key and related lockboxes are replaced which leads to PCS in this case. 70 | 71 | ## Server Authentication 72 | 73 | For authentication with the server the client has to request a challenge from the server. A nonce is returned. The client verifies that the nonce is prefixed with the text `"server-auth-"` followed by a UUID. The UUID is only verified by the length. Once this is confirmed the client will sign the challenge and return the signature to sign in. 74 | The server in the response sets a HTTP only session cookie to initialize a session. 75 | 76 | The purpose of the nonce prefix is to avoid the server sending any other message that the client would sign accidentally (kind of a chosen-plaintext attack). 77 | 78 | ### Possible Improvement 79 | 80 | It might make sense to also include an encryption challenge to verify that the client also has access to the lockbox private key. 81 | 82 | This though needs caution to prevent to prevent a chosen-plaintext attack as described [here](https://crypto.stackexchange.com/a/76662). 83 | 84 | ## Demo 85 | 86 | The demo is available at [https://www.serenity.li/](https://www.serenity.li/). Keep in mind the data is regularily wiped. 87 | 88 | ### Known Issues 89 | 90 | - The private and public keys are currently storred in the localstorage. This is not as planned in the production ready implementation where they should only storred encrypted secured by a password or WebAuthn. 91 | - Some buttons are shown as active e.g. "Demote to member", but the actual action will fail in certain cases e.g. when the current user is the last admin. In this case the error will only be visible in the console. 92 | - Remove member (based on a event proposal does not work) 93 | - Missing checks if the publicKeys are valid for createUser 94 | - Missing checks if the publicKeys match an existing user when adding a member to an organisation 95 | - Chain/Encryped State related 96 | - when promoting someone to an admin then set the name in the encrypted state 97 | - when removing a member, take over the encrypted state updates from them 98 | - when removing permissions from a member, take over the encrypted state updates 99 | 100 | Note: The whole project is prototype style code e.g. some functions of the trust chain package are mutating the input object. 101 | 102 | ## Trust Chain Package 103 | 104 | ### Trust Chain Events 105 | 106 | - `create-chain` 107 | 108 | - Usually created by one author, but can be more as well. 109 | 110 | - `add-member` 111 | 112 | - Members can only add another member if they have the permission `canAddMember`. A member can not add another member as admin. 113 | - Admins can add members and set the permissions `canAddMember` and `canRemoveMember`. If an admin wants to add another member the event must be signed by >50% of the admins. 114 | 115 | - `remove-member` 116 | 117 | - Members can only remove another member if they have the permission `canAddMember`. A member can not remove another member as admin. 118 | - Admins can add members and set the permissions `canAddMember` and `canRemoveMember`. If an admin wants to add another member the event must be signed by >50% of the admins. 119 | 120 | - `update-member` 121 | 122 | - Only admins can update the authorization info `isAdmin`, `canAddMember` and `canRemoveMember` of other members. If an admin promote a member to an admin or demote an admin to a member the event must be signed by >50% of the admins. 123 | 124 | - `end-chain` TODO (not implemented yet) 125 | 126 | - The purpose is to close the chain so no one else can attach more events to it. The event must be signed by >50% of the admins. 127 | 128 | #### Structure 129 | 130 | Example structure of an event: 131 | 132 | ```json 133 | { 134 | // the purpose of authors being an array is so that multiple users can sign an event and declare themselves as authors 135 | "authors": [ 136 | { 137 | // public signing key of creator of the event or others that signed it 138 | "publicKey": "pkcVysaH_mC-TpXzZEAAeB47rIqsWwubaM4stZQu-B4", 139 | // signature of the hash of the prev transaction + the hash of the current transaction 140 | "signature": "q5BHaR8Wu1CCA6mt-XCwmxuYpVU1-6J5E_FmxgWu_C63yfCJ9IwFh9bBMX3WPAdMN_yMFMAK3Ygapjd96qKHCg" 141 | } 142 | ], 143 | // hash of the prev transaction 144 | "prevHash": "M-LtpoR7cMzADedkf0TXAPVIXQR5kj7T-gCtcgNaFwu1IShI84B5PaXULQjQHMVANiMTyRyCcsne389jHIRvng", 145 | // transaction is the actual event content 146 | "transaction": { 147 | "type": "update-member", 148 | "isAdmin": true, 149 | "canAddMembers": true, 150 | "canRemoveMembers": true, 151 | "memberSigningPublicKey": "EfOSyGYcwLdVjRSJDimpEFB2_XuXd2oCCh7f8I4VlwY" 152 | } 153 | } 154 | ``` 155 | 156 | ### EncryptedState Structure 157 | 158 | Example structure of the encrypted state: 159 | 160 | ```json 161 | [ 162 | { 163 | // identified for symmetric key that's used 164 | "keyId": "43b89b1c-6c7b-48a6-839c-20cc495d3f97", 165 | "ciphertext": "yYV1_uUtoFPRBRBCUtyv-jwPLfQI2UFYMovnUUFJh5UXALMkq69KM73YI3WaqZGrcclwTE7jMYAsYKR5rU0d-Q7yprXOS_1hXZ8TeKyQ-vSxpgZmgJVUK5zP_HDyFB4ackgBRdws6IyOMxc1Kz_HB-SCqC8Vk26H7YhGaIQWs4MXNpqgTg17cIo7Q2TL2shYN_VHSHmAeojByOfyDpUP3kpXK3zduIZZEKG3tuwjnzN0JG83BCPel8Iyrh91_UnBpctnXQUEhkoQ_ZJ_6YXpGYTnhTFur40NhX5nDjpIImbCiMoVAVIU_KZEcQ4bzGZ1_eM8AeRKTiie", 166 | "nonce": "YOnlwPnEtwnfEQa0nEQR41RvQ7Dxt2NB", 167 | // global logical clock to order the encrypted states (to ensure deterministic data resolving) 168 | "publicData": "{\"clock\":2}", 169 | "author": { 170 | "publicKey": "pkcVysaH_mC-TpXzZEAAeB47rIqsWwubaM4stZQu-B4", 171 | "signature": "XTNuX2mqcpRF4nLnhHURAYblpE68ftPsaNW3ZsdZY1Se7BlLTjJSnADrV9Rn4AhM4MIjAsU8mj003vQCJUtjAA" 172 | }, 173 | "lockbox": { 174 | "keyId": "43b89b1c-6c7b-48a6-839c-20cc495d3f97", 175 | "receiverSigningPublicKey": "pkcVysaH_mC-TpXzZEAAeB47rIqsWwubaM4stZQu-B4", 176 | "senderLockboxPublicKey": "7CVtpWbqKFJvZO7hso3dOmrrwva_3uDgm3erquuTFRQ", 177 | "ciphertext": "f3_AVc5xIxC8ejhBflv_qhAjtZoTveMroA9H4uATn51yKKpToegWn_dB-P-oYG8IK_CKDt4mfmGD-43M5KVSeAykPDiPIxAIBAgNZfjVJMwhiYUFMt4U26WTlHX4aBFa75-VztyF2TcYmkhEbFw-Gf0fVVs", 178 | "nonce": "HLDYyAc4GMEcCqHSE2a_N-l1oaRkWfCL" 179 | } 180 | } 181 | ] 182 | ``` 183 | 184 | ### Utility functions 185 | 186 | - `addAuthorToEvent` 187 | - `createInvitation` TODO 188 | - `acceptInvitation` TODO 189 | - `revokeInvitation` TODO 190 | 191 | ### Invitation Process (TODO) 192 | 193 | - create invitation (to be storred in the encrypted part) 194 | - unique string id 195 | - author 196 | - accept invitation 197 | - id 198 | - signature of the unique string (proof) by invitee 199 | 200 | #### Error Philosophy 201 | 202 | The chain will not accept any invalid input. It will not ignore them, but rather throw an Error. When creating an event there is no validation. 203 | 204 | Here an example for clarification: 205 | 206 | ```ts 207 | const event = createChain(…); // does not throw errors 208 | const state = resolveState([event]); // throws errors (internally uses applyEvent) 209 | 210 | const event2 = addMember(…); // does not throw errors 211 | const newState = applyEvent(state, event2); // throws errors 212 | ``` 213 | 214 | ## Known UX issue 215 | 216 | Admin actions need to be in sync, meaning I can't vote on two admin interaction at the same time. Any additional chain event will invalidate them. See future improvements for a possible solution. 217 | 218 | ### Future Improvements 219 | 220 | - Exhaustive TS Matching 221 | - Functions should not mutate incoming parameters 222 | - Implement a state machine 223 | - Add functionality to sign multiple events in multiple orders and pick from the right one to prevent the known UX issue. It doesn't scale, but with a limit of 5 it probably covers lots of cases. 224 | -------------------------------------------------------------------------------- /packages/trust-chain/src/state/resolveEncryptedState.addMember.test.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { addMember, createChain, resolveState, updateMember } from ".."; 3 | import { 4 | getKeyPairA, 5 | getKeyPairB, 6 | getKeyPairC, 7 | getKeyPairsA, 8 | getKeyPairsB, 9 | getKeyPairsC, 10 | KeyPairs, 11 | } from "../testUtils"; 12 | import { hashTransaction } from "../utils"; 13 | import { createKey } from "./createKey"; 14 | import { encryptState } from "./encryptState"; 15 | import { resolveEncryptedState } from "./resolveEncryptedState"; 16 | 17 | let keyPairA: sodium.KeyPair = null; 18 | let keyPairsA: KeyPairs = null; 19 | let keyPairB: sodium.KeyPair = null; 20 | let keyPairsB: KeyPairs = null; 21 | let keyPairC: sodium.KeyPair = null; 22 | let keyPairsC: KeyPairs = null; 23 | let keyPairAPublicKey: string = null; 24 | let keyPairCPublicKey: string = null; 25 | 26 | beforeAll(async () => { 27 | await sodium.ready; 28 | keyPairA = getKeyPairA(); 29 | keyPairsA = getKeyPairsA(); 30 | keyPairB = getKeyPairB(); 31 | keyPairsB = getKeyPairsB(); 32 | keyPairC = getKeyPairC(); 33 | keyPairsC = getKeyPairsC(); 34 | keyPairAPublicKey = sodium.to_base64(keyPairA.publicKey); 35 | keyPairCPublicKey = sodium.to_base64(keyPairC.publicKey); 36 | }); 37 | 38 | test("should allow the client to add a member to set the name", async () => { 39 | const key = createKey(); 40 | const keys = { [key.keyId]: key.key }; 41 | 42 | const createEvent = createChain(keyPairsA.sign, { 43 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 44 | }); 45 | const addMemberEvent = addMember( 46 | hashTransaction(createEvent.transaction), 47 | keyPairA, 48 | keyPairsB.sign.publicKey, 49 | keyPairsB.box.publicKey, 50 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 51 | ); 52 | const addMemberEvent2 = addMember( 53 | hashTransaction(addMemberEvent.transaction), 54 | keyPairB, 55 | keyPairsC.sign.publicKey, 56 | keyPairsC.box.publicKey, 57 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 58 | ); 59 | const chain = [createEvent, addMemberEvent, addMemberEvent2]; 60 | const state = resolveState(chain); 61 | const encryptedState = encryptState( 62 | state, 63 | { members: { [keyPairCPublicKey]: { name: "Anna" } } }, 64 | key, 65 | keyPairB 66 | ); 67 | 68 | const result2 = resolveEncryptedState( 69 | state, 70 | [encryptedState], 71 | keys, 72 | keyPairsA.sign.publicKey 73 | ); 74 | 75 | expect(result2.state.members).toMatchInlineSnapshot(` 76 | Object { 77 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 78 | "addedBy": Array [ 79 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 80 | ], 81 | "canAddMembers": true, 82 | "canRemoveMembers": true, 83 | "isAdmin": true, 84 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 85 | }, 86 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 87 | "addedBy": Array [ 88 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 89 | ], 90 | "canAddMembers": true, 91 | "canRemoveMembers": false, 92 | "isAdmin": false, 93 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 94 | }, 95 | "ZKcwjAMAaSiq7k3MQVQUZ6aa7kBreK__5hkGI4SCltk": Object { 96 | "addedBy": Array [ 97 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY", 98 | ], 99 | "canAddMembers": false, 100 | "canRemoveMembers": false, 101 | "isAdmin": false, 102 | "lockboxPublicKey": "0hUuO22MoTa8X65ZvpR9KcfUwF_B2aIvLORPjuaofBg", 103 | "name": "Anna", 104 | "profileUpdatedBy": "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY", 105 | }, 106 | } 107 | `); 108 | }); 109 | 110 | test("should allow an admin to update the name of a member added by someone else", async () => { 111 | const key = createKey(); 112 | const keys = { [key.keyId]: key.key }; 113 | 114 | const createEvent = createChain(keyPairsA.sign, { 115 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 116 | }); 117 | const addMemberEvent = addMember( 118 | hashTransaction(createEvent.transaction), 119 | keyPairA, 120 | keyPairsB.sign.publicKey, 121 | keyPairsB.box.publicKey, 122 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 123 | ); 124 | const addMemberEvent2 = addMember( 125 | hashTransaction(addMemberEvent.transaction), 126 | keyPairB, 127 | keyPairsC.sign.publicKey, 128 | keyPairsC.box.publicKey, 129 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 130 | ); 131 | const chain = [createEvent, addMemberEvent, addMemberEvent2]; 132 | const state = resolveState(chain); 133 | const encryptedState = encryptState( 134 | state, 135 | { members: { [keyPairCPublicKey]: { name: "Anna" } } }, 136 | key, 137 | keyPairA 138 | ); 139 | 140 | const result2 = resolveEncryptedState( 141 | state, 142 | [encryptedState], 143 | keys, 144 | keyPairsA.sign.publicKey 145 | ); 146 | 147 | expect(result2.state.members).toMatchInlineSnapshot(` 148 | Object { 149 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 150 | "addedBy": Array [ 151 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 152 | ], 153 | "canAddMembers": true, 154 | "canRemoveMembers": true, 155 | "isAdmin": true, 156 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 157 | }, 158 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 159 | "addedBy": Array [ 160 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 161 | ], 162 | "canAddMembers": true, 163 | "canRemoveMembers": false, 164 | "isAdmin": false, 165 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 166 | }, 167 | "ZKcwjAMAaSiq7k3MQVQUZ6aa7kBreK__5hkGI4SCltk": Object { 168 | "addedBy": Array [ 169 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY", 170 | ], 171 | "canAddMembers": false, 172 | "canRemoveMembers": false, 173 | "isAdmin": false, 174 | "lockboxPublicKey": "0hUuO22MoTa8X65ZvpR9KcfUwF_B2aIvLORPjuaofBg", 175 | "name": "Anna", 176 | "profileUpdatedBy": "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 177 | }, 178 | } 179 | `); 180 | }); 181 | 182 | test("should allow an admin to overwrite the name of a member", async () => { 183 | const key = createKey(); 184 | const keys = { [key.keyId]: key.key }; 185 | 186 | const createEvent = createChain(keyPairsA.sign, { 187 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 188 | }); 189 | const addMemberEvent = addMember( 190 | hashTransaction(createEvent.transaction), 191 | keyPairA, 192 | keyPairsB.sign.publicKey, 193 | keyPairsB.box.publicKey, 194 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 195 | ); 196 | const addMemberEvent2 = addMember( 197 | hashTransaction(addMemberEvent.transaction), 198 | keyPairB, 199 | keyPairsC.sign.publicKey, 200 | keyPairsC.box.publicKey, 201 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 202 | ); 203 | const chain = [createEvent, addMemberEvent, addMemberEvent2]; 204 | const state = resolveState(chain); 205 | const encryptedState = encryptState( 206 | state, 207 | { members: { [keyPairCPublicKey]: { name: "Nik" } } }, 208 | key, 209 | keyPairB 210 | ); 211 | const result2 = resolveEncryptedState( 212 | state, 213 | [encryptedState], 214 | keys, 215 | keyPairsA.sign.publicKey 216 | ); 217 | 218 | const encryptedState2 = encryptState( 219 | result2.state, 220 | { members: { [keyPairCPublicKey]: { name: "Niko" } } }, 221 | key, 222 | keyPairA 223 | ); 224 | const result3 = resolveEncryptedState( 225 | state, 226 | [encryptedState, encryptedState2], 227 | keys, 228 | keyPairsA.sign.publicKey 229 | ); 230 | 231 | expect(result3.state.members).toMatchInlineSnapshot(` 232 | Object { 233 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 234 | "addedBy": Array [ 235 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 236 | ], 237 | "canAddMembers": true, 238 | "canRemoveMembers": true, 239 | "isAdmin": true, 240 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 241 | }, 242 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 243 | "addedBy": Array [ 244 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 245 | ], 246 | "canAddMembers": true, 247 | "canRemoveMembers": false, 248 | "isAdmin": false, 249 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 250 | }, 251 | "ZKcwjAMAaSiq7k3MQVQUZ6aa7kBreK__5hkGI4SCltk": Object { 252 | "addedBy": Array [ 253 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY", 254 | ], 255 | "canAddMembers": false, 256 | "canRemoveMembers": false, 257 | "isAdmin": false, 258 | "lockboxPublicKey": "0hUuO22MoTa8X65ZvpR9KcfUwF_B2aIvLORPjuaofBg", 259 | "name": "Niko", 260 | "profileUpdatedBy": "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 261 | }, 262 | } 263 | `); 264 | }); 265 | 266 | test("should not allow for a member to overwrite the name set by an admin", async () => { 267 | const key = createKey(); 268 | const keys = { [key.keyId]: key.key }; 269 | 270 | const createEvent = createChain(keyPairsA.sign, { 271 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 272 | }); 273 | const addMemberEvent = addMember( 274 | hashTransaction(createEvent.transaction), 275 | keyPairA, 276 | keyPairsB.sign.publicKey, 277 | keyPairsB.box.publicKey, 278 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 279 | ); 280 | const addMemberEvent2 = addMember( 281 | hashTransaction(addMemberEvent.transaction), 282 | keyPairB, 283 | keyPairsC.sign.publicKey, 284 | keyPairsC.box.publicKey, 285 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 286 | ); 287 | const chain = [createEvent, addMemberEvent, addMemberEvent2]; 288 | const state = resolveState(chain); 289 | const encryptedState = encryptState( 290 | state, 291 | { members: { [keyPairCPublicKey]: { name: "Nik" } } }, 292 | key, 293 | keyPairA 294 | ); 295 | const result2 = resolveEncryptedState( 296 | state, 297 | [encryptedState], 298 | keys, 299 | keyPairsA.sign.publicKey 300 | ); 301 | 302 | const encryptedState2 = encryptState( 303 | result2.state, 304 | { members: { [keyPairCPublicKey]: { name: "Niko" } } }, 305 | key, 306 | keyPairB 307 | ); 308 | const result3 = resolveEncryptedState( 309 | result2.state, 310 | [encryptedState, encryptedState2], 311 | keys, 312 | keyPairsA.sign.publicKey 313 | ); 314 | 315 | expect(result3.state.members).toMatchInlineSnapshot(` 316 | Object { 317 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 318 | "addedBy": Array [ 319 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 320 | ], 321 | "canAddMembers": true, 322 | "canRemoveMembers": true, 323 | "isAdmin": true, 324 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 325 | }, 326 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 327 | "addedBy": Array [ 328 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 329 | ], 330 | "canAddMembers": true, 331 | "canRemoveMembers": false, 332 | "isAdmin": false, 333 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 334 | }, 335 | "ZKcwjAMAaSiq7k3MQVQUZ6aa7kBreK__5hkGI4SCltk": Object { 336 | "addedBy": Array [ 337 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY", 338 | ], 339 | "canAddMembers": false, 340 | "canRemoveMembers": false, 341 | "isAdmin": false, 342 | "lockboxPublicKey": "0hUuO22MoTa8X65ZvpR9KcfUwF_B2aIvLORPjuaofBg", 343 | "name": "Nik", 344 | "profileUpdatedBy": "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 345 | }, 346 | } 347 | `); 348 | }); 349 | 350 | test("should not allow for a member to overwrite the name added by the member, but later promoted to admin", async () => { 351 | const key = createKey(); 352 | const keys = { [key.keyId]: key.key }; 353 | 354 | const createEvent = createChain(keyPairsA.sign, { 355 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 356 | }); 357 | const addMemberEvent = addMember( 358 | hashTransaction(createEvent.transaction), 359 | keyPairA, 360 | keyPairsB.sign.publicKey, 361 | keyPairsB.box.publicKey, 362 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 363 | ); 364 | const addMemberEvent2 = addMember( 365 | hashTransaction(addMemberEvent.transaction), 366 | keyPairB, 367 | keyPairsC.sign.publicKey, 368 | keyPairsC.box.publicKey, 369 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 370 | ); 371 | const updateMemberEvent = updateMember( 372 | hashTransaction(addMemberEvent2.transaction), 373 | keyPairA, 374 | keyPairsC.sign.publicKey, 375 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 376 | ); 377 | const chain = [ 378 | createEvent, 379 | addMemberEvent, 380 | addMemberEvent2, 381 | updateMemberEvent, 382 | ]; 383 | const state = resolveState(chain); 384 | const encryptedState = encryptState( 385 | state, 386 | { members: { [keyPairCPublicKey]: { name: "Jane Doe" } } }, 387 | key, 388 | keyPairB 389 | ); 390 | const result2 = resolveEncryptedState( 391 | state, 392 | [encryptedState], 393 | keys, 394 | keyPairsA.sign.publicKey 395 | ); 396 | 397 | expect(result2.state.members).toMatchInlineSnapshot(` 398 | Object { 399 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 400 | "addedBy": Array [ 401 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 402 | ], 403 | "canAddMembers": true, 404 | "canRemoveMembers": true, 405 | "isAdmin": true, 406 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 407 | }, 408 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 409 | "addedBy": Array [ 410 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 411 | ], 412 | "canAddMembers": true, 413 | "canRemoveMembers": false, 414 | "isAdmin": false, 415 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 416 | }, 417 | "ZKcwjAMAaSiq7k3MQVQUZ6aa7kBreK__5hkGI4SCltk": Object { 418 | "addedBy": Array [ 419 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY", 420 | ], 421 | "canAddMembers": true, 422 | "canRemoveMembers": true, 423 | "isAdmin": true, 424 | "lockboxPublicKey": "0hUuO22MoTa8X65ZvpR9KcfUwF_B2aIvLORPjuaofBg", 425 | }, 426 | } 427 | `); 428 | }); 429 | -------------------------------------------------------------------------------- /packages/trust-chain/src/resolveState.updateMember.test.ts: -------------------------------------------------------------------------------- 1 | import sodium from "libsodium-wrappers"; 2 | import { addAuthorToEvent } from "./addAuthorToEvent"; 3 | import { InvalidTrustChainError } from "./errors"; 4 | import { createChain, resolveState, addMember, updateMember } from "./index"; 5 | import { 6 | getKeyPairA, 7 | getKeyPairB, 8 | getKeyPairsA, 9 | getKeyPairsB, 10 | KeyPairs, 11 | } from "./testUtils"; 12 | import { hashTransaction } from "./utils"; 13 | 14 | let keyPairA: sodium.KeyPair = null; 15 | let keyPairsA: KeyPairs = null; 16 | let keyPairB: sodium.KeyPair = null; 17 | let keyPairsB: KeyPairs = null; 18 | 19 | beforeAll(async () => { 20 | await sodium.ready; 21 | keyPairA = getKeyPairA(); 22 | keyPairsA = getKeyPairsA(); 23 | keyPairB = getKeyPairB(); 24 | keyPairsB = getKeyPairsB(); 25 | }); 26 | 27 | test("should be able to promote a member to an admin", async () => { 28 | const createEvent = createChain(keyPairsA.sign, { 29 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 30 | }); 31 | const addMemberEvent = addMember( 32 | hashTransaction(createEvent.transaction), 33 | keyPairA, 34 | keyPairsB.sign.publicKey, 35 | keyPairsB.box.publicKey, 36 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 37 | ); 38 | const updateMemberEvent = updateMember( 39 | hashTransaction(addMemberEvent.transaction), 40 | keyPairA, 41 | keyPairsB.sign.publicKey, 42 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 43 | ); 44 | const state = resolveState([createEvent, addMemberEvent, updateMemberEvent]); 45 | expect(state.members).toMatchInlineSnapshot(` 46 | Object { 47 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 48 | "addedBy": Array [ 49 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 50 | ], 51 | "canAddMembers": true, 52 | "canRemoveMembers": true, 53 | "isAdmin": true, 54 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 55 | }, 56 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 57 | "addedBy": Array [ 58 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 59 | ], 60 | "canAddMembers": true, 61 | "canRemoveMembers": true, 62 | "isAdmin": true, 63 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 64 | }, 65 | } 66 | `); 67 | }); 68 | 69 | test("should be able to demote an admin to a member", async () => { 70 | const createEvent = createChain(keyPairsA.sign, { 71 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 72 | }); 73 | const addAdminEvent = addMember( 74 | hashTransaction(createEvent.transaction), 75 | keyPairA, 76 | keyPairsB.sign.publicKey, 77 | keyPairsB.box.publicKey, 78 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 79 | ); 80 | const updateMemberEvent = updateMember( 81 | hashTransaction(addAdminEvent.transaction), 82 | keyPairA, 83 | keyPairsB.sign.publicKey, 84 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 85 | ); 86 | const updateMemberEvent2 = addAuthorToEvent(updateMemberEvent, keyPairB); 87 | const state = resolveState([createEvent, addAdminEvent, updateMemberEvent2]); 88 | expect(state.members).toMatchInlineSnapshot(` 89 | Object { 90 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 91 | "addedBy": Array [ 92 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 93 | ], 94 | "canAddMembers": true, 95 | "canRemoveMembers": true, 96 | "isAdmin": true, 97 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 98 | }, 99 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 100 | "addedBy": Array [ 101 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 102 | ], 103 | "canAddMembers": false, 104 | "canRemoveMembers": false, 105 | "isAdmin": false, 106 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 107 | }, 108 | } 109 | `); 110 | }); 111 | 112 | test("should be able to update a member's canAddMembers", async () => { 113 | const createEvent = createChain(keyPairsA.sign, { 114 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 115 | }); 116 | const addMemberEvent = addMember( 117 | hashTransaction(createEvent.transaction), 118 | keyPairA, 119 | keyPairsB.sign.publicKey, 120 | keyPairsB.box.publicKey, 121 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 122 | ); 123 | const updateMemberEvent = updateMember( 124 | hashTransaction(addMemberEvent.transaction), 125 | keyPairA, 126 | keyPairsB.sign.publicKey, 127 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 128 | ); 129 | const state = resolveState([createEvent, addMemberEvent, updateMemberEvent]); 130 | expect(state.members).toMatchInlineSnapshot(` 131 | Object { 132 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 133 | "addedBy": Array [ 134 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 135 | ], 136 | "canAddMembers": true, 137 | "canRemoveMembers": true, 138 | "isAdmin": true, 139 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 140 | }, 141 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 142 | "addedBy": Array [ 143 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 144 | ], 145 | "canAddMembers": true, 146 | "canRemoveMembers": false, 147 | "isAdmin": false, 148 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 149 | }, 150 | } 151 | `); 152 | }); 153 | 154 | test("should be able to update a member's canRemoveMembers", async () => { 155 | const createEvent = createChain(keyPairsA.sign, { 156 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 157 | }); 158 | const addMemberEvent = addMember( 159 | hashTransaction(createEvent.transaction), 160 | keyPairA, 161 | keyPairsB.sign.publicKey, 162 | keyPairsB.box.publicKey, 163 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 164 | ); 165 | const updateMemberEvent = updateMember( 166 | hashTransaction(addMemberEvent.transaction), 167 | keyPairA, 168 | keyPairsB.sign.publicKey, 169 | { isAdmin: false, canAddMembers: false, canRemoveMembers: true } 170 | ); 171 | const state = resolveState([createEvent, addMemberEvent, updateMemberEvent]); 172 | expect(state.members).toMatchInlineSnapshot(` 173 | Object { 174 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 175 | "addedBy": Array [ 176 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 177 | ], 178 | "canAddMembers": true, 179 | "canRemoveMembers": true, 180 | "isAdmin": true, 181 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 182 | }, 183 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 184 | "addedBy": Array [ 185 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 186 | ], 187 | "canAddMembers": false, 188 | "canRemoveMembers": true, 189 | "isAdmin": false, 190 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 191 | }, 192 | } 193 | `); 194 | }); 195 | 196 | test("should be able to update a member's canAddMembers and canRemoveMembers", async () => { 197 | const createEvent = createChain(keyPairsA.sign, { 198 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 199 | }); 200 | const addMemberEvent = addMember( 201 | hashTransaction(createEvent.transaction), 202 | keyPairA, 203 | keyPairsB.sign.publicKey, 204 | keyPairsB.box.publicKey, 205 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 206 | ); 207 | const updateMemberEvent = updateMember( 208 | hashTransaction(addMemberEvent.transaction), 209 | keyPairA, 210 | keyPairsB.sign.publicKey, 211 | { isAdmin: false, canAddMembers: true, canRemoveMembers: true } 212 | ); 213 | const state = resolveState([createEvent, addMemberEvent, updateMemberEvent]); 214 | expect(state.members).toMatchInlineSnapshot(` 215 | Object { 216 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM": Object { 217 | "addedBy": Array [ 218 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 219 | ], 220 | "canAddMembers": true, 221 | "canRemoveMembers": true, 222 | "isAdmin": true, 223 | "lockboxPublicKey": "wevxDsZ-L7wpy3ePZcQNfG8WDh0wB0d27phr5OMdLwI", 224 | }, 225 | "MTDhqVIMflTD0Car-KSP1MWCIEYqs2LBaXfU20di0tY": Object { 226 | "addedBy": Array [ 227 | "74IPzs2dhoERLRuxeS7zadzEvKfb7IqOK-jKu0mQxIM", 228 | ], 229 | "canAddMembers": true, 230 | "canRemoveMembers": true, 231 | "isAdmin": false, 232 | "lockboxPublicKey": "b_skeL8qudNQji-HuOldPNFDzYSBENNqmFMlawhtrHg", 233 | }, 234 | } 235 | `); 236 | }); 237 | 238 | test("should fail to demote the last admin to a member", async () => { 239 | const createEvent = createChain(keyPairsA.sign, { 240 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 241 | }); 242 | const updateMemberEvent = updateMember( 243 | hashTransaction(createEvent.transaction), 244 | keyPairA, 245 | keyPairsA.sign.publicKey, 246 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 247 | ); 248 | const chain = [createEvent, updateMemberEvent]; 249 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 250 | expect(() => resolveState(chain)).toThrow( 251 | "Not allowed to demote the last admin." 252 | ); 253 | }); 254 | 255 | test("should fail to demote an admin to a member if not more than 50% admins agree", async () => { 256 | const createEvent = createChain(keyPairsA.sign, { 257 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 258 | }); 259 | const addAdminEvent = addMember( 260 | hashTransaction(createEvent.transaction), 261 | keyPairA, 262 | keyPairsB.sign.publicKey, 263 | keyPairsB.box.publicKey, 264 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 265 | ); 266 | const updateMemberEvent = updateMember( 267 | hashTransaction(addAdminEvent.transaction), 268 | keyPairA, 269 | keyPairsB.sign.publicKey, 270 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 271 | ); 272 | const chain = [createEvent, addAdminEvent, updateMemberEvent]; 273 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 274 | expect(() => resolveState(chain)).toThrow("Not allowed member update."); 275 | }); 276 | 277 | test("should fail to promote an admin that is already an admin", async () => { 278 | const createEvent = createChain(keyPairsA.sign, { 279 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 280 | }); 281 | const addAdminEvent = addMember( 282 | hashTransaction(createEvent.transaction), 283 | keyPairA, 284 | keyPairsB.sign.publicKey, 285 | keyPairsB.box.publicKey, 286 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 287 | ); 288 | const updateMemberEvent = updateMember( 289 | hashTransaction(addAdminEvent.transaction), 290 | keyPairA, 291 | keyPairsB.sign.publicKey, 292 | { isAdmin: true, canAddMembers: true, canRemoveMembers: true } 293 | ); 294 | const updateMemberEvent2 = addAuthorToEvent(updateMemberEvent, keyPairB); 295 | const chain = [createEvent, addAdminEvent, updateMemberEvent2]; 296 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 297 | expect(() => resolveState(chain)).toThrow("Not allowed member update."); 298 | }); 299 | 300 | test("should fail to update a member if nothing changes and canAddMembers and canRemoveMembers are false", async () => { 301 | const createEvent = createChain(keyPairsA.sign, { 302 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 303 | }); 304 | const addMemberEvent = addMember( 305 | hashTransaction(createEvent.transaction), 306 | keyPairA, 307 | keyPairsB.sign.publicKey, 308 | keyPairsB.box.publicKey, 309 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 310 | ); 311 | const updateMemberEvent = updateMember( 312 | hashTransaction(addMemberEvent.transaction), 313 | keyPairA, 314 | keyPairsB.sign.publicKey, 315 | { isAdmin: false, canAddMembers: false, canRemoveMembers: false } 316 | ); 317 | const chain = [createEvent, addMemberEvent, updateMemberEvent]; 318 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 319 | expect(() => resolveState(chain)).toThrow("Not allowed member update."); 320 | }); 321 | 322 | test("should fail to update a member if nothing changes and canAddMembers and canRemoveMembers are true", async () => { 323 | const createEvent = createChain(keyPairsA.sign, { 324 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 325 | }); 326 | const addMemberEvent = addMember( 327 | hashTransaction(createEvent.transaction), 328 | keyPairA, 329 | keyPairsB.sign.publicKey, 330 | keyPairsB.box.publicKey, 331 | { isAdmin: false, canAddMembers: true, canRemoveMembers: true } 332 | ); 333 | const updateMemberEvent = updateMember( 334 | hashTransaction(addMemberEvent.transaction), 335 | keyPairA, 336 | keyPairsB.sign.publicKey, 337 | { isAdmin: false, canAddMembers: true, canRemoveMembers: true } 338 | ); 339 | const chain = [createEvent, addMemberEvent, updateMemberEvent]; 340 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 341 | expect(() => resolveState(chain)).toThrow("Not allowed member update."); 342 | }); 343 | 344 | test("should fail to update a member if nothing changes and canAddMembers is true and canRemoveMembers is false", async () => { 345 | const createEvent = createChain(keyPairsA.sign, { 346 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 347 | }); 348 | const addMemberEvent = addMember( 349 | hashTransaction(createEvent.transaction), 350 | keyPairA, 351 | keyPairsB.sign.publicKey, 352 | keyPairsB.box.publicKey, 353 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 354 | ); 355 | const updateMemberEvent = updateMember( 356 | hashTransaction(addMemberEvent.transaction), 357 | keyPairA, 358 | keyPairsB.sign.publicKey, 359 | { isAdmin: false, canAddMembers: true, canRemoveMembers: false } 360 | ); 361 | const chain = [createEvent, addMemberEvent, updateMemberEvent]; 362 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 363 | expect(() => resolveState(chain)).toThrow("Not allowed member update."); 364 | }); 365 | 366 | test("should fail to update a member if nothing changes and canAddMembers is false and canRemoveMembers is true", async () => { 367 | const createEvent = createChain(keyPairsA.sign, { 368 | [keyPairsA.sign.publicKey]: keyPairsA.box.publicKey, 369 | }); 370 | const addMemberEvent = addMember( 371 | hashTransaction(createEvent.transaction), 372 | keyPairA, 373 | keyPairsB.sign.publicKey, 374 | keyPairsB.box.publicKey, 375 | { isAdmin: false, canAddMembers: false, canRemoveMembers: true } 376 | ); 377 | const updateMemberEvent = updateMember( 378 | hashTransaction(addMemberEvent.transaction), 379 | keyPairA, 380 | keyPairsB.sign.publicKey, 381 | { isAdmin: false, canAddMembers: false, canRemoveMembers: true } 382 | ); 383 | const chain = [createEvent, addMemberEvent, updateMemberEvent]; 384 | expect(() => resolveState(chain)).toThrow(InvalidTrustChainError); 385 | expect(() => resolveState(chain)).toThrow("Not allowed member update."); 386 | }); 387 | --------------------------------------------------------------------------------