Merge remote-tracking branch 'cojson/main'

This commit is contained in:
Anselm
2023-08-15 18:08:40 +01:00
30 changed files with 8939 additions and 0 deletions

21
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,21 @@
module.exports = {
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:require-extensions/recommended",
],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "require-extensions"],
parserOptions: {
project: "./tsconfig.json",
},
root: true,
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-floating-promises": "error",
},
};

171
.gitignore vendored Normal file
View File

@@ -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

2
.npmignore Normal file
View File

@@ -0,0 +1,2 @@
coverage
node_modules

19
LICENSE.txt Normal file
View File

@@ -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.

53
README.md Normal file
View File

@@ -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 (`CoID`, `AgentID`, `SessionID`, `SignerID`, `SignerSecret`, `Signature`, `SealerID`, `SealerSecret`, `Sealed`, `Hash`, `ShortHash`, `KeySecret`, `KeyID`, `Encrypted`, `Role`)
- array
- object
### `Collaborative` Values
- CoMap (`string``Immutable`, last-writer-wins per key)
- Team (`AgentID``Role`)
- CoList (`Immutable[]`, addressable positions, insertAfter semantics)
- Agent (`{signerID, sealerID}[]`)
- CoStream (independent per-session streams of `Immutable`s)
- Static (single addressable `Immutable`)
## Implementation Abstractions
- CoValue
- Session Logs
- Transactions
- Private (encrypted) transactions
- Trusting (unencrypted) transactions
- Rulesets
- CoValue Content Types
- LocalNode
- Peers
- AgentCredentials
- Peer
## Extensions & higher-level protocols
### More complex datastructures
- CoText: a clean way to collaboratively mark up rich text with CoJSON
- CoJSON Tree: a clean way to represent collaborative tree structures with CoJSON

55
package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "cojson",
"module": "dist/index.js",
"main": "dist/index.js",
"types": "src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.0.14",
"devDependencies": {
"@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1",
"eslint": "^8.46.0",
"eslint-plugin-require-extensions": "^0.1.3",
"jest": "^29.6.2",
"ts-jest": "^29.1.1",
"typescript": "5.0.2"
},
"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",
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
},
"scripts": {
"test": "jest",
"lint": "eslint src/**/*.ts",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"prepublishOnly": "npm run build"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"transform": {
"\\.[jt]sx?$": [
"ts-jest",
{
"useESM": true
}
]
},
"moduleNameMapper": {
"(.+)\\.js": "$1"
},
"extensionsToTreatAsEsm": [
".ts"
],
"modulePathIgnorePatterns": [
"/node_modules/",
"/dist/"
]
}
}

67
src/account.test.ts Normal file
View File

@@ -0,0 +1,67 @@
import { newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import { connectedPeers } from "./streamUtils.js";
test("Can create a node while creating a new account with profile", async () => {
const { node, accountID, accountSecret, sessionID } =
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
expect(node).not.toBeNull();
expect(accountID).not.toBeNull();
expect(accountSecret).not.toBeNull();
expect(sessionID).not.toBeNull();
expect(node.expectProfileLoaded(accountID).get("name")).toEqual(
"Hermes Puggington"
);
expect((await node.loadProfile(accountID)).get("name")).toEqual(
"Hermes Puggington"
);
});
test("A node with an account can create teams and and objects within them", async () => {
const { node, accountID } =
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
const team = await node.createTeam();
expect(team).not.toBeNull();
let map = team.createMap();
map = map.edit((edit) => {
edit.set("foo", "bar", "private");
expect(edit.get("foo")).toEqual("bar");
});
expect(map.get("foo")).toEqual("bar");
expect(map.getLastEditor("foo")).toEqual(accountID);
});
test("Can create account with one node, and then load it on another", async () => {
const { node, accountID, accountSecret } =
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
const team = await node.createTeam();
expect(team).not.toBeNull();
let map = team.createMap();
map = map.edit((edit) => {
edit.set("foo", "bar", "private");
expect(edit.get("foo")).toEqual("bar");
});
const [node1asPeer, node2asPeer] = connectedPeers("node1", "node2", {trace: true, peer1role: "server", peer2role: "client"});
node.sync.addPeer(node2asPeer);
const node2 = await LocalNode.withLoadedAccount(
accountID,
accountSecret,
newRandomSessionID(accountID),
[node1asPeer]
);
const map2 = await node2.load(map.id);
expect(map2.get("foo")).toEqual("bar");
});

152
src/account.ts Normal file
View File

@@ -0,0 +1,152 @@
import { CoValueHeader } from "./coValue.js";
import { CoID } from "./contentType.js";
import {
AgentSecret,
SealerID,
SealerSecret,
SignerID,
SignerSecret,
getAgentID,
getAgentSealerID,
getAgentSealerSecret,
getAgentSignerID,
getAgentSignerSecret,
} from "./crypto.js";
import { AgentID } from "./ids.js";
import { CoMap, LocalNode } from "./index.js";
import { Team, TeamContent } from "./permissions.js";
export function accountHeaderForInitialAgentSecret(
agentSecret: AgentSecret
): CoValueHeader {
const agent = getAgentID(agentSecret);
return {
type: "comap",
ruleset: { type: "team", initialAdmin: agent },
meta: {
type: "account",
},
createdAt: null,
uniqueness: null,
};
}
export class Account extends Team {
get id(): AccountID {
return this.teamMap.id as AccountID;
}
getCurrentAgentID(): AgentID {
const agents = this.teamMap
.keys()
.filter((k): k is AgentID => k.startsWith("sealer_"));
if (agents.length !== 1) {
throw new Error(
"Expected exactly one agent in account, got " + agents.length
);
}
return agents[0]!;
}
}
export interface GeneralizedControlledAccount {
id: AccountIDOrAgentID;
agentSecret: AgentSecret;
currentAgentID: () => AgentID;
currentSignerID: () => SignerID;
currentSignerSecret: () => SignerSecret;
currentSealerID: () => SealerID;
currentSealerSecret: () => SealerSecret;
}
export class ControlledAccount
extends Account
implements GeneralizedControlledAccount
{
agentSecret: AgentSecret;
constructor(
agentSecret: AgentSecret,
teamMap: CoMap<AccountContent, AccountMeta>,
node: LocalNode
) {
super(teamMap, node);
this.agentSecret = agentSecret;
}
currentAgentID(): AgentID {
return getAgentID(this.agentSecret);
}
currentSignerID(): SignerID {
return getAgentSignerID(this.currentAgentID());
}
currentSignerSecret(): SignerSecret {
return getAgentSignerSecret(this.agentSecret);
}
currentSealerID(): SealerID {
return getAgentSealerID(this.currentAgentID());
}
currentSealerSecret(): SealerSecret {
return getAgentSealerSecret(this.agentSecret);
}
}
export class AnonymousControlledAccount
implements GeneralizedControlledAccount
{
agentSecret: AgentSecret;
constructor(agentSecret: AgentSecret) {
this.agentSecret = agentSecret;
}
get id(): AgentID {
return getAgentID(this.agentSecret);
}
currentAgentID(): AgentID {
return getAgentID(this.agentSecret);
}
currentSignerID(): SignerID {
return getAgentSignerID(this.currentAgentID());
}
currentSignerSecret(): SignerSecret {
return getAgentSignerSecret(this.agentSecret);
}
currentSealerID(): SealerID {
return getAgentSealerID(this.currentAgentID());
}
currentSealerSecret(): SealerSecret {
return getAgentSealerSecret(this.agentSecret);
}
}
export type AccountContent = TeamContent & { profile: CoID<Profile> };
export type AccountMeta = { type: "account" };
export type AccountID = CoID<CoMap<AccountContent, AccountMeta>>;
export type AccountIDOrAgentID = AgentID | AccountID;
export type AccountOrAgentID = AgentID | Account;
export type AccountOrAgentSecret = AgentSecret | Account;
export function isAccountID(id: AccountIDOrAgentID): id is AccountID {
return id.startsWith("co_");
}
export type ProfileContent = {
name: string;
};
export type ProfileMeta = { type: "profile" };
export type Profile = CoMap<ProfileContent, ProfileMeta>;

123
src/coValue.test.ts Normal file
View File

@@ -0,0 +1,123 @@
import { Transaction } from "./coValue.js";
import { LocalNode } from "./node.js";
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
test("Can create coValue with new agent credentials and add transaction to it", () => {
const [account, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(account, sessionID);
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.ownSessionID,
[transaction]
);
expect(
coValue.tryAddTransactions(
node.ownSessionID,
[transaction],
expectedNewHash,
sign(account.currentSignerSecret(), expectedNewHash)
)
).toBe(true);
});
test("transactions with wrong signature are rejected", () => {
const wrongAgent = newRandomAgentSecret();
const [agentSecret, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(agentSecret, sessionID);
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.ownSessionID,
[transaction]
);
expect(
coValue.tryAddTransactions(
node.ownSessionID,
[transaction],
expectedNewHash,
sign(getAgentSignerSecret(wrongAgent), expectedNewHash)
)
).toBe(false);
});
test("transactions with correctly signed, but wrong hash are rejected", () => {
const [account, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(account, sessionID);
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.ownSessionID,
[
{
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "wrong",
},
],
},
]
);
expect(
coValue.tryAddTransactions(
node.ownSessionID,
[transaction],
expectedNewHash,
sign(account.currentSignerSecret(), expectedNewHash)
)
).toBe(false);
});

547
src/coValue.ts Normal file
View File

