From d0993189d20fe78ec8b09abc3acfcf3ab738bd5d Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 19 Jul 2023 11:55:18 +0100 Subject: [PATCH] Technology is anything that wasn't around when you were born. --- .gitignore | 171 ++++++++++++++++++++ LICENSE.txt | 19 +++ README.md | 53 ++++++ bun.lockb | Bin 0 -> 3145 bytes package.json | 19 +++ src/cojsonValue.test.ts | 103 ++++++++++++ src/cojsonValue.ts | 183 +++++++++++++++++++++ src/crypto.test.ts | 103 ++++++++++++ src/crypto.ts | 245 ++++++++++++++++++++++++++++ src/index.ts | 14 ++ src/jsonValue.ts | 6 + src/multilog.test.ts | 138 ++++++++++++++++ src/multilog.ts | 348 ++++++++++++++++++++++++++++++++++++++++ src/node.ts | 75 +++++++++ src/permissions.test.ts | 311 +++++++++++++++++++++++++++++++++++ src/permissions.ts | 171 ++++++++++++++++++++ tsconfig.json | 21 +++ 17 files changed, 1980 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100755 bun.lockb create mode 100644 package.json create mode 100644 src/cojsonValue.test.ts create mode 100644 src/cojsonValue.ts create mode 100644 src/crypto.test.ts create mode 100644 src/crypto.ts create mode 100644 src/index.ts create mode 100644 src/jsonValue.ts create mode 100644 src/multilog.test.ts create mode 100644 src/multilog.ts create mode 100644 src/node.ts create mode 100644 src/permissions.test.ts create mode 100644 src/permissions.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..dd6b144d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,171 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +.DS_Store \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 000000000..1a7607611 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright 2023, Garden Computing, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..24c0090fb --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# CoJSON + +CoJSON ("Collaborative JSON") will be a minimal protocol and implementation for collaborative values (CRDTs + public-key cryptography). + +CoJSON is developed by [Garden Computing](https://gcmp.io) as the underpinnings of [Jazz](https://jazz.tools), a framework for building apps with telepathic data. + +The protocol and implementation will cover: + +- how to represent collaborative values internally +- the APIs collaborative values expose +- how to sync and query for collaborative values between peers +- how to enforce access rights within collaborative values locally and at sync boundaries + +THIS IS WORK IN PROGRESS + +## Core Value Types + +### `Immutable` Values (JSON) +- null +- boolean +- number +- string + - stringly-encoded CoJSON identifiers & data (`CoValueID`, `AgentID`, `SessionID`, `SignatoryID`, `SignatorySecret`, `Signature`, `RecipientID`, `RecipientSecret`, `Sealed`, `Hash`, `ShortHash`, `SecretKey`, `EncryptedChunk`, `Role`) + +- array +- object + +### `Collaborative` Values +- CoMap (`string` → `Immutable`, last-writer-wins per key) + - Team (`AgentID` → `Role`) +- CoList (`Immutable[]`, addressable positions, insertAfter semantics) + - Agent (`{signatoryID, recipientID}[]`) +- MultiStream (independent per-session streams of `Immutable`s) +- Static (single addressable `Immutable`) + +## Implementation Abstractions +- MultiLog + - Session Logs + - Transactions + - Private (encrypted) transactions + - Trusting (unencrypted) transactions + - Rulesets +- CoValue Types +- LocalNode + - Peers + - AgentCredentials +- Peer + +## Extensions & higher-level protocols + +### More complex datastructures +- MarCo: a clean way to collaboratively mark up rich text with CoJSON +- CoJSON Tree: a clean way to represent collaborative tree structures with CoJSON \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..e498e2f15b9c847f2b245b68f0fa209587c189da GIT binary patch literal 3145 zcmd^Bdr(wm6u-a%ipirjA0VB&4t$$LSyJftf~N*g3oRvz0;IoThQ6Gjr#B=brEP z`_6a1*NF^g%6N_`GME|GTrMjrGyB1(Fj+LkT7!vIXf0fcT2PcL{X8j(I@0djKdGuF zkN#z-D}Phx!c{?=mD7#$7R=ALJ=VFcXUgFT&9zExUGm<9BDQbdo~j>pXX_rl8sPzWlr=d6p4cXK+q-fv(nf##u1*7U z%bV)IHO=DsAG3f`2;UNj18rA7m}3gTAAw#^0UojB8xtrx3I00RkShn?OXT<`cp3&J z_y>vK0H72YAIC&I#^8>S_-*3&i1C3B*4k~s%|kZOXd2@gx$}_x!EnO?!$|lqiwEW8 zb)}YUolRvJ`G;f+qhl!tGrz|W71oJF~+dK zp~kjX12R`P?p|hBZn+-P9|kcJUXo`FTcVThXpy&QJWp?|%v|MzO(Q2AWQF^wy5VNdclUQEEitab(REuvVT*X z^2>{9&*)Apn44&q9=D!5EKRE&u77WLiI4Y^bG_QL(ktfhgAJCWD+X%6+K~NiMov;t z=7sRq{G2b|pXtIY!9Yckoc~TQ$tn!|W^TRycaLq&lGs@%y-pmQM0tm0$ z+SmL_T(*5+dD`ofjcwV|g#5tj$nom=_Mqy7Lw_I*$%8i`1ubD(7fLKws%;Zb&i?xR zM8$r_Uv_muZSUiK#&yqiue@|4{E^^ep%jyyH0#(IkIiB6XNq@61h-1N{A*fky92K6 zbm7H4OGNT>awQ$wQ*py`J7e+8IWwZ`g$YaSRcvQide8BxA;B4!H`<~i%qfR$JGSQj zSu^#w=T9|UZfNN%+|&Q&%;?;lC?pzj|KNQ@C3QQ_men^P%L@{;G=^6FC;!OA+|;gn z*iFd)vD<#}u4)stUoy{WXoHy-)Fu;6UR?SGj%8D2F-jS4sAMf#xonP1Z8GT07A?Mg zD8sj%f^FP~{UYHw@zV24E{lyx1>7hMxK?;K;5o)Khvx%1;zjIYH#nd>g%KbQ@yLUG z$cy~gt|NT#SfltQ=f}joTow;Ai@Ot->h77r{H#q;R{89;Y*C=c(kE#ptpp^X@x>=E z_&86KT3~sBqDW0Z-gr-srAk^w$5K={V6b{1RRDNY-~l#LSCBfv;faNFg|DHb@*tIj zhyhOqc(67hwFY?LB#zDo!y_5BQ=B)^cP}>2mXxy`oxVa~IkVc7D{uz0E>))bJ2mll zQqv(awTQJ9tS)3xb&n(p6{Re%;S8k$mT?a2X|tus#4;K~sh;I{VwG{_EHU(IUJnMZ zDdSkCNX@faH7_WzFjK(h1TZL!(4ei*3qmQM$S@qMGr-RZ+FV*<P$Fdoh^l9 acisnlXW4L`WC;NoN2_8H3VZ9Jl)nJ%=VM?1 literal 0 HcmV?d00001 diff --git a/package.json b/package.json new file mode 100644 index 000000000..45c1931e1 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "cojson", + "module": "src/index.ts", + "type": "module", + "license": "MIT", + "devDependencies": { + "bun-types": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@noble/ciphers": "^0.1.3", + "@noble/curves": "^1.1.0", + "@noble/hashes": "^1.3.1", + "@scure/base": "^1.1.1", + "fast-json-stable-stringify": "^2.1.0" + } +} \ No newline at end of file diff --git a/src/cojsonValue.test.ts b/src/cojsonValue.test.ts new file mode 100644 index 000000000..bb613dc05 --- /dev/null +++ b/src/cojsonValue.test.ts @@ -0,0 +1,103 @@ +import { test, expect } from "bun:test"; +import { + getAgent, + getAgentID, + newRandomAgentCredential, + newRandomSessionID, +} from "./multilog"; +import { LocalNode } from "./node"; + +test("Empty COJSON Map works", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "comap", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const content = multilog.getCurrentContent(); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.type).toEqual("comap"); + expect([...content.keys()]).toEqual([]); + expect(content.toJSON()).toEqual({}); +}); + +test("Can insert and delete Map entries in edit()", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "comap", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const content = multilog.getCurrentContent(); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.type).toEqual("comap"); + + content.edit((editable) => { + editable.set("hello", "world"); + expect(editable.get("hello")).toEqual("world"); + editable.set("foo", "bar"); + expect(editable.get("foo")).toEqual("bar"); + expect([...editable.keys()]).toEqual(["hello", "foo"]); + editable.delete("foo"); + expect(editable.get("foo")).toEqual(undefined); + }); +}); + +test("Can get map entry values at different points in time", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "comap", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const content = multilog.getCurrentContent(); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.type).toEqual("comap"); + + content.edit((editable) => { + const beforeA = Date.now(); + Bun.sleepSync(1); + editable.set("hello", "A"); + const beforeB = Date.now(); + Bun.sleepSync(1); + editable.set("hello", "B"); + const beforeC = Date.now(); + Bun.sleepSync(1); + editable.set("hello", "C"); + expect(editable.get("hello")).toEqual("C"); + expect(editable.getAtTime("hello", Date.now())).toEqual("C"); + expect(editable.getAtTime("hello", beforeA)).toEqual(undefined); + expect(editable.getAtTime("hello", beforeB)).toEqual("A"); + expect(editable.getAtTime("hello", beforeC)).toEqual("B"); + }); +}) \ No newline at end of file diff --git a/src/cojsonValue.ts b/src/cojsonValue.ts new file mode 100644 index 000000000..37f65f353 --- /dev/null +++ b/src/cojsonValue.ts @@ -0,0 +1,183 @@ +import { JsonAtom, JsonObject, JsonValue } from "./jsonValue"; +import { MultiLog, MultiLogID, TransactionID } from "./multilog"; + +export type CoValueID = MultiLogID & { + readonly __type: T; +}; + +export type CoValue = + | CoMap + | CoList + | MultiStream + | Static; + +type MapOp = { + txID: TransactionID; + madeAt: number; + changeIdx: number; +} & MapOpPayload; + +// TODO: add after TransactionID[] for conflicts/ordering +export type MapOpPayload = + | { + op: "insert"; + key: K; + value: V; + } + | { + op: "delete"; + key: K; + }; + +export class CoMap< + K extends string, + V extends JsonValue, + Meta extends JsonValue +> { + id: CoValueID>; + multiLog: MultiLog; + type: "comap" = "comap"; + ops: Map[]>; + + constructor(multiLog: MultiLog) { + this.id = multiLog.id as CoValueID>; + this.multiLog = multiLog; + this.ops = new Map(); + + this.fillOpsFromMultilog(); + } + + protected fillOpsFromMultilog() { + for (const { txID, changes, madeAt } of this.multiLog.getValidSortedTransactions()) { + for (const [changeIdx, change] of ( + changes as MapOpPayload[] + ).entries()) { + let entries = this.ops.get(change.key); + if (!entries) { + entries = []; + this.ops.set(change.key, entries); + } + entries.push({ + txID, + madeAt, + changeIdx, + ...change, + }); + } + } + } + + keys(): IterableIterator { + return this.ops.keys(); + } + + get(key: K): V | undefined { + const ops = this.ops.get(key); + if (!ops) { + return undefined; + } + + let lastEntry = ops[ops.length - 1]; + + if (lastEntry.op === "delete") { + return undefined; + } else { + return lastEntry.value; + } + } + + getAtTime(key: K, time: number): V | undefined { + const ops = this.ops.get(key); + if (!ops) { + return undefined; + } + + const lastOpBeforeOrAtTime = ops.findLast((op) => op.madeAt <= time); + + if (!lastOpBeforeOrAtTime) { + return undefined; + } + + if (lastOpBeforeOrAtTime.op === "delete") { + return undefined; + } else { + return lastOpBeforeOrAtTime.value; + } + } + + toJSON(): JsonObject { + const json: JsonObject = {}; + + for (const key of this.keys()) { + const value = this.get(key); + if (value !== undefined) { + json[key] = value; + } + } + + return json; + } + + edit(changer: (editable: WriteableCoMap) => void): void { + const editable = new WriteableCoMap(this.multiLog); + changer(editable); + } +} + +export class WriteableCoMap< + K extends string, + V extends JsonValue, + Meta extends JsonValue +> extends CoMap { + // TODO: change default to private + set(key: K, value: V, privacy: "private" | "trusting" = "trusting"): void { + this.multiLog.makeTransaction([ + { + op: "insert", + key, + value, + }, + ], privacy); + + this.fillOpsFromMultilog(); + } + + // TODO: change default to private + delete(key: K, privacy: "private" | "trusting" = "trusting"): void { + this.multiLog.makeTransaction([ + { + op: "delete", + key, + }, + ], privacy); + + this.fillOpsFromMultilog(); + } +} + +export class CoList { + id: CoValueID>; + type: "colist" = "colist"; + + constructor(multilog: MultiLog) { + this.id = multilog.id as CoValueID>; + } +} + +export class MultiStream { + id: CoValueID>; + type: "multistream" = "multistream"; + + constructor(multilog: MultiLog) { + this.id = multilog.id as CoValueID>; + } +} + +export class Static { + id: CoValueID>; + type: "static" = "static"; + + constructor(multilog: MultiLog) { + this.id = multilog.id as CoValueID>; + } +} diff --git a/src/crypto.test.ts b/src/crypto.test.ts new file mode 100644 index 000000000..8a50dd46a --- /dev/null +++ b/src/crypto.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test"; +import { + getRecipientID, + getSignatoryID, + secureHash, + newRandomRecipient, + newRandomSignatory, + sealFor, + sign, + unsealAs, + verify, + shortHash, + newRandomSecretKey, + EncryptionStream, + DecryptionStream, +} from "./crypto"; + +test("Signatures round-trip and use stable stringify", () => { + const data = { b: "world", a: "hello" }; + const signatory = newRandomSignatory(); + const signature = sign(signatory, data); + + expect(signature).toMatch(/^signature_z/); + expect( + verify(signature, { a: "hello", b: "world" }, getSignatoryID(signatory)) + ).toBe(true); +}); + +test("Invalid signatures don't verify", () => { + const data = { b: "world", a: "hello" }; + const signatory = newRandomSignatory(); + const signatory2 = newRandomSignatory(); + const wrongSignature = sign(signatory2, data); + + expect(verify(wrongSignature, data, getSignatoryID(signatory))).toBe(false); +}); + +test("Sealing round-trips", () => { + const data = { b: "world", a: "hello" }; + const recipient = newRandomRecipient(); + const sealed = sealFor(getRecipientID(recipient), data); + + expect(sealed).toMatch(/^sealed_U/); + expect(unsealAs(recipient, sealed)).toEqual(data); +}); + +test("Invalid receiver can't unseal", () => { + const data = { b: "world", a: "hello" }; + const recipient = newRandomRecipient(); + const recipient2 = newRandomRecipient(); + const sealed = sealFor(getRecipientID(recipient), data); + + expect(unsealAs(recipient2, sealed)).toBeUndefined(); +}); + +test("Hashing is deterministic", () => { + expect(secureHash({ b: "world", a: "hello" })).toEqual( + secureHash({ a: "hello", b: "world" }) + ); + + expect(shortHash({ b: "world", a: "hello" })).toEqual( + shortHash({ a: "hello", b: "world" }) + ); +}); + +test("Encryption streams round-trip", () => { + const secretKey = newRandomSecretKey(); + const nonce = new Uint8Array(24); + + const encryptionStream = new EncryptionStream(secretKey, nonce); + const decryptionStream = new DecryptionStream(secretKey, nonce); + + const encryptedChunks = [ + encryptionStream.encrypt({ a: "hello" }), + encryptionStream.encrypt({ b: "world" }), + ]; + + const decryptedChunks = encryptedChunks.map((chunk) => + decryptionStream.decrypt(chunk) + ); + + expect(decryptedChunks).toEqual([{ a: "hello" }, { b: "world" }]); +}); + +test("Encryption streams don't decrypt with a wrong key", () => { + const secretKey = newRandomSecretKey(); + const secretKey2 = newRandomSecretKey(); + const nonce = new Uint8Array(24); + + const encryptionStream = new EncryptionStream(secretKey, nonce); + const decryptionStream = new DecryptionStream(secretKey2, nonce); + + const encryptedChunks = [ + encryptionStream.encrypt({ a: "hello" }), + encryptionStream.encrypt({ b: "world" }), + ]; + + const decryptedChunks = encryptedChunks.map((chunk) => + decryptionStream.decrypt(chunk) + ); + + expect(decryptedChunks).toEqual([undefined, undefined]); +}); diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 000000000..276d0cdcd --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,245 @@ +import { ed25519, x25519 } from "@noble/curves/ed25519"; +import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa"; +import { JsonValue } from "./jsonValue"; +import { base58, base64url } from "@scure/base"; +import stableStringify from "fast-json-stable-stringify"; +import { blake2b } from "@noble/hashes/blake2b"; +import { concatBytes } from "@noble/ciphers/utils"; +import { blake3 } from "@noble/hashes/blake3"; +import { randomBytes } from "@noble/ciphers/webcrypto/utils"; + +export type SignatorySecret = `signatorySecret_z${string}`; +export type SignatoryID = `signatory_z${string}`; +export type Signature = `signature_z${string}`; + +export type RecipientSecret = `recipientSecret_z${string}`; +export type RecipientID = `recipient_z${string}`; +export type Sealed = `sealed_U${string}`; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export function newRandomSignatory(): SignatorySecret { + return `signatorySecret_z${base58.encode( + ed25519.utils.randomPrivateKey() + )}`; +} + +export function getSignatoryID(secret: SignatorySecret): SignatoryID { + return `signatory_z${base58.encode( + ed25519.getPublicKey( + base58.decode(secret.substring("signatorySecret_z".length)) + ) + )}`; +} + +export function sign(secret: SignatorySecret, message: JsonValue): Signature { + const signature = ed25519.sign( + textEncoder.encode(stableStringify(message)), + base58.decode(secret.substring("signatorySecret_z".length)) + ); + return `signature_z${base58.encode(signature)}`; +} + +export function verify( + signature: Signature, + message: JsonValue, + id: SignatoryID +): boolean { + return ed25519.verify( + base58.decode(signature.substring("signature_z".length)), + textEncoder.encode(stableStringify(message)), + base58.decode(id.substring("signatory_z".length)) + ); +} + +export function newRandomRecipient(): RecipientSecret { + return `recipientSecret_z${base58.encode(x25519.utils.randomPrivateKey())}`; +} + +export function getRecipientID(secret: RecipientSecret): RecipientID { + return `recipient_z${base58.encode( + x25519.getPublicKey( + base58.decode(secret.substring("recipientSecret_z".length)) + ) + )}`; +} + +// same construction as libsodium sealed_Uox +export function sealFor(recipient: RecipientID, message: JsonValue): Sealed { + const ephemeralSenderPriv = x25519.utils.randomPrivateKey(); + const ephemeralSenderPub = x25519.getPublicKey(ephemeralSenderPriv); + const recipientPub = base58.decode( + recipient.substring("recipient_z".length) + ); + const sharedSecret = x25519.getSharedSecret( + ephemeralSenderPriv, + recipientPub + ); + const nonce = blake2b(concatBytes(ephemeralSenderPub, recipientPub)).slice( + 0, + 24 + ); + + const plaintext = textEncoder.encode(stableStringify(message)); + + const sealedBox = concatBytes( + ephemeralSenderPub, + xsalsa20_poly1305(sharedSecret, nonce).encrypt(plaintext) + ); + + return `sealed_U${base64url.encode(sealedBox)}`; +} + +export function unsealAs( + recipientSecret: RecipientSecret, + sealed: Sealed +): JsonValue | undefined { + const sealedBytes = base64url.decode(sealed.substring("sealed_U".length)); + + const ephemeralSenderPub = sealedBytes.slice(0, 32); + const recipentPriv = base58.decode( + recipientSecret.substring("recipientSecret_z".length) + ); + const recipientPub = x25519.getPublicKey(recipentPriv); + const sharedSecret = x25519.getSharedSecret( + recipentPriv, + ephemeralSenderPub + ); + const nonce = blake2b(concatBytes(ephemeralSenderPub, recipientPub)).slice( + 0, + 24 + ); + + const ciphertext = sealedBytes.slice(32); + try { + const plaintext = xsalsa20_poly1305(sharedSecret, nonce).decrypt( + ciphertext + ); + return JSON.parse(textDecoder.decode(plaintext)); + } catch (e) { + return undefined; + } +} + +export type Hash = `hash_z${string}`; + +export function secureHash(value: JsonValue): Hash { + return `hash_z${base58.encode( + blake3(textEncoder.encode(stableStringify(value))) + )}`; +} + +export class StreamingHash { + state: ReturnType; + + constructor(fromClone?: ReturnType) { + this.state = fromClone || blake3.create({}); + } + + update(value: JsonValue) { + this.state.update(textEncoder.encode(stableStringify(value))); + } + + digest(): Hash { + const hash = this.state.digest(); + return `hash_z${base58.encode(hash)}`; + } + + clone(): StreamingHash { + return new StreamingHash(this.state.clone()); + } +} + +export type ShortHash = `shortHash_z${string}`; + +export function shortHash(value: JsonValue): ShortHash { + return `shortHash_z${base58.encode( + blake3(textEncoder.encode(stableStringify(value))).slice(0, 19) + )}`; +} + +export type EncryptedStreamChunk = + `encryptedChunk_U${string}`; + +export type SecretKey = `secretKey_z${string}`; + +export function newRandomSecretKey(): SecretKey { + return `secretKey_z${base58.encode(randomBytes(32))}`; +} + +export class EncryptionStream { + secretKey: Uint8Array; + nonce: Uint8Array; + counter: number; + + constructor(secretKey: SecretKey, nonce: Uint8Array) { + this.secretKey = base58.decode( + secretKey.substring("secretKey_z".length) + ); + this.nonce = nonce; + this.counter = 0; + } + + static resume(secretKey: SecretKey, nonce: Uint8Array, counter: number) { + const stream = new EncryptionStream(secretKey, nonce); + stream.counter = counter; + return stream; + } + + encrypt(value: T): EncryptedStreamChunk { + const plaintext = textEncoder.encode(stableStringify(value)); + const ciphertext = xsalsa20( + this.secretKey, + this.nonce, + plaintext, + new Uint8Array(plaintext.length), + this.counter + ); + this.counter++; + + return `encryptedChunk_U${base64url.encode(ciphertext)}`; + } +} + +export class DecryptionStream { + secretKey: Uint8Array; + nonce: Uint8Array; + counter: number; + + constructor(secretKey: SecretKey, nonce: Uint8Array) { + this.secretKey = base58.decode( + secretKey.substring("secretKey_z".length) + ); + this.nonce = nonce; + this.counter = 0; + } + + static resume(secretKey: SecretKey, nonce: Uint8Array, counter: number) { + const stream = new DecryptionStream(secretKey, nonce); + stream.counter = counter; + return stream; + } + + decrypt( + encryptedChunk: EncryptedStreamChunk + ): T | undefined { + const ciphertext = base64url.decode( + encryptedChunk.substring("encryptedChunk_U".length) + ); + const plaintext = xsalsa20( + this.secretKey, + this.nonce, + ciphertext, + new Uint8Array(ciphertext.length), + this.counter + ); + this.counter++; + + try { + return JSON.parse(textDecoder.decode(plaintext)); + } catch (e) { + return undefined; + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..fc26e7985 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,14 @@ +import { CoValue } from "./cojsonValue"; +import { JsonValue } from "./jsonValue"; +import { MultiLog } from "./multilog"; +import { LocalNode } from "./node"; + +type Value = JsonValue | CoValue; + +export { + JsonValue, + CoValue as CoJsonValue, + Value, + LocalNode as Node, + MultiLog +} diff --git a/src/jsonValue.ts b/src/jsonValue.ts new file mode 100644 index 000000000..b3bc130a0 --- /dev/null +++ b/src/jsonValue.ts @@ -0,0 +1,6 @@ +import { CoValueID, CoValue } from "./cojsonValue"; + +export type JsonAtom = string | number | boolean | null; +export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID; +export type JsonArray = JsonValue[]; +export type JsonObject = { [key: string]: JsonValue; }; diff --git a/src/multilog.test.ts b/src/multilog.test.ts new file mode 100644 index 000000000..09fa2aa0f --- /dev/null +++ b/src/multilog.test.ts @@ -0,0 +1,138 @@ +import { expect, test } from "bun:test"; +import { + MultiLog, + Transaction, + getAgent, + getAgentID, + newRandomAgentCredential, + newRandomSessionID, +} from "./multilog"; +import { LocalNode } from "./node"; +import { sign } from "./crypto"; + +test("Can create multilog with new agent credentials and add transaction to it", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "multistream", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const transaction: Transaction = { + privacy: "trusting", + madeAt: Date.now(), + changes: [ + { + hello: "world", + }, + ], + }; + + const { expectedNewHash } = multilog.expectedNewHashAfter( + node.ownSessionID, + [transaction] + ); + + expect( + multilog.tryAddTransactions( + node.ownSessionID, + [transaction], + expectedNewHash, + sign(agentCredential.signatorySecret, expectedNewHash) + ) + ).toBe(true); +}); + +test("transactions with wrong signature are rejected", () => { + const agent = newRandomAgentCredential(); + const wrongAgent = newRandomAgentCredential(); + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "multistream", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const transaction: Transaction = { + privacy: "trusting", + madeAt: Date.now(), + changes: [ + { + hello: "world", + }, + ], + }; + + const { expectedNewHash } = multilog.expectedNewHashAfter( + node.ownSessionID, + [transaction] + ); + + expect( + multilog.tryAddTransactions( + node.ownSessionID, + [transaction], + expectedNewHash, + sign(wrongAgent.signatorySecret, expectedNewHash) + ) + ).toBe(false); +}); + +test("transactions with correctly signed, but wrong hash are rejected", () => { + const agent = newRandomAgentCredential(); + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "multistream", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const transaction: Transaction = { + privacy: "trusting", + madeAt: Date.now(), + changes: [ + { + hello: "world", + }, + ], + }; + + const { expectedNewHash } = multilog.expectedNewHashAfter( + node.ownSessionID, + [ + { + privacy: "trusting", + madeAt: Date.now(), + changes: [ + { + hello: "wrong", + }, + ], + }, + ] + ); + + expect( + multilog.tryAddTransactions( + node.ownSessionID, + [transaction], + expectedNewHash, + sign(agent.signatorySecret, expectedNewHash) + ) + ).toBe(false); +}); diff --git a/src/multilog.ts b/src/multilog.ts new file mode 100644 index 000000000..4e1828e2f --- /dev/null +++ b/src/multilog.ts @@ -0,0 +1,348 @@ +import { randomBytes } from "@noble/hashes/utils"; +import { + CoList, + CoMap, + CoValue, + Static, + MultiStream, +} from "./cojsonValue"; +import { + EncryptedStreamChunk, + Hash, + RecipientID, + RecipientSecret, + SignatoryID, + SignatorySecret, + Signature, + StreamingHash, + getRecipientID, + getSignatoryID, + newRandomRecipient, + newRandomSignatory, + shortHash, + sign, + verify, +} from "./crypto"; +import { JsonValue } from "./jsonValue"; +import { base58 } from "@scure/base"; +import { + PermissionsDef as RulesetDef, + determineValidTransactions, +} from "./permissions"; + +export type MultiLogID = `coval_${string}`; + +export type MultiLogHeader = { + type: CoValue['type']; + ruleset: RulesetDef; + meta: JsonValue; +}; + +function multilogIDforHeader(header: MultiLogHeader): MultiLogID { + const hash = shortHash(header); + return `coval_${hash.slice("shortHash_".length)}`; +} + +export type SessionID = `session_${string}_${AgentID}`; + +export function agentIDfromSessionID(sessionID: SessionID): AgentID { + return `agent_${sessionID.substring(sessionID.lastIndexOf("_") + 1)}`; +} + +export function newRandomSessionID(agentID: AgentID): SessionID { + return `session_${base58.encode(randomBytes(8))}_${agentID}`; +} + +type SessionLog = { + transactions: Transaction[]; + lastHash?: Hash; + streamingHash: StreamingHash; + lastSignature: string; +}; + +export type PrivateTransaction = { + privacy: "private"; + madeAt: number; + encryptedChanges: EncryptedStreamChunk; +}; + +export type TrustingTransaction = { + privacy: "trusting"; + madeAt: number; + changes: JsonValue[]; +}; + +export type Transaction = PrivateTransaction | TrustingTransaction; + +export type DecryptedTransaction = { + txID: TransactionID; + changes: JsonValue[]; + madeAt: number; +}; + +export type TransactionID = { sessionID: SessionID; txIndex: number }; + +export class MultiLog { + id: MultiLogID; + header: MultiLogHeader; + sessions: { [key: SessionID]: SessionLog }; + agentCredential: AgentCredential; + ownSessionID: SessionID; + knownAgents: { [key: AgentID]: Agent }; + requiredMultiLogs: { [key: MultiLogID]: MultiLog }; + content?: CoValue; + + constructor( + header: MultiLogHeader, + agentCredential: AgentCredential, + ownSessionID: SessionID, + knownAgents: { [key: AgentID]: Agent }, + requiredMultiLogs: { [key: MultiLogID]: MultiLog } + ) { + this.id = multilogIDforHeader(header); + this.header = header; + this.sessions = {}; + this.agentCredential = agentCredential; + this.ownSessionID = ownSessionID; + this.knownAgents = knownAgents; + this.requiredMultiLogs = requiredMultiLogs; + } + + testWithDifferentCredentials( + agentCredential: AgentCredential, + ownSessionID: SessionID + ): MultiLog { + const knownAgents = { + ...this.knownAgents, + [agentIDfromSessionID(ownSessionID)]: getAgent(agentCredential), + }; + const cloned = new MultiLog( + this.header, + agentCredential, + ownSessionID, + knownAgents, + this.requiredMultiLogs + ); + + cloned.sessions = JSON.parse(JSON.stringify(this.sessions)); + + return cloned; + } + + knownState(): MultilogKnownState { + return { + header: true, + sessions: Object.fromEntries( + Object.entries(this.sessions).map(([k, v]) => [ + k, + v.transactions.length, + ]) + ), + }; + } + + get meta(): JsonValue { + return this.header?.meta ?? null; + } + + tryAddTransactions( + sessionID: SessionID, + newTransactions: Transaction[], + newHash: Hash, + newSignature: Signature + ): boolean { + const signatoryID = + this.knownAgents[agentIDfromSessionID(sessionID)]?.signatoryID; + + if (!signatoryID) { + console.warn("Unknown agent", agentIDfromSessionID(sessionID)); + return false; + } + + const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter( + sessionID, + newTransactions + ); + + if (newHash !== expectedNewHash) { + console.warn("Invalid hash", { newHash, expectedNewHash }); + return false; + } + + if (!verify(newSignature, newHash, signatoryID)) { + console.warn( + "Invalid signature", + newSignature, + newHash, + signatoryID + ); + return false; + } + + const transactions = this.sessions[sessionID]?.transactions ?? []; + + transactions.push(...newTransactions); + + this.sessions[sessionID] = { + transactions, + lastHash: newHash, + streamingHash: newStreamingHash, + lastSignature: newSignature, + }; + + this.content = undefined; + + const _ = this.getCurrentContent(); + + return true; + } + + expectedNewHashAfter( + sessionID: SessionID, + newTransactions: Transaction[] + ): { expectedNewHash: Hash; newStreamingHash: StreamingHash } { + const streamingHash = + this.sessions[sessionID]?.streamingHash.clone() ?? + new StreamingHash(); + for (const transaction of newTransactions) { + streamingHash.update(transaction); + } + + const newStreamingHash = streamingHash.clone(); + + return { + expectedNewHash: streamingHash.digest(), + newStreamingHash, + }; + } + + makeTransaction( + changes: JsonValue[], + privacy: "private" | "trusting" + ): boolean { + const madeAt = Date.now(); + + const transaction: Transaction = + privacy === "private" + ? (() => { + throw new Error("Not implemented"); + })() + : { + privacy: "trusting", + madeAt, + changes, + }; + + const sessionID = this.ownSessionID; + + const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [ + transaction, + ]); + + const signature = sign( + this.agentCredential.signatorySecret, + expectedNewHash + ); + + return this.tryAddTransactions( + sessionID, + [transaction], + expectedNewHash, + signature + ); + } + + getCurrentContent(): CoValue { + if (this.content) { + return this.content; + } + + if (this.header.type === "comap") { + this.content = new CoMap(this); + } else if (this.header.type === "colist") { + this.content = new CoList(this); + } else if (this.header.type === "multistream") { + this.content = new MultiStream(this); + } else if (this.header.type === "static") { + this.content = new Static(this); + } else { + throw new Error(`Unknown multilog type ${this.header.type}`); + } + + return this.content; + } + + getValidSortedTransactions(): DecryptedTransaction[] { + const validTransactions = determineValidTransactions(this); + + const allTransactions: DecryptedTransaction[] = validTransactions.map( + ({ txID, tx }) => { + if (tx.privacy === "private") { + throw new Error("Private transactions not supported yet"); + } else { + return { + txID, + changes: tx.changes, + madeAt: tx.madeAt, + }; + } + } + ); + // TODO: sort by timestamp, then by txID + allTransactions.sort((a, b) => a.madeAt - b.madeAt); + + return allTransactions; + } +} + +type MultilogKnownState = { + header: boolean; + sessions: { [key: SessionID]: number }; +}; + +export type AgentID = `agent_${string}`; + +export type Agent = { + signatoryID: SignatoryID; + recipientID: RecipientID; +}; + +export function getAgent(agentCredential: AgentCredential) { + return { + signatoryID: getSignatoryID(agentCredential.signatorySecret), + recipientID: getRecipientID(agentCredential.recipientSecret), + }; +} + +export function getAgentMultilogHeader(agent: Agent): MultiLogHeader { + return { + type: "comap", + ruleset: { + type: "agent", + initialSignatoryID: agent.signatoryID, + initialRecipientID: agent.recipientID, + }, + meta: null, + }; +} + +export function getAgentID(agent: Agent): AgentID { + return `agent_${multilogIDforHeader(getAgentMultilogHeader(agent)).slice( + "coval_".length + )}`; +} + +export type AgentCredential = { + signatorySecret: SignatorySecret; + recipientSecret: RecipientSecret; +}; + +export function newRandomAgentCredential(): AgentCredential { + const signatorySecret = newRandomSignatory(); + const recipientSecret = newRandomRecipient(); + return { signatorySecret, recipientSecret }; +} + +// type Role = "admin" | "writer" | "reader"; + +// type PermissionsDef = CJMap; diff --git a/src/node.ts b/src/node.ts new file mode 100644 index 000000000..ce34db012 --- /dev/null +++ b/src/node.ts @@ -0,0 +1,75 @@ +import { + MultiLogID, + MultiLog, + AgentCredential, + AgentID, + SessionID, + Agent, + getAgent, + getAgentID, + getAgentMultilogHeader, + MultiLogHeader, +} from "./multilog"; + +export class LocalNode { + multilogs: { [key: MultiLogID]: Promise | MultiLog } = {}; + // peers: {[key: Hostname]: Peer} = {}; + agentCredential: AgentCredential; + agentID: AgentID; + ownSessionID: SessionID; + knownAgents: { [key: AgentID]: Agent } = {}; + + constructor(agentCredential: AgentCredential, ownSessionID: SessionID) { + this.agentCredential = agentCredential; + const agent = getAgent(agentCredential); + const agentID = getAgentID(agent); + this.agentID = agentID; + this.knownAgents[agentID] = agent; + this.ownSessionID = ownSessionID; + + const agentMultilog = new MultiLog( + getAgentMultilogHeader(agent), + agentCredential, + ownSessionID, + this.knownAgents, + {} + ); + this.multilogs[agentMultilog.id] = Promise.resolve(agentMultilog); + } + + createMultiLog(header: MultiLogHeader): MultiLog { + const requiredMultiLogs = header.ruleset.type === "ownedByTeam" ? { + [header.ruleset.team]: this.expectMultiLogLoaded(header.ruleset.team) + } : {}; + + const multilog = new MultiLog( + header, + this.agentCredential, + this.ownSessionID, + this.knownAgents, + requiredMultiLogs + ); + this.multilogs[multilog.id] = multilog; + return multilog; + } + + expectMultiLogLoaded(id: MultiLogID): MultiLog { + const multilog = this.multilogs[id]; + if (!multilog) { + throw new Error(`Unknown multilog ${id}`); + } + if (multilog instanceof Promise) { + throw new Error(`Multilog ${id} not yet loaded`); + } + return multilog; + } +} + +// type Hostname = string; + +// interface Peer { +// hostname: Hostname; +// incoming: ReadableStream; +// outgoing: WritableStream; +// optimisticKnownStates: {[multilogID: MultiLogID]: MultilogKnownState}; +// } diff --git a/src/permissions.test.ts b/src/permissions.test.ts new file mode 100644 index 000000000..7e008330c --- /dev/null +++ b/src/permissions.test.ts @@ -0,0 +1,311 @@ +import { test, expect } from "bun:test"; +import { + AgentID, + getAgent, + getAgentID, + newRandomAgentCredential, + newRandomSessionID, +} from "./multilog"; +import { LocalNode } from "./node"; +import { CoJsonValue } from "."; +import { CoMap } from "./cojsonValue"; +import { Role } from "./permissions"; + +function teamWithTwoAdmins() { + const { multilog, admin, adminID } = newTeam(); + + const otherAdmin = newRandomAgentCredential(); + const otherAdminID = getAgentID(getAgent(otherAdmin)); + + let content = expectTeam(multilog.getCurrentContent()); + + content.edit((editable) => { + editable.set(otherAdminID, "admin", "trusting"); + expect(editable.get(otherAdminID)).toEqual("admin"); + }); + + content = expectTeam(multilog.getCurrentContent()); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.get(otherAdminID)).toEqual("admin"); + return { multilog, admin, adminID, otherAdmin, otherAdminID }; +} + +function newTeam() { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const multilog = node.createMultiLog({ + type: "comap", + ruleset: { type: "team", initialAdmin: adminID }, + meta: null, + }); + + const content = expectTeam(multilog.getCurrentContent()); + + content.edit((editable) => { + editable.set(adminID, "admin", "trusting"); + expect(editable.get(adminID)).toEqual("admin"); + }); + + return { node, multilog, admin, adminID }; +} + +function expectTeam(content: CoJsonValue): CoMap { + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + return content as CoMap; +} + +function expectMap(content: CoJsonValue): CoMap { + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + return content as CoMap; +} + +test("Initial admin can add another admin to a team", () => { + teamWithTwoAdmins(); +}); + +test("Added admin can add a third admin to a team", () => { + const { multilog, otherAdmin, otherAdminID } = teamWithTwoAdmins(); + + const otherAdminMultilog = multilog.testWithDifferentCredentials( + otherAdmin, + newRandomSessionID(otherAdminID) + ); + + let otherContent = expectTeam(otherAdminMultilog.getCurrentContent()); + + expect(otherContent.get(otherAdminID)).toEqual("admin"); + + const thirdAdmin = newRandomAgentCredential(); + const thirdAdminID = getAgentID(getAgent(thirdAdmin)); + + otherContent.edit((editable) => { + editable.set(thirdAdminID, "admin", "trusting"); + expect(editable.get(thirdAdminID)).toEqual("admin"); + }); + + otherContent = expectTeam(otherAdminMultilog.getCurrentContent()); + + expect(otherContent.get(thirdAdminID)).toEqual("admin"); +}); + +test("Admins can't demote other admins in a team", () => { + const { multilog, adminID, otherAdmin, otherAdminID } = teamWithTwoAdmins(); + + let content = expectTeam(multilog.getCurrentContent()); + + content.edit((editable) => { + editable.set(otherAdminID, "writer", "trusting"); + expect(editable.get(otherAdminID)).toEqual("admin"); + }); + + content = expectTeam(multilog.getCurrentContent()); + expect(content.get(otherAdminID)).toEqual("admin"); + + const otherAdminMultilog = multilog.testWithDifferentCredentials( + otherAdmin, + newRandomSessionID(otherAdminID) + ); + + let otherContent = expectTeam(otherAdminMultilog.getCurrentContent()); + + otherContent.edit((editable) => { + editable.set(adminID, "writer", "trusting"); + expect(editable.get(adminID)).toEqual("admin"); + }); + + otherContent = expectTeam(otherAdminMultilog.getCurrentContent()); + + expect(otherContent.get(adminID)).toEqual("admin"); +}); + +test("Admins an add writers to a team, who can't add admins, writers, or readers", () => { + const { multilog } = newTeam(); + const writer = newRandomAgentCredential(); + const writerID = getAgentID(getAgent(writer)); + + let content = expectTeam(multilog.getCurrentContent()); + + content.edit((editable) => { + editable.set(writerID, "writer", "trusting"); + expect(editable.get(writerID)).toEqual("writer"); + }); + + content = expectTeam(multilog.getCurrentContent()); + expect(content.get(writerID)).toEqual("writer"); + + const writerMultilog = multilog.testWithDifferentCredentials( + writer, + newRandomSessionID(writerID) + ); + + let writerContent = expectTeam(writerMultilog.getCurrentContent()); + + expect(writerContent.get(writerID)).toEqual("writer"); + + const otherAgent = newRandomAgentCredential(); + const otherAgentID = getAgentID(getAgent(otherAgent)); + + writerContent.edit((editable) => { + editable.set(otherAgentID, "admin", "trusting"); + expect(editable.get(otherAgentID)).toBeUndefined(); + + editable.set(otherAgentID, "writer", "trusting"); + expect(editable.get(otherAgentID)).toBeUndefined(); + + editable.set(otherAgentID, "reader", "trusting"); + expect(editable.get(otherAgentID)).toBeUndefined(); + }); + + writerContent = expectTeam(writerMultilog.getCurrentContent()); + + expect(writerContent.get(otherAgentID)).toBeUndefined(); +}); + +test("Admins can add readers to a team, who can't add admins, writers, or readers", () => { + const { multilog } = newTeam(); + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + + let content = expectTeam(multilog.getCurrentContent()); + + content.edit((editable) => { + editable.set(readerID, "reader", "trusting"); + expect(editable.get(readerID)).toEqual("reader"); + }); + + content = expectTeam(multilog.getCurrentContent()); + expect(content.get(readerID)).toEqual("reader"); + + const readerMultilog = multilog.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + + let readerContent = expectTeam(readerMultilog.getCurrentContent()); + + expect(readerContent.get(readerID)).toEqual("reader"); + + const otherAgent = newRandomAgentCredential(); + const otherAgentID = getAgentID(getAgent(otherAgent)); + + readerContent.edit((editable) => { + editable.set(otherAgentID, "admin", "trusting"); + expect(editable.get(otherAgentID)).toBeUndefined(); + + editable.set(otherAgentID, "writer", "trusting"); + expect(editable.get(otherAgentID)).toBeUndefined(); + + editable.set(otherAgentID, "reader", "trusting"); + expect(editable.get(otherAgentID)).toBeUndefined(); + }); + + readerContent = expectTeam(readerMultilog.getCurrentContent()); + + expect(readerContent.get(otherAgentID)).toBeUndefined(); +}); + +test("Admins can write to an object that is owned by their team", () => { + const { node, multilog, adminID } = newTeam(); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: multilog.id }, + meta: null, + }); + + let childContent = expectMap(childObject.getCurrentContent()); + + childContent.edit((editable) => { + editable.set("foo", "bar", "trusting"); + expect(editable.get("foo")).toEqual("bar"); + }); + + childContent = expectMap(childObject.getCurrentContent()); + + expect(childContent.get("foo")).toEqual("bar"); +}) + +test("Writers can write to an object that is owned by their team", () => { + const { node, multilog, adminID } = newTeam(); + + const content = expectTeam(multilog.getCurrentContent()); + + const writer = newRandomAgentCredential(); + const writerID = getAgentID(getAgent(writer)); + + content.edit((editable) => { + editable.set(writerID, "writer", "trusting"); + expect(editable.get(writerID)).toEqual("writer"); + }); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: multilog.id }, + meta: null, + }); + + const childObjectAsWriter = childObject.testWithDifferentCredentials( + writer, + newRandomSessionID(writerID) + ); + + let childContent = expectMap(childObjectAsWriter.getCurrentContent()); + + childContent.edit((editable) => { + editable.set("foo", "bar", "trusting"); + expect(editable.get("foo")).toEqual("bar"); + }); + + childContent = expectMap(childObjectAsWriter.getCurrentContent()); + + expect(childContent.get("foo")).toEqual("bar"); +}); + +test("Readers can not write to an object that is owned by their team", () => { + const { node, multilog, adminID } = newTeam(); + + const content = expectTeam(multilog.getCurrentContent()); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + + content.edit((editable) => { + editable.set(readerID, "reader", "trusting"); + expect(editable.get(readerID)).toEqual("reader"); + }); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: multilog.id }, + meta: null, + }); + + const childObjectAsReader = childObject.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + + let childContent = expectMap(childObjectAsReader.getCurrentContent()); + + childContent.edit((editable) => { + editable.set("foo", "bar", "trusting"); + expect(editable.get("foo")).toBeUndefined(); + }); + + childContent = expectMap(childObjectAsReader.getCurrentContent()); + + expect(childContent.get("foo")).toBeUndefined(); +}); diff --git a/src/permissions.ts b/src/permissions.ts new file mode 100644 index 000000000..a44427ef8 --- /dev/null +++ b/src/permissions.ts @@ -0,0 +1,171 @@ +import { MapOpPayload } from "./cojsonValue"; +import { RecipientID, SignatoryID } from "./crypto"; +import { + AgentID, + MultiLog, + MultiLogID, + SessionID, + Transaction, + TransactionID, + TrustingTransaction, + agentIDfromSessionID, +} from "./multilog"; + +export type PermissionsDef = + | { type: "team"; initialAdmin: AgentID; parentTeams?: MultiLogID[] } + | { type: "ownedByTeam"; team: MultiLogID } + | { + type: "agent"; + initialSignatoryID: SignatoryID; + initialRecipientID: RecipientID; + } + | { type: "unsafeAllowAll" }; + +export type Role = "reader" | "writer" | "admin" | "revoked"; + +export function determineValidTransactions( + multilog: MultiLog +): { txID: TransactionID; tx: Transaction }[] { + if (multilog.header.ruleset.type === "team") { + const allTrustingTransactionsSorted = Object.entries( + multilog.sessions + ).flatMap(([sessionID, sessionLog]) => { + return sessionLog.transactions + .map((tx, txIndex) => ({ sessionID, txIndex, tx })) + .filter(({ tx }) => { + if (tx.privacy === "trusting") { + return true; + } else { + console.warn("Unexpected private transaction in Team"); + return false; + } + }) as { + sessionID: SessionID; + txIndex: number; + tx: TrustingTransaction; + }[]; + }); + + allTrustingTransactionsSorted.sort((a, b) => { + return a.tx.madeAt - b.tx.madeAt; + }); + + const initialAdmin = multilog.header.ruleset.initialAdmin; + + if (!initialAdmin) { + throw new Error("Team must have initialAdmin"); + } + + const memberState: { [agent: AgentID]: Role } = {}; + + const validTransactions: { txID: TransactionID; tx: Transaction }[] = + []; + + for (const { + sessionID, + txIndex, + tx, + } of allTrustingTransactionsSorted) { + // console.log("before", { memberState, validTransactions }); + const transactor = agentIDfromSessionID(sessionID); + + const change = tx.changes[0] as MapOpPayload; + if (tx.changes.length !== 1) { + console.warn("Team transaction must have exactly one change"); + continue; + } + + const affectedMember = change.key; + + if (change.op !== "insert") { + console.warn("Team transaction must set a role"); + continue; + } + + const assignedRole = change.value; + + if ( + change.value !== "admin" && + change.value !== "writer" && + change.value !== "reader" + ) { + console.warn("Team transaction must set a valid role"); + continue; + } + + const isFirstSelfAppointment = + !memberState[transactor] && + transactor === initialAdmin && + change.op === "insert" && + change.key === transactor && + change.value === "admin"; + + if (!isFirstSelfAppointment) { + if (memberState[transactor] !== "admin") { + console.warn( + "Team transaction must be made by current admin" + ); + continue; + } + + if ( + memberState[affectedMember] === "admin" && + affectedMember !== transactor && + assignedRole !== "admin" + ) { + console.warn("Admins can only demote themselves."); + continue; + } + } + + memberState[affectedMember] = change.value; + validTransactions.push({ txID: { sessionID, txIndex }, tx }); + + // console.log("after", { memberState, validTransactions }); + } + + return validTransactions; + } else if (multilog.header.ruleset.type === "ownedByTeam") { + const teamContent = + multilog.requiredMultiLogs[ + multilog.header.ruleset.team + ].getCurrentContent(); + + if (teamContent.type !== "comap") { + throw new Error("Team must be a map"); + } + + return Object.entries(multilog.sessions).flatMap( + ([sessionID, sessionLog]) => { + const transactor = agentIDfromSessionID(sessionID as SessionID); + return sessionLog.transactions + .filter((tx) => { + const transactorRoleAtTxTime = teamContent.getAtTime( + transactor, + tx.madeAt + ); + + return ( + transactorRoleAtTxTime === "admin" || + transactorRoleAtTxTime === "writer" + ); + }) + .map((tx, txIndex) => ({ + txID: { sessionID: sessionID as SessionID, txIndex }, + tx, + })); + } + ); + } else if (multilog.header.ruleset.type === "unsafeAllowAll") { + return Object.entries(multilog.sessions).flatMap( + ([sessionID, sessionLog]) => { + return sessionLog.transactions.map((tx, txIndex) => ({ + txID: { sessionID: sessionID as SessionID, txIndex }, + tx, + })); + } + ); + } else { + throw new Error("Unknown ruleset type " + multilog.header.ruleset.type); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..29f8aa003 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "preserve", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "noEmit": true, + "types": [ + "bun-types" // add Bun global + ] + } +}