├── 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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------