@@ -0,0 +1,547 @@
import { randomBytes } from "@noble/hashes/utils";
import { ContentType } from "./contentType.js";
import { Static } from "./contentTypes/static.js";
import { CoStream } from "./contentTypes/coStream.js";
import { CoMap } from "./contentTypes/coMap.js";
import {
Encrypted,
Hash,
KeySecret,
Signature,
StreamingHash,
unseal,
shortHash,
sign,
verify,
encryptForTransaction,
decryptForTransaction,
KeyID,
decryptKeySecret,
getAgentSignerID,
getAgentSealerID,
} from "./crypto.js";
import { JsonObject, JsonValue } from "./jsonValue.js";
import { base58 } from "@scure/base";
import {
PermissionsDef as RulesetDef,
Team,
determineValidTransactions,
expectTeamContent,
isKeyForKeyField,
} from "./permissions.js";
import { LocalNode } from "./node.js";
import { CoValueKnownState, NewContentMessage } from "./sync.js";
import { RawCoID, SessionID, TransactionID } from "./ids.js";
import { CoList } from "./contentTypes/coList.js";
import {
AccountID,
AccountIDOrAgentID,
GeneralizedControlledAccount,
} from "./account.js";
export type CoValueHeader = {
type: ContentType["type"];
ruleset: RulesetDef;
meta: JsonObject | null;
createdAt: `2${string}` | null;
uniqueness: `z${string}` | null;
};
export function idforHeader(header: CoValueHeader): RawCoID {
const hash = shortHash(header);
return `co_z${hash.slice("shortHash_z".length)}`;
}
export function accountOrAgentIDfromSessionID(
sessionID: SessionID
): AccountIDOrAgentID {
return sessionID.split("_session")[0] as AccountIDOrAgentID;
}
export function newRandomSessionID(accountID: AccountIDOrAgentID): SessionID {
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
}
type SessionLog = {
transactions: Transaction[];
lastHash?: Hash;
streamingHash: StreamingHash;
lastSignature: Signature;
};
export type PrivateTransaction = {
privacy: "private";
madeAt: number;
keyUsed: KeyID;
encryptedChanges: Encrypted<
JsonValue[],
{ in: RawCoID; tx: TransactionID }
>;
};
export type TrustingTransaction = {
privacy: "trusting";
madeAt: number;
changes: JsonValue[];
};
export type Transaction = PrivateTransaction | TrustingTransaction;
export type DecryptedTransaction = {
txID: TransactionID;
changes: JsonValue[];
madeAt: number;
};
export class CoValue {
id: RawCoID;
node: LocalNode;
header: CoValueHeader;
sessions: { [key: SessionID]: SessionLog };
content?: ContentType;
listeners: Set<(content?: ContentType) => void> = new Set();
constructor(header: CoValueHeader, node: LocalNode) {
this.id = idforHeader(header);
this.header = header;
this.sessions = {};
this.node = node;
}
testWithDifferentAccount(
account: GeneralizedControlledAccount,
ownSessionID: SessionID
): CoValue {
const newNode = this.node.testWithDifferentAccount(
account,
ownSessionID
);
return newNode.expectCoValueLoaded(this.id);
}
knownState(): CoValueKnownState {
return {
id: this.id,
header: true,
sessions: Object.fromEntries(
Object.entries(this.sessions).map(([k, v]) => [
k,
v.transactions.length,
])
),
};
}
get meta(): JsonValue {
return this.header?.meta ?? null;
}
nextTransactionID(): TransactionID {
const sessionID = this.node.ownSessionID;
return {
sessionID,
txIndex: this.sessions[sessionID]?.transactions.length || 0,
};
}
tryAddTransactions(
sessionID: SessionID,
newTransactions: Transaction[],
givenExpectedNewHash: Hash | undefined,
newSignature: Signature
): boolean {
const signerID = getAgentSignerID(
this.node.resolveAccountAgent(
accountOrAgentIDfromSessionID(sessionID),
"Expected to know signer of transaction"
)
);
if (!signerID) {
console.warn(
"Unknown agent",
accountOrAgentIDfromSessionID(sessionID)
);
return false;
}
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
sessionID,
newTransactions
);
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
console.warn("Invalid hash", { expectedNewHash, givenExpectedNewHash });
return false;
}
if (!verify(newSignature, expectedNewHash, signerID)) {
console.warn(
"Invalid signature",
newSignature,
expectedNewHash,
signerID
);
return false;
}
const transactions = this.sessions[sessionID]?.transactions ?? [];
transactions.push(...newTransactions);
this.sessions[sessionID] = {
transactions,
lastHash: expectedNewHash,
streamingHash: newStreamingHash,
lastSignature: newSignature,
};
this.content = undefined;
const content = this.getCurrentContent();
for (const listener of this.listeners) {
listener(content);
}
return true;
}
subscribe(listener: (content?: ContentType) => void): () => void {
this.listeners.add(listener);
listener(this.getCurrentContent());
return () => {
this.listeners.delete(listener);
};
}
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();
let transaction: Transaction;
if (privacy === "private") {
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
if (!keySecret) {
throw new Error(
"Can't make transaction without read key secret"
);
}
transaction = {
privacy: "private",
madeAt,
keyUsed: keyID,
encryptedChanges: encryptForTransaction(changes, keySecret, {
in: this.id,
tx: this.nextTransactionID(),
}),
};
} else {
transaction = {
privacy: "trusting",
madeAt,
changes,
};
}
const sessionID = this.node.ownSessionID;
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
transaction,
]);
const signature = sign(
this.node.account.currentSignerSecret(),
expectedNewHash
);
const success = this.tryAddTransactions(
sessionID,
[transaction],
expectedNewHash,
signature
);
if (success) {
void this.node.sync.syncCoValue(this);
}
return success;
}
getCurrentContent(): ContentType {
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 === "costream") {
this.content = new CoStream(this);
} else if (this.header.type === "static") {
this.content = new Static(this);
} else {
throw new Error(`Unknown coValue type ${this.header.type}`);
}
return this.content;
}
getValidSortedTransactions(): DecryptedTransaction[] {
const validTransactions = determineValidTransactions(this);
const allTransactions: DecryptedTransaction[] = validTransactions
.map(({ txID, tx }) => {
if (tx.privacy === "trusting") {
return {
txID,
madeAt: tx.madeAt,
changes: tx.changes,
};
} else {
const readKey = this.getReadKey(tx.keyUsed);
if (!readKey) {
return undefined;
} else {
const decrytedChanges = decryptForTransaction(
tx.encryptedChanges,
readKey,
{
in: this.id,
tx: txID,
}
);
if (!decrytedChanges) {
console.error(
"Failed to decrypt transaction despite having key"
);
return undefined;
}
return {
txID,
madeAt: tx.madeAt,
changes: decrytedChanges,
};
}
}
})
.filter((x): x is Exclude<typeof x, undefined> => !!x);
allTransactions.sort(
(a, b) =>
a.madeAt - b.madeAt ||
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
a.txID.txIndex - b.txID.txIndex
);
return allTransactions;
}
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
if (this.header.ruleset.type === "team") {
const content = expectTeamContent(this.getCurrentContent());
const currentKeyId = content.get("readKey");
if (!currentKeyId) {
throw new Error("No readKey set");
}
const secret = this.getReadKey(currentKeyId);
return {
secret: secret,
id: currentKeyId,
};
} else if (this.header.ruleset.type === "ownedByTeam") {
return this.node
.expectCoValueLoaded(this.header.ruleset.team)
.getCurrentReadKey();
} else {
throw new Error(
"Only teams or values owned by teams have read secrets"
);
}
}
getReadKey(keyID: KeyID): KeySecret | undefined {
if (this.header.ruleset.type === "team") {
const content = expectTeamContent(this.getCurrentContent());
// Try to find key revelation for us
const readKeyEntry = content.getLastEntry(`${keyID}_for_${this.node.account.id}`);
if (readKeyEntry) {
const revealer = accountOrAgentIDfromSessionID(
readKeyEntry.txID.sessionID
);
const revealerAgent = this.node.resolveAccountAgent(
revealer,
"Expected to know revealer"
);
const secret = unseal(
readKeyEntry.value,
this.node.account.currentSealerSecret(),
getAgentSealerID(revealerAgent),
{
in: this.id,
tx: readKeyEntry.txID,
}
);
if (secret) return secret as KeySecret;
}
// Try to find indirect revelation through previousKeys
for (const field of content.keys()) {
if (isKeyForKeyField(field) && field.startsWith(keyID)) {
const encryptingKeyID = field.split("_for_")[1] as KeyID;
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
if (!encryptingKeySecret) {
continue;
}
const encryptedPreviousKey = content.get(field)!;
const secret = decryptKeySecret(
{
encryptedID: keyID,
encryptingID: encryptingKeyID,
encrypted: encryptedPreviousKey,
},
encryptingKeySecret
);
if (secret) {
return secret;
} else {
console.error(
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`
);
}
}
}
return undefined;
} else if (this.header.ruleset.type === "ownedByTeam") {
return this.node
.expectCoValueLoaded(this.header.ruleset.team)
.getReadKey(keyID);
} else {
throw new Error(
"Only teams or values owned by teams have read secrets"
);
}
}
getTeam(): Team {
if (this.header.ruleset.type !== "ownedByTeam") {
throw new Error("Only values owned by teams have teams");
}
return new Team(
expectTeamContent(
this.node
.expectCoValueLoaded(this.header.ruleset.team)
.getCurrentContent()
),
this.node
);
}
getTx(txID: TransactionID): Transaction | undefined {
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
}
newContentSince(
knownState: CoValueKnownState | undefined
): NewContentMessage | undefined {
const newContent: NewContentMessage = {
action: "content",
id: this.id,
header: knownState?.header ? undefined : this.header,
new: Object.fromEntries(
Object.entries(this.sessions)
.map(([sessionID, log]) => {
const newTransactions = log.transactions.slice(
knownState?.sessions[sessionID as SessionID] || 0
);
if (
newTransactions.length === 0 ||
!log.lastHash ||
!log.lastSignature
) {
return undefined;
}
return [
sessionID,
{
after:
knownState?.sessions[
sessionID as SessionID
] || 0,
newTransactions,
lastSignature: log.lastSignature,
},
];
})
.filter((x): x is Exclude<typeof x, undefined> => !!x)
),
};
if (
!newContent.header &&
Object.keys(newContent.new).length === 0
) {
return undefined;
}
return newContent;
}
getDependedOnCoValues(): RawCoID[] {
return this.header.ruleset.type === "team"
? expectTeamContent(this.getCurrentContent())
.keys()
.filter((k): k is AccountID => k.startsWith("co_"))
: this.header.ruleset.type === "ownedByTeam"
? [this.header.ruleset.team]
: [];
}
}

175
src/contentType.test.ts Normal file
View File

@@ -0,0 +1,175 @@
import { accountOrAgentIDfromSessionID } from "./coValue.js";
import { createdNowUnique } from "./crypto.js";
import { LocalNode } from "./node.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
test("Empty COJSON Map works", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.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 node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
content.edit((editable) => {
editable.set("hello", "world", "trusting");
expect(editable.get("hello")).toEqual("world");
editable.set("foo", "bar", "trusting");
expect(editable.get("foo")).toEqual("bar");
expect([...editable.keys()]).toEqual(["hello", "foo"]);
editable.delete("foo", "trusting");
expect(editable.get("foo")).toEqual(undefined);
});
});
test("Can get map entry values at different points in time", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
content.edit((editable) => {
const beforeA = Date.now();
while (Date.now() < beforeA + 10) {}
editable.set("hello", "A", "trusting");
const beforeB = Date.now();
while (Date.now() < beforeB + 10) {}
editable.set("hello", "B", "trusting");
const beforeC = Date.now();
while (Date.now() < beforeC + 10) {}
editable.set("hello", "C", "trusting");
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");
});
});
test("Can get all historic values of key", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
content.edit((editable) => {
editable.set("hello", "A", "trusting");
const txA = editable.getLastTxID("hello");
editable.set("hello", "B", "trusting");
const txB = editable.getLastTxID("hello");
editable.delete("hello", "trusting");
const txDel = editable.getLastTxID("hello");
editable.set("hello", "C", "trusting");
const txC = editable.getLastTxID("hello");
expect(editable.getHistory("hello")).toEqual([
{
txID: txA,
value: "A",
at: txA && coValue.getTx(txA)?.madeAt,
},
{
txID: txB,
value: "B",
at: txB && coValue.getTx(txB)?.madeAt,
},
{
txID: txDel,
value: undefined,
at: txDel && coValue.getTx(txDel)?.madeAt,
},
{
txID: txC,
value: "C",
at: txC && coValue.getTx(txC)?.madeAt,
},
]);
});
});
test("Can get last tx ID for a key", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
content.edit((editable) => {
expect(editable.getLastTxID("hello")).toEqual(undefined);
editable.set("hello", "A", "trusting");
const sessionID = editable.getLastTxID("hello")?.sessionID;
expect(sessionID && accountOrAgentIDfromSessionID(sessionID)).toEqual(
node.account.id
);
expect(editable.getLastTxID("hello")?.txIndex).toEqual(0);
editable.set("hello", "B", "trusting");
expect(editable.getLastTxID("hello")?.txIndex).toEqual(1);
editable.set("hello", "C", "trusting");
expect(editable.getLastTxID("hello")?.txIndex).toEqual(2);
});
});

26
src/contentType.ts Normal file
View File

@@ -0,0 +1,26 @@
import { JsonObject, JsonValue } from "./jsonValue.js";
import { RawCoID } from "./ids.js";
import { CoMap } from "./contentTypes/coMap.js";
import { CoStream } from "./contentTypes/coStream.js";
import { Static } from "./contentTypes/static.js";
import { CoList } from "./contentTypes/coList.js";
export type CoID<T extends ContentType> = RawCoID & {
readonly __type: T;
};
export type ContentType =
| CoMap<{ [key: string]: JsonValue }, JsonObject | null>
| CoList<JsonValue, JsonObject | null>
| CoStream<JsonValue, JsonObject | null>
| Static<JsonObject>;
export function expectMap(
content: ContentType
): CoMap<{ [key: string]: string }, JsonObject | null> {
if (content.type !== "comap") {
throw new Error("Expected map");
}
return content as CoMap<{ [key: string]: string }, JsonObject | null>;
}

View File

@@ -0,0 +1,24 @@
import { JsonObject, JsonValue } from '../jsonValue.js';
import { CoID } from '../contentType.js';
import { CoValue } from '../coValue.js';
export class CoList<T extends JsonValue, Meta extends JsonObject | null = null> {
id: CoID<CoList<T, Meta>>;
type = "colist" as const;
coValue: CoValue;
constructor(coValue: CoValue) {
this.id = coValue.id as CoID<CoList<T, Meta>>;
this.coValue = coValue;
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
}
subscribe(listener: (coMap: CoList<T, Meta>) => void): () => void {
return this.coValue.subscribe((content) => {
listener(content as CoList<T, Meta>);
});
}
}

221
src/contentTypes/coMap.ts Normal file
View File

@@ -0,0 +1,221 @@
import { JsonObject, JsonValue } from '../jsonValue.js';
import { TransactionID } from '../ids.js';
import { CoID } from '../contentType.js';
import { CoValue, accountOrAgentIDfromSessionID } from '../coValue.js';
import { AccountID, isAccountID } from '../account.js';
type MapOp<K extends string, V extends JsonValue> = {
txID: TransactionID;
madeAt: number;
changeIdx: number;
} & MapOpPayload<K, V>;
// TODO: add after TransactionID[] for conflicts/ordering
export type MapOpPayload<K extends string, V extends JsonValue> = {
op: "insert";
key: K;
value: V;
} |
{
op: "delete";
key: K;
};
export type MapK<M extends { [key: string]: JsonValue; }> = keyof M & string;
export type MapV<M extends { [key: string]: JsonValue; }> = M[MapK<M>];
export type MapM<M extends { [key: string]: JsonValue; }> = {
[KK in MapK<M>]: M[KK];
}
export class CoMap<
M extends { [key: string]: JsonValue; },
Meta extends JsonObject | null = null,
> {
id: CoID<CoMap<MapM<M>, Meta>>;
coValue: CoValue;
type = "comap" as const;
ops: {
[KK in MapK<M>]?: MapOp<KK, M[KK]>[];
};
constructor(coValue: CoValue) {
this.id = coValue.id as CoID<CoMap<MapM<M>, Meta>>;
this.coValue = coValue;
this.ops = {};
this.fillOpsFromCoValue();
}
protected fillOpsFromCoValue() {
this.ops = {};
for (const { txID, changes, madeAt } of this.coValue.getValidSortedTransactions()) {
for (const [changeIdx, changeUntyped] of (
changes
).entries()) {
const change = changeUntyped as MapOpPayload<MapK<M>, MapV<M>>;
let entries = this.ops[change.key];
if (!entries) {
entries = [];
this.ops[change.key] = entries;
}
entries.push({
txID,
madeAt,
changeIdx,
...(change as MapOpPayload<MapK<M>, MapV<M>>),
});
}
}
}
keys(): MapK<M>[] {
return Object.keys(this.ops) as MapK<M>[];
}
get<K extends MapK<M>>(key: K): M[K] | undefined {
const ops = this.ops[key];
if (!ops) {
return undefined;
}
const lastEntry = ops[ops.length - 1]!;
if (lastEntry.op === "delete") {
return undefined;
} else {
return lastEntry.value;
}
}
getAtTime<K extends MapK<M>>(key: K, time: number): M[K] | undefined {
const ops = this.ops[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;
}
}
getLastEditor<K extends MapK<M>>(key: K): AccountID | undefined {
const tx = this.getLastTxID(key);
if (!tx) {
return undefined;
}
const accountID = accountOrAgentIDfromSessionID(tx.sessionID);
if (isAccountID(accountID)) {
return accountID;
} else {
return undefined;
}
}
getLastTxID<K extends MapK<M>>(key: K): TransactionID | undefined {
const ops = this.ops[key];
if (!ops) {
return undefined;
}
const lastEntry = ops[ops.length - 1]!;
return lastEntry.txID;
}
getLastEntry<K extends MapK<M>>(key: K): { at: number; txID: TransactionID; value: M[K]; } | undefined {
const ops = this.ops[key];
if (!ops) {
return undefined;
}
const lastEntry = ops[ops.length - 1]!;
if (lastEntry.op === "delete") {
return undefined;
} else {
return { at: lastEntry.madeAt, txID: lastEntry.txID, value: lastEntry.value };
}
}
getHistory<K extends MapK<M>>(key: K): { at: number; txID: TransactionID; value: M[K] | undefined; }[] {
const ops = this.ops[key];
if (!ops) {
return [];
}
const history: { at: number; txID: TransactionID; value: M[K] | undefined; }[] = [];
for (const op of ops) {
if (op.op === "delete") {
history.push({ at: op.madeAt, txID: op.txID, value: undefined });
} else {
history.push({ at: op.madeAt, txID: op.txID, value: op.value });
}
}
return history;
}
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<M, Meta>) => void): CoMap<M, Meta> {
const editable = new WriteableCoMap<M, Meta>(this.coValue);
changer(editable);
return new CoMap(this.coValue);
}
subscribe(listener: (coMap: CoMap<M, Meta>) => void): () => void {
return this.coValue.subscribe((content) => {
listener(content as CoMap<M, Meta>);
});
}
}
export class WriteableCoMap<
M extends { [key: string]: JsonValue; },
Meta extends JsonObject | null = null,
> extends CoMap<M, Meta> {
set<K extends MapK<M>>(key: K, value: M[K], privacy: "private" | "trusting" = "private"): void {
this.coValue.makeTransaction([
{
op: "insert",
key,
value,
},
], privacy);
this.fillOpsFromCoValue();
}
delete(key: MapK<M>, privacy: "private" | "trusting" = "private"): void {
this.coValue.makeTransaction([
{
op: "delete",
key,
},
], privacy);
this.fillOpsFromCoValue();
}
}

View File

@@ -0,0 +1,24 @@
import { JsonObject, JsonValue } from '../jsonValue.js';
import { CoID } from '../contentType.js';
import { CoValue } from '../coValue.js';
export class CoStream<T extends JsonValue, Meta extends JsonObject | null = null> {
id: CoID<CoStream<T, Meta>>;
type = "costream" as const;
coValue: CoValue;
constructor(coValue: CoValue) {
this.id = coValue.id as CoID<CoStream<T, Meta>>;
this.coValue = coValue;
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
}
subscribe(listener: (coMap: CoStream<T, Meta>) => void): () => void {
return this.coValue.subscribe((content) => {
listener(content as CoStream<T, Meta>);
});
}
}

View File

@@ -0,0 +1,22 @@
import { JsonObject } from '../jsonValue.js';
import { CoID } from '../contentType.js';
import { CoValue } from '../coValue.js';
export class Static<T extends JsonObject> {
id: CoID<Static<T>>;
type = "static" as const;
coValue: CoValue;
constructor(coValue: CoValue) {
this.id = coValue.id as CoID<Static<T>>;
this.coValue = coValue;
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
}
subscribe(_listener: (coMap: Static<T>) => void): () => void {
throw new Error("Method not implemented.");
}
}

183
src/crypto.test.ts Normal file
View File

@@ -0,0 +1,183 @@
import {
getSealerID,
getSignerID,
secureHash,
newRandomSealer,
newRandomSigner,
seal,
sign,
unseal,
verify,
shortHash,
newRandomKeySecret,
encryptForTransaction,
decryptForTransaction,
encryptKeySecret,
decryptKeySecret,
} from './crypto.js';
import { base58, base64url } from "@scure/base";
import { x25519 } from "@noble/curves/ed25519";
import { xsalsa20_poly1305 } from "@noble/ciphers/salsa";
import { blake3 } from "@noble/hashes/blake3";
import stableStringify from "fast-json-stable-stringify";
test("Signatures round-trip and use stable stringify", () => {
const data = { b: "world", a: "hello" };
const signer = newRandomSigner();
const signature = sign(signer, data);
expect(signature).toMatch(/^signature_z/);
expect(
verify(signature, { a: "hello", b: "world" }, getSignerID(signer))
).toBe(true);
});
test("Invalid signatures don't verify", () => {
const data = { b: "world", a: "hello" };
const signer = newRandomSigner();
const signer2 = newRandomSigner();
const wrongSignature = sign(signer2, data);
expect(verify(wrongSignature, data, getSignerID(signer))).toBe(false);
});
test("encrypting round-trips, but invalid receiver can't unseal", () => {
const data = { b: "world", a: "hello" };
const sender = newRandomSealer();
const sealer = newRandomSealer();
const wrongSealer = newRandomSealer();
const nOnceMaterial = {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
} as const;
const sealed = seal(
data,
sender,
getSealerID(sealer),
nOnceMaterial
);
expect(
unseal(sealed, sealer, getSealerID(sender), nOnceMaterial)
).toEqual(data);
expect(
() => unseal(sealed, wrongSealer, getSealerID(sender), nOnceMaterial)
).toThrow(/Wrong tag/);
// trying with wrong sealer secret, by hand
const nOnce = blake3(
new TextEncoder().encode(stableStringify(nOnceMaterial))
).slice(0, 24);
const sealer3priv = base58.decode(
wrongSealer.substring("sealerSecret_z".length)
);
const senderPub = base58.decode(
getSealerID(sender).substring("sealer_z".length)
);
const sealedBytes = base64url.decode(
sealed.substring("sealed_U".length)
);
const sharedSecret = x25519.getSharedSecret(sealer3priv, senderPub);
expect(() => {
const _ = xsalsa20_poly1305(sharedSecret, nOnce).decrypt(sealedBytes);
}).toThrow("Wrong tag");
});
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 for transactions round-trips", () => {
const { secret } = newRandomKeySecret();
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
});
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
});
const decrypted1 = decryptForTransaction(encrypted1, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
});
const decrypted2 = decryptForTransaction(encrypted2, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
});
expect([decrypted1, decrypted2]).toEqual([{ a: "hello" }, { b: "world" }]);
});
test("Encryption for transactions doesn't decrypt with a wrong key", () => {
const { secret } = newRandomKeySecret();
const { secret: secret2 } = newRandomKeySecret();
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
});
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
});
const decrypted1 = decryptForTransaction(encrypted1, secret2, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
});
const decrypted2 = decryptForTransaction(encrypted2, secret2, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
});
expect([decrypted1, decrypted2]).toEqual([undefined, undefined]);
});
test("Encryption of keySecrets round-trips", () => {
const toEncrypt = newRandomKeySecret();
const encrypting = newRandomKeySecret();
const keys = {
toEncrypt,
encrypting,
};
const encrypted = encryptKeySecret(keys);
const decrypted = decryptKeySecret(encrypted, encrypting.secret);
expect(decrypted).toEqual(toEncrypt.secret);
});
test("Encryption of keySecrets doesn't decrypt with a wrong key", () => {
const toEncrypt = newRandomKeySecret();
const encrypting = newRandomKeySecret();
const encryptingWrong = newRandomKeySecret();
const keys = {
toEncrypt,
encrypting,
};
const encrypted = encryptKeySecret(keys);
const decrypted = decryptKeySecret(encrypted, encryptingWrong.secret);
expect(decrypted).toBeUndefined();
});

353
src/crypto.ts Normal file
View File

@@ -0,0 +1,353 @@
import { ed25519, x25519 } from "@noble/curves/ed25519";
import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa";
import { JsonValue } from './jsonValue.js';
import { base58, base64url } from "@scure/base";
import stableStringify from "fast-json-stable-stringify";
import { blake3 } from "@noble/hashes/blake3";
import { randomBytes } from "@noble/ciphers/webcrypto/utils";
import { AgentID, RawCoID, TransactionID } from './ids.js';
export type SignerSecret = `signerSecret_z${string}`;
export type SignerID = `signer_z${string}`;
export type Signature = `signature_z${string}`;
export type SealerSecret = `sealerSecret_z${string}`;
export type SealerID = `sealer_z${string}`;
export type Sealed<T> = `sealed_U${string}` & { __type: T };
export type AgentSecret = `${SealerSecret}/${SignerSecret}`;
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
export function newRandomSigner(): SignerSecret {
return `signerSecret_z${base58.encode(
ed25519.utils.randomPrivateKey()
)}`;
}
export function signerSecretToBytes(secret: SignerSecret): Uint8Array {
return base58.decode(secret.substring("signerSecret_z".length));
}
export function signerSecretFromBytes(bytes: Uint8Array): SignerSecret {
return `signerSecret_z${base58.encode(bytes)}`;
}
export function getSignerID(secret: SignerSecret): SignerID {
return `signer_z${base58.encode(
ed25519.getPublicKey(
base58.decode(secret.substring("signerSecret_z".length))
)
)}`;
}
export function sign(secret: SignerSecret, message: JsonValue): Signature {
const signature = ed25519.sign(
textEncoder.encode(stableStringify(message)),
base58.decode(secret.substring("signerSecret_z".length))
);
return `signature_z${base58.encode(signature)}`;
}
export function verify(
signature: Signature,
message: JsonValue,
id: SignerID
): boolean {
return ed25519.verify(
base58.decode(signature.substring("signature_z".length)),
textEncoder.encode(stableStringify(message)),
base58.decode(id.substring("signer_z".length))
);
}
export function newRandomSealer(): SealerSecret {
return `sealerSecret_z${base58.encode(x25519.utils.randomPrivateKey())}`;
}
export function sealerSecretToBytes(secret: SealerSecret): Uint8Array {
return base58.decode(secret.substring("sealerSecret_z".length));
}
export function sealerSecretFromBytes(bytes: Uint8Array): SealerSecret {
return `sealerSecret_z${base58.encode(bytes)}`;
}
export function getSealerID(secret: SealerSecret): SealerID {
return `sealer_z${base58.encode(
x25519.getPublicKey(
base58.decode(secret.substring("sealerSecret_z".length))
)
)}`;
}
export function newRandomAgentSecret(): AgentSecret {
return `${newRandomSealer()}/${newRandomSigner()}`;
}
export function agentSecretToBytes(secret: AgentSecret): Uint8Array {
const [sealerSecret, signerSecret] = secret.split("/");
return new Uint8Array([
...sealerSecretToBytes(sealerSecret as SealerSecret),
...signerSecretToBytes(signerSecret as SignerSecret),
]);
}
export function agentSecretFromBytes(bytes: Uint8Array): AgentSecret {
const sealerSecret = sealerSecretFromBytes(
bytes.slice(0, 32)
);
const signerSecret = signerSecretFromBytes(
bytes.slice(32)
);
return `${sealerSecret}/${signerSecret}`;
}
export function getAgentID(secret: AgentSecret): AgentID {
const [sealerSecret, signerSecret] = secret.split("/");
return `${getSealerID(
sealerSecret as SealerSecret
)}/${getSignerID(signerSecret as SignerSecret)}`;
}
export function getAgentSignerID(agentId: AgentID): SignerID {
return agentId.split("/")[1] as SignerID;
}
export function getAgentSignerSecret(agentSecret: AgentSecret): SignerSecret {
return agentSecret.split("/")[1] as SignerSecret;
}
export function getAgentSealerID(agentId: AgentID): SealerID {
return agentId.split("/")[0] as SealerID;
}
export function getAgentSealerSecret(agentSecret: AgentSecret): SealerSecret {
return agentSecret.split("/")[0] as SealerSecret;
}
export function seal<T extends JsonValue>(
message: T,
from: SealerSecret,
to: SealerID,
nOnceMaterial: { in: RawCoID; tx: TransactionID }
): Sealed<T> {
const nOnce = blake3(
textEncoder.encode(stableStringify(nOnceMaterial))
).slice(0, 24);
const sealerPub = base58.decode(to.substring("sealer_z".length));
const senderPriv = base58.decode(
from.substring("sealerSecret_z".length)
);
const plaintext = textEncoder.encode(stableStringify(message));
const sharedSecret = x25519.getSharedSecret(
senderPriv,
sealerPub
);
const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt(
plaintext
);
return `sealed_U${base64url.encode(
sealedBytes
)}` as Sealed<T>
}
export function unseal<T extends JsonValue>(
sealed: Sealed<T>,
sealer: SealerSecret,
from: SealerID,
nOnceMaterial: { in: RawCoID; tx: TransactionID }
): T | undefined {
const nOnce = blake3(
textEncoder.encode(stableStringify(nOnceMaterial))
).slice(0, 24);
const sealerPriv = base58.decode(
sealer.substring("sealerSecret_z".length)
);
const senderPub = base58.decode(from.substring("sealer_z".length));
const sealedBytes = base64url.decode(sealed.substring("sealed_U".length));
const sharedSecret = x25519.getSharedSecret(sealerPriv, senderPub);
const plaintext = xsalsa20_poly1305(sharedSecret, nOnce).decrypt(
sealedBytes
);
try {
return JSON.parse(textDecoder.decode(plaintext));
} catch (e) {
console.error("Failed to decrypt/parse sealed message", 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<typeof blake3.create>;
constructor(fromClone?: ReturnType<typeof blake3.create>) {
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 Encrypted<
T extends JsonValue,
N extends JsonValue
> = `encrypted_U${string}` & { __type: T; __nOnceMaterial: N };
export type KeySecret = `keySecret_z${string}`;
export type KeyID = `key_z${string}`;
export function newRandomKeySecret(): { secret: KeySecret; id: KeyID } {
return {
secret: `keySecret_z${base58.encode(randomBytes(32))}`,
id: `key_z${base58.encode(randomBytes(12))}`,
};
}
function encrypt<T extends JsonValue, N extends JsonValue>(
value: T,
keySecret: KeySecret,
nOnceMaterial: N
): Encrypted<T, N> {
const keySecretBytes = base58.decode(
keySecret.substring("keySecret_z".length)
);
const nOnce = blake3(
textEncoder.encode(stableStringify(nOnceMaterial))
).slice(0, 24);
const plaintext = textEncoder.encode(stableStringify(value));
const ciphertext = xsalsa20(keySecretBytes, nOnce, plaintext);
return `encrypted_U${base64url.encode(ciphertext)}` as Encrypted<T, N>;
}
export function encryptForTransaction<T extends JsonValue>(
value: T,
keySecret: KeySecret,
nOnceMaterial: { in: RawCoID; tx: TransactionID }
): Encrypted<T, { in: RawCoID; tx: TransactionID }> {
return encrypt(value, keySecret, nOnceMaterial);
}
export function encryptKeySecret(keys: {
toEncrypt: { id: KeyID; secret: KeySecret };
encrypting: { id: KeyID; secret: KeySecret };
}): {
encryptedID: KeyID;
encryptingID: KeyID;
encrypted: Encrypted<KeySecret, { encryptedID: KeyID; encryptingID: KeyID }>;
} {
const nOnceMaterial = {
encryptedID: keys.toEncrypt.id,
encryptingID: keys.encrypting.id,
};
return {
encryptedID: keys.toEncrypt.id,
encryptingID: keys.encrypting.id,
encrypted: encrypt(
keys.toEncrypt.secret,
keys.encrypting.secret,
nOnceMaterial
),
};
}
function decrypt<T extends JsonValue, N extends JsonValue>(
encrypted: Encrypted<T, N>,
keySecret: KeySecret,
nOnceMaterial: N
): T | undefined {
const keySecretBytes = base58.decode(
keySecret.substring("keySecret_z".length)
);
const nOnce = blake3(
textEncoder.encode(stableStringify(nOnceMaterial))
).slice(0, 24);
const ciphertext = base64url.decode(
encrypted.substring("encrypted_U".length)
);
const plaintext = xsalsa20(keySecretBytes, nOnce, ciphertext);
try {
return JSON.parse(textDecoder.decode(plaintext));
} catch (e) {
return undefined;
}
}
export function decryptForTransaction<T extends JsonValue>(
encrypted: Encrypted<T, { in: RawCoID; tx: TransactionID }>,
keySecret: KeySecret,
nOnceMaterial: { in: RawCoID; tx: TransactionID }
): T | undefined {
return decrypt(encrypted, keySecret, nOnceMaterial);
}
export function decryptKeySecret(
encryptedInfo: {
encryptedID: KeyID;
encryptingID: KeyID;
encrypted: Encrypted<KeySecret, { encryptedID: KeyID; encryptingID: KeyID }>;
},
sealingSecret: KeySecret
): KeySecret | undefined {
const nOnceMaterial = {
encryptedID: encryptedInfo.encryptedID,
encryptingID: encryptedInfo.encryptingID,
};
return decrypt(encryptedInfo.encrypted, sealingSecret, nOnceMaterial);
}
export function uniquenessForHeader(): `z${string}` {
return `z${base58.encode(randomBytes(12))}`;
}
export function createdNowUnique(): {createdAt: `2${string}`, uniqueness: `z${string}`} {
const createdAt = (new Date()).toISOString() as `2${string}`;
return {
createdAt,
uniqueness: uniquenessForHeader(),
}
}

13
src/ids.ts Normal file
View File

@@ -0,0 +1,13 @@
import { AccountIDOrAgentID } from './account.js';
export type RawCoID = `co_z${string}`;
export type TransactionID = { sessionID: SessionID; txIndex: number };
export type AgentID = `sealer_z${string}/signer_z${string}`;
export function isAgentID(id: string): id is AgentID {
return typeof id === "string" && id.startsWith("sealer_") && id.includes("/signer_");
}
export type SessionID = `${AccountIDOrAgentID}_session_z${string}`;

32
src/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import { CoValue, newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import { CoMap } from "./contentTypes/coMap.js";
import { agentSecretFromBytes, agentSecretToBytes } from "./crypto.js";
import { connectedPeers } from "./streamUtils.js";
import type { SessionID } from "./ids.js";
import type { CoID, ContentType } from "./contentType.js";
import type { JsonValue } from "./jsonValue.js";
import type { SyncMessage } from "./sync.js";
import type { AgentSecret } from "./crypto.js";
type Value = JsonValue | ContentType;
const internals = {
agentSecretFromBytes,
agentSecretToBytes,
newRandomSessionID,
connectedPeers
};
export { LocalNode, CoValue, CoMap, internals };
export type {
Value,
JsonValue,
ContentType,
CoID,
AgentSecret,
SessionID,
SyncMessage,
};

6
src/jsonValue.ts Normal file
View File

@@ -0,0 +1,6 @@
import { CoID, ContentType } from './contentType.js';
export type JsonAtom = string | number | boolean | null;
export type JsonValue = JsonAtom | JsonArray | JsonObject | CoID<ContentType>;
export type JsonArray = JsonValue[];
export type JsonObject = { [key: string]: JsonValue; };

324
src/node.ts Normal file
View File

@@ -0,0 +1,324 @@
import {
AgentSecret,
createdNowUnique,
getAgentID,
getAgentSealerID,
getAgentSealerSecret,
newRandomAgentSecret,
newRandomKeySecret,
seal,
} from "./crypto.js";
import { CoValue, CoValueHeader, newRandomSessionID } from "./coValue.js";
import { Team, TeamContent, expectTeamContent } from "./permissions.js";
import { Peer, SyncManager } from "./sync.js";
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
import { CoID, ContentType } from "./contentType.js";
import {
Account,
AccountMeta,
AccountIDOrAgentID,
accountHeaderForInitialAgentSecret,
GeneralizedControlledAccount,
ControlledAccount,
AnonymousControlledAccount,
AccountID,
Profile,
AccountContent,
ProfileContent,
ProfileMeta,
} from "./account.js";
import { CoMap } from "./index.js";
export class LocalNode {
coValues: { [key: RawCoID]: CoValueState } = {};
account: GeneralizedControlledAccount;
ownSessionID: SessionID;
sync = new SyncManager(this);
constructor(
account: GeneralizedControlledAccount,
ownSessionID: SessionID
) {
this.account = account;
this.ownSessionID = ownSessionID;
}
static withNewlyCreatedAccount(name: string): {
node: LocalNode;
accountID: AccountID;
accountSecret: AgentSecret;
sessionID: SessionID;
} {
const throwawayAgent = newRandomAgentSecret();
const setupNode = new LocalNode(
new AnonymousControlledAccount(throwawayAgent),
newRandomSessionID(getAgentID(throwawayAgent))
);
const account = setupNode.createAccount(name);
const nodeWithAccount = account.node.testWithDifferentAccount(
account,
newRandomSessionID(account.id)
);
return {
node: nodeWithAccount,
accountID: account.id,
accountSecret: account.agentSecret,
sessionID: nodeWithAccount.ownSessionID,
};
}
static async withLoadedAccount(accountID: AccountID, accountSecret: AgentSecret, sessionID: SessionID, peersToLoadFrom: Peer[]): Promise<LocalNode> {
const loadingNode = new LocalNode(new AnonymousControlledAccount(accountSecret), newRandomSessionID(accountID));
const accountPromise = loadingNode.load(accountID);
for (const peer of peersToLoadFrom) {
loadingNode.sync.addPeer(peer);
}
const account = await accountPromise;
// since this is all synchronous, we can just swap out nodes for the SyncManager
const node = loadingNode.testWithDifferentAccount(new ControlledAccount(accountSecret, account, loadingNode), sessionID);
node.sync = loadingNode.sync;
node.sync.local = node;
return node;
}
createCoValue(header: CoValueHeader): CoValue {
const coValue = new CoValue(header, this);
this.coValues[coValue.id] = { state: "loaded", coValue: coValue };
void this.sync.syncCoValue(coValue);
return coValue;
}
loadCoValue(id: RawCoID): Promise<CoValue> {
let entry = this.coValues[id];
if (!entry) {
entry = newLoadingState();
this.coValues[id] = entry;
this.sync.loadFromPeers(id);
}
if (entry.state === "loaded") {
return Promise.resolve(entry.coValue);
}
return entry.done;
}
async load<T extends ContentType>(id: CoID<T>): Promise<T> {
return (await this.loadCoValue(id)).getCurrentContent() as T;
}
async loadProfile(id: AccountID): Promise<Profile> {
const account = await this.load<CoMap<AccountContent>>(id);
const profileID = account.get("profile");
if (!profileID) {
throw new Error(`Account ${id} has no profile`);
}
return (await this.loadCoValue(profileID)).getCurrentContent() as Profile;
}
expectCoValueLoaded(id: RawCoID, expectation?: string): CoValue {
const entry = this.coValues[id];
if (!entry) {
throw new Error(
`${expectation ? expectation + ": " : ""}Unknown CoValue ${id}`
);
}
if (entry.state === "loading") {
throw new Error(
`${
expectation ? expectation + ": " : ""
}CoValue ${id} not yet loaded`
);
}
return entry.coValue;
}
expectProfileLoaded(id: AccountID, expectation?: string): Profile {
const account = this.expectCoValueLoaded(id, expectation);
const profileID = expectTeamContent(account.getCurrentContent()).get("profile");
if (!profileID) {
throw new Error(
`${
expectation ? expectation + ": " : ""
}Account ${id} has no profile`
);
}
return this.expectCoValueLoaded(profileID, expectation).getCurrentContent() as Profile;
}
createAccount(name: string): ControlledAccount {
const agentSecret = newRandomAgentSecret();
const account = this.createCoValue(
accountHeaderForInitialAgentSecret(agentSecret)
).testWithDifferentAccount(
new AnonymousControlledAccount(agentSecret),
newRandomSessionID(getAgentID(agentSecret))
);
const accountAsTeam = new Team(expectTeamContent(account.getCurrentContent()), account.node);
accountAsTeam.teamMap.edit((editable) => {
editable.set(getAgentID(agentSecret), "admin", "trusting");
const readKey = newRandomKeySecret();
editable.set(
`${readKey.id}_for_${getAgentID(agentSecret)}`,
seal(
readKey.secret,
getAgentSealerSecret(agentSecret),
getAgentSealerID(getAgentID(agentSecret)),
{
in: account.id,
tx: account.nextTransactionID(),
}
),
"trusting"
);
editable.set("readKey", readKey.id, "trusting");
});
const controlledAccount = new ControlledAccount(
agentSecret,
account.getCurrentContent() as CoMap<AccountContent, AccountMeta>,
account.node
);
const profile = accountAsTeam.createMap<ProfileContent, ProfileMeta>({ type: "profile" });
profile.edit((editable) => {
editable.set("name", name, "trusting");
});
accountAsTeam.teamMap.edit((editable) => {
editable.set("profile", profile.id, "trusting");
});
return controlledAccount;
}
resolveAccountAgent(id: AccountIDOrAgentID, expectation?: string): AgentID {
if (isAgentID(id)) {
return id;
}
const coValue = this.expectCoValueLoaded(id, expectation);
if (
coValue.header.type !== "comap" ||
coValue.header.ruleset.type !== "team" ||
!coValue.header.meta ||
!("type" in coValue.header.meta) ||
coValue.header.meta.type !== "account"
) {
throw new Error(
`${
expectation ? expectation + ": " : ""
}CoValue ${id} is not an account`
);
}
return new Account(
coValue.getCurrentContent() as CoMap<TeamContent, AccountMeta>,
this
).getCurrentAgentID();
}
createTeam(): Team {
const teamCoValue = this.createCoValue({
type: "comap",
ruleset: { type: "team", initialAdmin: this.account.id },
meta: null,
...createdNowUnique(),
});
let teamContent = expectTeamContent(teamCoValue.getCurrentContent());
teamContent = teamContent.edit((editable) => {
editable.set(this.account.id, "admin", "trusting");
const readKey = newRandomKeySecret();
editable.set(
`${readKey.id}_for_${this.account.id}`,
seal(
readKey.secret,
this.account.currentSealerSecret(),
this.account.currentSealerID(),
{
in: teamCoValue.id,
tx: teamCoValue.nextTransactionID(),
}
),
"trusting"
);
editable.set("readKey", readKey.id, "trusting");
});
return new Team(teamContent, this);
}
testWithDifferentAccount(
account: GeneralizedControlledAccount,
ownSessionID: SessionID
): LocalNode {
const newNode = new LocalNode(account, ownSessionID);
newNode.coValues = Object.fromEntries(
Object.entries(this.coValues)
.map(([id, entry]) => {
if (entry.state === "loading") {
return undefined;
}
const newCoValue = new CoValue(
entry.coValue.header,
newNode
);
newCoValue.sessions = entry.coValue.sessions;
return [id, { state: "loaded", coValue: newCoValue }];
})
.filter((x): x is Exclude<typeof x, undefined> => !!x)
);
return newNode;
}
}
type CoValueState =
| {
state: "loading";
done: Promise<CoValue>;
resolve: (coValue: CoValue) => void;
}
| { state: "loaded"; coValue: CoValue };
export function newLoadingState(): CoValueState {
let resolve: (coValue: CoValue) => void;
const promise = new Promise<CoValue>((r) => {
resolve = r;
});
return {
state: "loading",
done: promise,
resolve: resolve!,
};
}

1267
src/permissions.test.ts Normal file

File diff suppressed because it is too large Load Diff

392
src/permissions.ts Normal file
View File

@@ -0,0 +1,392 @@
import { CoID, ContentType } from "./contentType.js";
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
import { JsonObject, JsonValue } from "./jsonValue.js";
import {
Encrypted,
KeyID,
KeySecret,
createdNowUnique,
newRandomKeySecret,
seal,
encryptKeySecret,
getAgentSealerID,
Sealed,
} from "./crypto.js";
import {
CoValue,
Transaction,
TrustingTransaction,
accountOrAgentIDfromSessionID,
} from "./coValue.js";
import { LocalNode } from "./node.js";
import { RawCoID, SessionID, TransactionID, isAgentID } from "./ids.js";
import { AccountIDOrAgentID, GeneralizedControlledAccount, Profile } from "./account.js";
export type PermissionsDef =
| { type: "team"; initialAdmin: AccountIDOrAgentID }
| { type: "ownedByTeam"; team: RawCoID }
| { type: "unsafeAllowAll" };
export type Role = "reader" | "writer" | "admin" | "revoked";
export function determineValidTransactions(
coValue: CoValue
): { txID: TransactionID; tx: Transaction }[] {
if (coValue.header.ruleset.type === "team") {
const allTrustingTransactionsSorted = Object.entries(
coValue.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 = coValue.header.ruleset.initialAdmin;
if (!initialAdmin) {
throw new Error("Team must have initialAdmin");
}
const memberState: { [agent: AccountIDOrAgentID]: Role } = {};
const validTransactions: { txID: TransactionID; tx: Transaction }[] =
[];
for (const {
sessionID,
txIndex,
tx,
} of allTrustingTransactionsSorted) {
// console.log("before", { memberState, validTransactions });
const transactor = accountOrAgentIDfromSessionID(sessionID);
const change = tx.changes[0] as
| MapOpPayload<AccountIDOrAgentID, Role>
| MapOpPayload<"readKey", JsonValue>
| MapOpPayload<"profile", CoID<Profile>>;
if (tx.changes.length !== 1) {
console.warn("Team transaction must have exactly one change");
continue;
}
if (change.op !== "insert") {
console.warn("Team transaction must set a role or readKey");
continue;
}
if (change.key === "readKey") {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set readKeys");
continue;
}
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (change.key === 'profile') {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set profile");
continue;
}
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (isKeyForKeyField(change.key) || isKeyForAccountField(change.key)) {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can reveal keys");
continue;
}
// TODO: check validity of agents who the key is revealed to?
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
}
const affectedMember = change.key;
const assignedRole = change.value;
if (
change.value !== "admin" &&
change.value !== "writer" &&
change.value !== "reader" &&
change.value !== "revoked"
) {
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 (coValue.header.ruleset.type === "ownedByTeam") {
const teamContent = coValue.node
.expectCoValueLoaded(
coValue.header.ruleset.team,
"Determining valid transaction in owned object but its team wasn't loaded"
)
.getCurrentContent();
if (teamContent.type !== "comap") {
throw new Error("Team must be a map");
}
return Object.entries(coValue.sessions).flatMap(
([sessionID, sessionLog]) => {
const transactor = accountOrAgentIDfromSessionID(
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 (coValue.header.ruleset.type === "unsafeAllowAll") {
return Object.entries(coValue.sessions).flatMap(
([sessionID, sessionLog]) => {
return sessionLog.transactions.map((tx, txIndex) => ({
txID: { sessionID: sessionID as SessionID, txIndex },
tx,
}));
}
);
} else {
throw new Error(
"Unknown ruleset type " + (coValue.header.ruleset as any).type
);
}
}
export type TeamContent = {
profile: CoID<Profile> | null;
[key: AccountIDOrAgentID]: Role;
readKey: KeyID;
[revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed<KeySecret>;
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
KeySecret,
{ encryptedID: KeyID; encryptingID: KeyID }
>;
};
export function expectTeamContent(
content: ContentType
): CoMap<TeamContent, JsonObject | null> {
if (content.type !== "comap") {
throw new Error("Expected map");
}
return content as CoMap<TeamContent, JsonObject | null>;
}
export class Team {
teamMap: CoMap<TeamContent, JsonObject | null>;
node: LocalNode;
constructor(teamMap: CoMap<TeamContent, JsonObject | null>, node: LocalNode) {
this.teamMap = teamMap;
this.node = node;
}
get id(): CoID<CoMap<TeamContent, JsonObject | null>> {
return this.teamMap.id;
}
addMember(accountID: AccountIDOrAgentID, role: Role) {
this.teamMap = this.teamMap.edit((map) => {
const currentReadKey = this.teamMap.coValue.getCurrentReadKey();
if (!currentReadKey.secret) {
throw new Error("Can't add member without read key secret");
}
const agent = this.node.resolveAccountAgent(
accountID,
"Expected to know agent to add them to team"
);
map.set(accountID, role, "trusting");
if (map.get(accountID) !== role) {
throw new Error("Failed to set role");
}
map.set(
`${currentReadKey.id}_for_${accountID}`,
seal(
currentReadKey.secret,
this.teamMap.coValue.node.account.currentSealerSecret(),
getAgentSealerID(agent),
{
in: this.teamMap.coValue.id,
tx: this.teamMap.coValue.nextTransactionID(),
}
),
"trusting"
);
});
}
rotateReadKey() {
const currentlyPermittedReaders = this.teamMap.keys().filter((key) => {
if (key.startsWith("co_") || isAgentID(key)) {
const role = this.teamMap.get(key);
return (
role === "admin" || role === "writer" || role === "reader"
);
} else {
return false;
}
}) as AccountIDOrAgentID[];
const maybeCurrentReadKey = this.teamMap.coValue.getCurrentReadKey();
if (!maybeCurrentReadKey.secret) {
throw new Error(
"Can't rotate read key secret we don't have access to"
);
}
const currentReadKey = {
id: maybeCurrentReadKey.id,
secret: maybeCurrentReadKey.secret,
};
const newReadKey = newRandomKeySecret();
this.teamMap = this.teamMap.edit((map) => {
for (const readerID of currentlyPermittedReaders) {
const reader = this.node.resolveAccountAgent(
readerID,
"Expected to know currently permitted reader"
);
map.set(
`${newReadKey.id}_for_${readerID}`,
seal(
newReadKey.secret,
this.teamMap.coValue.node.account.currentSealerSecret(),
getAgentSealerID(reader),
{
in: this.teamMap.coValue.id,
tx: this.teamMap.coValue.nextTransactionID(),
}
),
"trusting"
);
}
map.set(
`${currentReadKey.id}_for_${newReadKey.id}`,
encryptKeySecret({
encrypting: newReadKey,
toEncrypt: currentReadKey,
}).encrypted,
"trusting"
);
map.set("readKey", newReadKey.id, "trusting");
});
}
removeMember(accountID: AccountIDOrAgentID) {
this.teamMap = this.teamMap.edit((map) => {
map.set(accountID, "revoked", "trusting");
});
this.rotateReadKey();
}
createMap<M extends { [key: string]: JsonValue }, Meta extends JsonObject | null>(
meta?: Meta
): CoMap<M, Meta> {
return this.node
.createCoValue({
type: "comap",
ruleset: {
type: "ownedByTeam",
team: this.teamMap.id,
},
meta: meta || null,
...createdNowUnique(),
})
.getCurrentContent() as CoMap<M, Meta>;
}
testWithDifferentAccount(
account: GeneralizedControlledAccount,
sessionId: SessionID
): Team {
return new Team(
expectTeamContent(
this.teamMap.coValue
.testWithDifferentAccount(account, sessionId)
.getCurrentContent()
),
this.node
);
}
}
export function isKeyForKeyField(field: string): field is `${KeyID}_for_${KeyID}` {
return field.startsWith("key_") && field.includes("_for_key");
}
export function isKeyForAccountField(field: string): field is `${KeyID}_for_${AccountIDOrAgentID}` {
return field.startsWith("key_") && (field.includes("_for_sealer") || field.includes("_for_co"));
}

130
src/streamUtils.ts Normal file
View File

@@ -0,0 +1,130 @@
import { ReadableStream, TransformStream, WritableStream } from "isomorphic-streams";
import { Peer, PeerID, SyncMessage } from "./sync.js";
export function connectedPeers(
peer1id: PeerID,
peer2id: PeerID,
{
trace = false, peer1role = "peer", peer2role = "peer",
}: {
trace?: boolean;
peer1role?: Peer["role"];
peer2role?: Peer["role"];
} = {}
): [Peer, Peer] {
const [inRx1, inTx1] = newStreamPair<SyncMessage>();
const [outRx1, outTx1] = newStreamPair<SyncMessage>();
const [inRx2, inTx2] = newStreamPair<SyncMessage>();
const [outRx2, outTx2] = newStreamPair<SyncMessage>();
void outRx2
.pipeThrough(
new TransformStream({
transform(
chunk: SyncMessage,
controller: { enqueue: (msg: SyncMessage) => void; }
) {
trace && console.log(`${peer2id} -> ${peer1id}`, JSON.stringify(chunk, null, 2));
controller.enqueue(chunk);
},
})
)
.pipeTo(inTx1);
void outRx1
.pipeThrough(
new TransformStream({
transform(
chunk: SyncMessage,
controller: { enqueue: (msg: SyncMessage) => void; }
) {
trace && console.log(`${peer1id} -> ${peer2id}`, JSON.stringify(chunk, null, 2));
controller.enqueue(chunk);
},
})
)
.pipeTo(inTx2);
const peer2AsPeer: Peer = {
id: peer2id,
incoming: inRx1,
outgoing: outTx1,
role: peer2role,
};
const peer1AsPeer: Peer = {
id: peer1id,
incoming: inRx2,
outgoing: outTx2,
role: peer1role,
};
return [peer1AsPeer, peer2AsPeer];
}
export function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
const queue: T[] = [];
let resolveNextItemReady: () => void = () => { };
let nextItemReady: Promise<void> = new Promise((resolve) => {
resolveNextItemReady = resolve;
});
let writerClosed = false;
let readerClosed = false;
const readable = new ReadableStream<T>({
async pull(controller) {
let retriesLeft = 3;
while (retriesLeft > 0) {
if (writerClosed) {
controller.close();
return;
}
retriesLeft--;
if (queue.length > 0) {
controller.enqueue(queue.shift()!);
if (queue.length === 0) {
nextItemReady = new Promise((resolve) => {
resolveNextItemReady = resolve;
});
}
return;
} else {
await nextItemReady;
}
}
throw new Error(
"Should only use one retry to get next item in queue."
);
},
cancel(reason) {
console.log("Manually closing reader");
readerClosed = true;
},
});
const writable = new WritableStream<T>({
write(chunk, controller) {
if (readerClosed) {
console.log("Reader closed, not writing chunk", chunk);
throw new Error("Reader closed, not writing chunk");
}
queue.push(chunk);
if (queue.length === 1) {
// make sure that await write resolves before corresponding read
setTimeout(() => resolveNextItemReady());
}
},
abort(reason) {
console.log("Manually closing writer");
writerClosed = true;
resolveNextItemReady();
return Promise.resolve();
},
});
return [readable, writable];
}

1125
src/sync.test.ts Normal file

File diff suppressed because it is too large Load Diff

512
src/sync.ts Normal file
View File

@@ -0,0 +1,512 @@
import { Hash, Signature } from "./crypto.js";
import { CoValueHeader, Transaction } from "./coValue.js";
import { CoValue } from "./coValue.js";
import { LocalNode } from "./node.js";
import { newLoadingState } from "./node.js";
import {
ReadableStream,
WritableStream,
WritableStreamDefaultWriter,
} from "isomorphic-streams";
import { RawCoID, SessionID } from "./ids.js";
export type CoValueKnownState = {
id: RawCoID;
header: boolean;
sessions: { [sessionID: SessionID]: number };
};
export function emptyKnownState(id: RawCoID): CoValueKnownState {
return {
id,
header: false,
sessions: {},
};
}
export type SyncMessage =
| LoadMessage
| KnownStateMessage
| NewContentMessage
| DoneMessage;
export type LoadMessage = {
action: "load";
} & CoValueKnownState;
export type KnownStateMessage = {
action: "known";
asDependencyOf?: RawCoID;
isCorrection?: boolean;
} & CoValueKnownState;
export type NewContentMessage = {
action: "content";
id: RawCoID;
header?: CoValueHeader;
new: {
[sessionID: SessionID]: SessionNewContent;
};
};
export type SessionNewContent = {
after: number;
newTransactions: Transaction[];
lastSignature: Signature;
};
export type DoneMessage = {
action: "done";
id: RawCoID;
};
export type PeerID = string;
export interface Peer {
id: PeerID;
incoming: ReadableStream<SyncMessage>;
outgoing: WritableStream<SyncMessage>;
role: "peer" | "server" | "client";
}
export interface PeerState {
id: PeerID;
optimisticKnownStates: { [id: RawCoID]: CoValueKnownState };
toldKnownState: Set<RawCoID>;
incoming: ReadableStream<SyncMessage>;
outgoing: WritableStreamDefaultWriter<SyncMessage>;
role: "peer" | "server" | "client";
}
export function combinedKnownStates(
stateA: CoValueKnownState,
stateB: CoValueKnownState
): CoValueKnownState {
const sessionStates: CoValueKnownState["sessions"] = {};
const allSessions = new Set([
...Object.keys(stateA.sessions),
...Object.keys(stateB.sessions),
] as SessionID[]);
for (const sessionID of allSessions) {
const stateAValue = stateA.sessions[sessionID];
const stateBValue = stateB.sessions[sessionID];
sessionStates[sessionID] = Math.max(stateAValue || 0, stateBValue || 0);
}
return {
id: stateA.id,
header: stateA.header || stateB.header,
sessions: sessionStates,
};
}
export class SyncManager {
peers: { [key: PeerID]: PeerState } = {};
local: LocalNode;
constructor(local: LocalNode) {
this.local = local;
}
loadFromPeers(id: RawCoID) {
for (const peer of Object.values(this.peers)) {
peer.outgoing
.write({
action: "load",
id: id,
header: false,
sessions: {},
})
.catch((e) => {
console.error("Error writing to peer", e);
});
}
}
async handleSyncMessage(msg: SyncMessage, peer: PeerState) {
// TODO: validate
switch (msg.action) {
case "load":
return await this.handleLoad(msg, peer);
case "known":
if (msg.isCorrection) {
return await this.handleCorrection(msg, peer);
} else {
return await this.handleKnownState(msg, peer);
}
case "content":
return await this.handleNewContent(msg, peer);
case "done":
return await this.handleUnsubscribe(msg);
default:
throw new Error(
`Unknown message type ${
(msg as { action: "string" }).action
}`
);
}
}
async subscribeToIncludingDependencies(
id: RawCoID,
peer: PeerState
) {
const entry = this.local.coValues[id];
if (!entry) {
throw new Error(
"Expected coValue entry on subscribe"
);
}
if (entry.state === "loading") {
await this.trySendToPeer(peer, {
action: "load",
id,
header: false,
sessions: {},
});
return;
}
const coValue = entry.coValue;
for (const id of coValue.getDependedOnCoValues()) {
await this.subscribeToIncludingDependencies(id, peer);
}
if (!peer.toldKnownState.has(id)) {
peer.toldKnownState.add(id);
await this.trySendToPeer(peer, {
action: "load",
...coValue.knownState(),
});
}
}
async tellUntoldKnownStateIncludingDependencies(
id: RawCoID,
peer: PeerState,
asDependencyOf?: RawCoID
) {
const coValue = this.local.expectCoValueLoaded(id);
for (const dependentCoID of coValue.getDependedOnCoValues()) {
await this.tellUntoldKnownStateIncludingDependencies(
dependentCoID,
peer,
asDependencyOf || id
);
}
if (!peer.toldKnownState.has(id)) {
await this.trySendToPeer(peer, {
action: "known",
asDependencyOf,
...coValue.knownState(),
});
peer.toldKnownState.add(id);
}
}
async sendNewContentIncludingDependencies(
id: RawCoID,
peer: PeerState
) {
const coValue = this.local.expectCoValueLoaded(id);
for (const id of coValue.getDependedOnCoValues()) {
await this.sendNewContentIncludingDependencies(id, peer);
}
const newContent = coValue.newContentSince(
peer.optimisticKnownStates[id]
);
if (newContent) {
await this.trySendToPeer(peer, newContent);
peer.optimisticKnownStates[id] = combinedKnownStates(
peer.optimisticKnownStates[id] ||
emptyKnownState(id),
coValue.knownState()
);
}
}
addPeer(peer: Peer) {
const peerState: PeerState = {
id: peer.id,
optimisticKnownStates: {},
incoming: peer.incoming,
outgoing: peer.outgoing.getWriter(),
toldKnownState: new Set(),
role: peer.role,
};
this.peers[peer.id] = peerState;
if (peer.role === "server") {
const initialSync = async () => {
for (const id of Object.keys(
this.local.coValues
) as RawCoID[]) {
await this.subscribeToIncludingDependencies(id, peerState);
peerState.optimisticKnownStates[id] = {
id: id,
header: false,
sessions: {},
};
}
};
void initialSync();
}
const readIncoming = async () => {
for await (const msg of peerState.incoming) {
try {
await this.handleSyncMessage(msg, peerState);
} catch (e) {
console.error(
`Error reading from peer ${peer.id}`,
JSON.stringify(msg),
e
);
}
}
console.log("Peer disconnected:", peer.id);
delete this.peers[peer.id];
};
void readIncoming();
}
trySendToPeer(peer: PeerState, msg: SyncMessage) {
return peer.outgoing.write(msg).catch((e) => {
console.error(new Error("Error writing to peer, disconnecting", {cause: e}));
delete this.peers[peer.id];
});
}
async handleLoad(msg: LoadMessage, peer: PeerState) {
const entry = this.local.coValues[msg.id];
if (!entry || entry.state === "loading") {
if (!entry) {
this.local.coValues[msg.id] = newLoadingState();
}
peer.optimisticKnownStates[msg.id] = knownStateIn(msg);
peer.toldKnownState.add(msg.id);
await this.trySendToPeer(peer, {
action: "known",
id: msg.id,
header: false,
sessions: {},
});
return;
}
peer.optimisticKnownStates[msg.id] = knownStateIn(msg);
await this.tellUntoldKnownStateIncludingDependencies(
msg.id,
peer
);
await this.sendNewContentIncludingDependencies(msg.id, peer);
}
async handleKnownState(msg: KnownStateMessage, peer: PeerState) {
let entry = this.local.coValues[msg.id];
peer.optimisticKnownStates[msg.id] = combinedKnownStates(
peer.optimisticKnownStates[msg.id] ||
emptyKnownState(msg.id),
knownStateIn(msg)
);
if (!entry) {
if (msg.asDependencyOf) {
if (this.local.coValues[msg.asDependencyOf]) {
entry = newLoadingState();
this.local.coValues[msg.id] = entry;
} else {
throw new Error(
"Expected coValue dependency entry to be created, missing subscribe?"
);
}
} else {
throw new Error(
"Expected coValue entry to be created, missing subscribe?"
);
}
}
if (entry.state === "loading") {
return [];
}
await this.tellUntoldKnownStateIncludingDependencies(
msg.id,
peer
);
await this.sendNewContentIncludingDependencies(msg.id, peer);
}
async handleNewContent(msg: NewContentMessage, peer: PeerState) {
let entry = this.local.coValues[msg.id];
if (!entry) {
throw new Error(
"Expected coValue entry to be created, missing subscribe?"
);
}
let resolveAfterDone: ((coValue: CoValue) => void) | undefined;
const peerOptimisticKnownState =
peer.optimisticKnownStates[msg.id];
if (!peerOptimisticKnownState) {
throw new Error(
"Expected optimisticKnownState to be set for coValue we receive new content for"
);
}
if (entry.state === "loading") {
if (!msg.header) {
throw new Error("Expected header to be sent in first message");
}
peerOptimisticKnownState.header = true;
const coValue = new CoValue(msg.header, this.local);
resolveAfterDone = entry.resolve;
entry = {
state: "loaded",
coValue: coValue,
};
this.local.coValues[msg.id] = entry;
}
const coValue = entry.coValue;
let invalidStateAssumed = false;
for (const [sessionID, newContentForSession] of Object.entries(
msg.new
) as [SessionID, SessionNewContent][]) {
const ourKnownTxIdx =
coValue.sessions[sessionID]?.transactions.length;
const theirFirstNewTxIdx = newContentForSession.after;
if ((ourKnownTxIdx || 0) < theirFirstNewTxIdx) {
invalidStateAssumed = true;
continue;
}
const alreadyKnownOffset = ourKnownTxIdx
? ourKnownTxIdx - theirFirstNewTxIdx
: 0;
const newTransactions =
newContentForSession.newTransactions.slice(alreadyKnownOffset);
const success = coValue.tryAddTransactions(
sessionID,
newTransactions,
undefined,
newContentForSession.lastSignature
);
if (!success) {
console.error("Failed to add transactions", newTransactions);
continue;
}
peerOptimisticKnownState.sessions[sessionID] =
newContentForSession.after +
newContentForSession.newTransactions.length;
}
if (resolveAfterDone) {
resolveAfterDone(coValue);
}
await this.syncCoValue(coValue);
if (invalidStateAssumed) {
await this.trySendToPeer(peer, {
action: "known",
isCorrection: true,
...coValue.knownState(),
});
}
}
async handleCorrection(
msg: KnownStateMessage,
peer: PeerState
) {
const coValue = this.local.expectCoValueLoaded(msg.id);
peer.optimisticKnownStates[msg.id] = combinedKnownStates(
msg,
coValue.knownState()
);
const newContent = coValue.newContentSince(msg);
if (newContent) {
await this.trySendToPeer(peer, newContent);
}
}
handleUnsubscribe(_msg: DoneMessage) {
throw new Error("Method not implemented.");
}
async syncCoValue(coValue: CoValue) {
for (const peer of Object.values(this.peers)) {
const optimisticKnownState = peer.optimisticKnownStates[coValue.id];
if (optimisticKnownState) {
await this.tellUntoldKnownStateIncludingDependencies(
coValue.id,
peer
);
await this.sendNewContentIncludingDependencies(
coValue.id,
peer
);
} else if (peer.role === "server") {
await this.subscribeToIncludingDependencies(coValue.id, peer);
await this.sendNewContentIncludingDependencies(
coValue.id,
peer
);
}
}
}
}
function knownStateIn(
msg:
| LoadMessage
| KnownStateMessage
) {
return {
id: msg.id,
header: msg.header,
sessions: msg.sessions,
};
}

99
src/testUtils.ts Normal file
View File

@@ -0,0 +1,99 @@
import { AgentSecret, createdNowUnique, getAgentID, newRandomAgentSecret } from "./crypto.js";
import { newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import { expectTeamContent } from "./permissions.js";
import { AnonymousControlledAccount } from "./account.js";
import { SessionID } from "./ids.js";
export function randomAnonymousAccountAndSessionID(): [AnonymousControlledAccount, SessionID] {
const agentSecret = newRandomAgentSecret();
const sessionID = newRandomSessionID(getAgentID(agentSecret));
return [new AnonymousControlledAccount(agentSecret), sessionID];
}
export function newTeam() {
const [admin, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, sessionID);
const team = node.createCoValue({
type: "comap",
ruleset: { type: "team", initialAdmin: admin.id },
meta: null,
...createdNowUnique(),
});
const teamContent = expectTeamContent(team.getCurrentContent());
teamContent.edit((editable) => {
editable.set(admin.id, "admin", "trusting");
expect(editable.get(admin.id)).toEqual("admin");
});
return { node, team, admin };
}
export function teamWithTwoAdmins() {
const { team, admin, node } = newTeam();
const otherAdmin = node.createAccount("otherAdmin");
let content = expectTeamContent(team.getCurrentContent());
content.edit((editable) => {
editable.set(otherAdmin.id, "admin", "trusting");
expect(editable.get(otherAdmin.id)).toEqual("admin");
});
content = expectTeamContent(team.getCurrentContent());
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.get(otherAdmin.id)).toEqual("admin");
return { team, admin, otherAdmin, node };
}
export function newTeamHighLevel() {
const [admin, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, sessionID);
const team = node.createTeam();
return { admin, node, team };
}
export function teamWithTwoAdminsHighLevel() {
const { admin, node, team } = newTeamHighLevel();
const otherAdmin = node.createAccount("otherAdmin");
team.addMember(otherAdmin.id, "admin");
return { admin, node, team, otherAdmin };
}
export function shouldNotResolve<T>(
promise: Promise<T>,
ops: { timeout: number }
): Promise<void> {
return new Promise((resolve, reject) => {
promise
.then((v) =>
reject(
new Error(
"Should not have resolved, but resolved to " +
JSON.stringify(v)
)
)
)
.catch(reject);
setTimeout(resolve, ops.timeout);
});
}

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "esnext",
"target": "ES2020",
"moduleResolution": "bundler",
"moduleDetection": "force",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
},
"include": ["./src/**/*"],
}

2786
yarn.lock Normal file

File diff suppressed because it is too large Load Diff