Files
jazz-tools/src/multilog.ts
2023-07-25 16:22:19 +01:00

509 lines
14 KiB
TypeScript

import { randomBytes } from "@noble/hashes/utils";
import { CoList, CoMap, CoValue, Static, MultiStream } from "./coValue";
import {
Encrypted,
Hash,
KeySecret,
RecipientID,
RecipientSecret,
SignatoryID,
SignatorySecret,
Signature,
StreamingHash,
getRecipientID,
getSignatoryID,
newRandomRecipient,
newRandomSignatory,
openAs,
shortHash,
sign,
verify,
encryptForTransaction,
decryptForTransaction,
KeyID,
unsealKeySecret,
} from "./crypto";
import { JsonValue } from "./jsonValue";
import { base58 } from "@scure/base";
import {
PermissionsDef as RulesetDef,
determineValidTransactions,
expectTeam,
} 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;
keyUsed: KeyID;
encryptedChanges: Encrypted<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,
Object.fromEntries(
Object.entries(this.requiredMultiLogs).map(([id, multilog]) => [
id,
multilog.testWithDifferentCredentials(
agentCredential,
ownSessionID
),
])
)
);
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;
}
nextTransactionID(): TransactionID {
const sessionID = this.ownSessionID;
return {
sessionID,
txIndex: this.sessions[sessionID]?.transactions.length || 0,
};
}
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();
let transaction: Transaction;
if (privacy === "private") {
const { keySecret, keyID } = this.getCurrentReadKey();
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.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 }) => {
return {
txID,
madeAt: tx.madeAt,
changes:
tx.privacy === "private"
? decryptForTransaction(
tx.encryptedChanges,
this.getReadKey(tx.keyUsed),
{
in: this.id,
tx: txID,
}
) ||
(() => {
throw new Error("Couldn't decrypt changes");
})()
: tx.changes,
};
}
);
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(): { keySecret: KeySecret; keyID: KeyID } {
if (this.header.ruleset.type === "team") {
const content = expectTeam(this.getCurrentContent());
const currentRevelation = content.get("readKey");
if (!currentRevelation) {
throw new Error("No readKey");
}
const revelationTxID = content.getLastTxID("readKey");
if (!revelationTxID) {
throw new Error("No readKey transaction ID");
}
const revealer = agentIDfromSessionID(revelationTxID.sessionID);
const revealerAgent = this.knownAgents[revealer];
if (!revealerAgent) {
throw new Error("Unknown revealer");
}
const secret = openAs(
currentRevelation.revelation,
this.agentCredential.recipientSecret,
revealerAgent.recipientID,
{
in: this.id,
tx: revelationTxID,
}
);
if (!secret) {
throw new Error("Couldn't decrypt readKey");
}
return {
keySecret: secret as KeySecret,
keyID: currentRevelation.keyID,
};
} else if (this.header.ruleset.type === "ownedByTeam") {
return this.requiredMultiLogs[
this.header.ruleset.team
].getCurrentReadKey();
} else {
throw new Error(
"Only teams or values owned by teams have read secrets"
);
}
}
getReadKey(keyID: KeyID): KeySecret {
if (this.header.ruleset.type === "team") {
const content = expectTeam(this.getCurrentContent());
const readKeyHistory = content.getHistory("readKey");
const matchingEntry = readKeyHistory.find(
(entry) => entry.value?.keyID === keyID
);
if (!matchingEntry || !matchingEntry.value) {
throw new Error("No matching readKey");
}
const revealer = agentIDfromSessionID(matchingEntry.txID.sessionID);
const revealerAgent = this.knownAgents[revealer];
if (!revealerAgent) {
throw new Error("Unknown revealer");
}
const secret = openAs(
matchingEntry.value.revelation,
this.agentCredential.recipientSecret,
revealerAgent.recipientID,
{
in: this.id,
tx: matchingEntry.txID,
}
);
if (secret) return secret as KeySecret;
for (const entry of readKeyHistory) {
if (entry.value?.previousKeys?.[keyID]) {
const sealingKeyID = entry.value.keyID;
const sealingKeySecret = this.getReadKey(sealingKeyID);
if (!sealingKeySecret) {
continue;
}
const secret = unsealKeySecret({ sealed: keyID, sealing: sealingKeyID, encrypted: entry.value.previousKeys[keyID] }, sealingKeySecret);
if (secret) {
return secret;
}
}
}
throw new Error("readKey " + keyID + " not revealed for " + getAgentID(getAgent(this.agentCredential)));
} else if (this.header.ruleset.type === "ownedByTeam") {
return this.requiredMultiLogs[this.header.ruleset.team].getReadKey(
keyID
);
} else {
throw new Error(
"Only teams or values owned by teams have read secrets"
);
}
}
getTx(txID: TransactionID): Transaction | undefined {
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
}
}
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}>;