Technology is anything that wasn't around when you were born.

This commit is contained in:
Anselm
2023-07-19 11:55:18 +01:00
commit d0993189d2
17 changed files with 1980 additions and 0 deletions

103
src/cojsonValue.test.ts Normal file
View File

@@ -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");
});
})

183
src/cojsonValue.ts Normal file
View File

@@ -0,0 +1,183 @@
import { JsonAtom, JsonObject, JsonValue } from "./jsonValue";
import { MultiLog, MultiLogID, TransactionID } from "./multilog";
export type CoValueID<T extends CoValue> = MultiLogID & {
readonly __type: T;
};
export type CoValue =
| CoMap<string, JsonValue, JsonValue>
| CoList<JsonValue, JsonValue>
| MultiStream<JsonValue, JsonValue>
| Static<JsonValue>;
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 class CoMap<
K extends string,
V extends JsonValue,
Meta extends JsonValue
> {
id: CoValueID<CoMap<K, V, Meta>>;
multiLog: MultiLog;
type: "comap" = "comap";
ops: Map<K, MapOp<K, V>[]>;
constructor(multiLog: MultiLog) {
this.id = multiLog.id as CoValueID<CoMap<K, V, Meta>>;
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<K, V>[]
).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<K> {
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<K, V, Meta>) => void): void {
const editable = new WriteableCoMap<K, V, Meta>(this.multiLog);
changer(editable);
}
}
export class WriteableCoMap<
K extends string,
V extends JsonValue,
Meta extends JsonValue
> extends CoMap<K, V, Meta> {
// 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<T extends JsonValue, Meta extends JsonValue> {
id: CoValueID<CoList<T, Meta>>;
type: "colist" = "colist";
constructor(multilog: MultiLog) {
this.id = multilog.id as CoValueID<CoList<T, Meta>>;
}
}
export class MultiStream<T extends JsonValue, Meta extends JsonValue> {
id: CoValueID<MultiStream<T, Meta>>;
type: "multistream" = "multistream";
constructor(multilog: MultiLog) {
this.id = multilog.id as CoValueID<MultiStream<T, Meta>>;
}
}
export class Static<T extends JsonValue> {
id: CoValueID<Static<T>>;
type: "static" = "static";
constructor(multilog: MultiLog) {
this.id = multilog.id as CoValueID<Static<T>>;
}
}

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

@@ -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]);
});

245
src/crypto.ts Normal file
View File

@@ -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<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 EncryptedStreamChunk<T extends JsonValue> =
`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<T extends JsonValue>(value: T): EncryptedStreamChunk<T> {
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<T extends JsonValue>(
encryptedChunk: EncryptedStreamChunk<T>
): 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;
}
}
}

14
src/index.ts Normal file
View File

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

6
src/jsonValue.ts Normal file
View File

@@ -0,0 +1,6 @@
import { CoValueID, CoValue } from "./cojsonValue";
export type JsonAtom = string | number | boolean | null;
export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID<CoValue>;
export type JsonArray = JsonValue[];
export type JsonObject = { [key: string]: JsonValue; };

138
src/multilog.test.ts Normal file
View File

@@ -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);
});

348
src/multilog.ts Normal file
View File

@@ -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<JsonValue[]>;
};
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<AgentID, Role, {[agent: AgentID]: Role}>;

75
src/node.ts Normal file
View File

@@ -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> | 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<SyncMessage>;
// outgoing: WritableStream<SyncMessage>;
// optimisticKnownStates: {[multilogID: MultiLogID]: MultilogKnownState};
// }

311
src/permissions.test.ts Normal file
View File

@@ -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<AgentID, Role, {}> {
if (content.type !== "comap") {
throw new Error("Expected map");
}
return content as CoMap<AgentID, Role, {}>;
}
function expectMap(content: CoJsonValue): CoMap<string, string, {}> {
if (content.type !== "comap") {
throw new Error("Expected map");
}
return content as CoMap<string, string, {}>;
}
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();
});

171
src/permissions.ts Normal file
View File

@@ -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<AgentID, Role>;
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);
}
}