From cb4f75801c4bf1f16083c0acdc2ebd9709474a75 Mon Sep 17 00:00:00 2001 From: Anselm Date: Thu, 20 Jul 2023 13:57:15 +0100 Subject: [PATCH] Simple tx encryption based on current key --- src/cojsonValue.test.ts | 35 +++++++++++ src/cojsonValue.ts | 19 ++++++ src/crypto.test.ts | 89 ++++++++++++++++----------- src/crypto.ts | 131 +++++++++++++++------------------------- src/index.ts | 4 +- src/multilog.ts | 116 ++++++++++++++++++++++++++++------- src/permissions.test.ts | 70 ++++++++++++++------- src/permissions.ts | 38 ++++++++++-- 8 files changed, 335 insertions(+), 167 deletions(-) diff --git a/src/cojsonValue.test.ts b/src/cojsonValue.test.ts index bb613dc05..14652a2ae 100644 --- a/src/cojsonValue.test.ts +++ b/src/cojsonValue.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "bun:test"; import { + agentIDfromSessionID, getAgent, getAgentID, newRandomAgentCredential, @@ -100,4 +101,38 @@ test("Can get map entry values at different points in time", () => { expect(editable.getAtTime("hello", beforeB)).toEqual("A"); expect(editable.getAtTime("hello", beforeC)).toEqual("B"); }); +}); + +test("Can get last tx ID for a key", () => { + 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) => { + expect(editable.getLastTxID("hello")).toEqual(undefined); + editable.set("hello", "A"); + const sessionID = editable.getLastTxID("hello")?.sessionID + expect(sessionID && agentIDfromSessionID(sessionID)).toEqual(getAgentID(getAgent(agentCredential))); + expect(editable.getLastTxID("hello")?.txIndex).toEqual(0); + editable.set("hello", "B"); + expect(editable.getLastTxID("hello")?.txIndex).toEqual(1); + editable.set("hello", "C"); + expect(editable.getLastTxID("hello")?.txIndex).toEqual(2); + }); }) \ No newline at end of file diff --git a/src/cojsonValue.ts b/src/cojsonValue.ts index a9466a17c..f6d31a49f 100644 --- a/src/cojsonValue.ts +++ b/src/cojsonValue.ts @@ -108,6 +108,17 @@ export class CoMap< } } + getLastTxID(key: KK): TransactionID | undefined { + const ops = this.ops[key]; + if (!ops) { + return undefined; + } + + const lastEntry = ops[ops.length - 1]; + + return lastEntry.txID; + } + toJSON(): JsonObject { const json: JsonObject = {}; @@ -186,3 +197,11 @@ export class Static { this.id = multilog.id as CoValueID>; } } + +export function expectMap(content: CoValue): CoMap<{ [key: string]: string }, {}> { + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + return content as CoMap<{ [key: string]: string }, {}>; +} diff --git a/src/crypto.test.ts b/src/crypto.test.ts index 56dff9338..5f6b2de92 100644 --- a/src/crypto.test.ts +++ b/src/crypto.test.ts @@ -10,9 +10,9 @@ import { openAs, verify, shortHash, - newRandomSecretKey, - EncryptionStream, - DecryptionStream, + newRandomKeySecret, + encrypt, + decrypt, } from "./crypto"; import { base58, base64url } from "@scure/base"; import { x25519 } from "@noble/curves/ed25519"; @@ -52,31 +52,42 @@ test("Sealing round-trips, but invalid receiver can't unseal", () => { tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, } as const; - const sealed = seal(data, sender, new Set([getRecipientID(recipient1), getRecipientID(recipient2)]), nOnceMaterial); - - console.log(sealed) + const sealed = seal( + data, + sender, + new Set([getRecipientID(recipient1), getRecipientID(recipient2)]), + nOnceMaterial + ); expect(sealed[getRecipientID(recipient1)]).toMatch(/^sealed_U/); expect(sealed[getRecipientID(recipient2)]).toMatch(/^sealed_U/); - expect(openAs(sealed, recipient1, getRecipientID(sender), nOnceMaterial)).toEqual(data); - expect(openAs(sealed, recipient2, getRecipientID(sender), nOnceMaterial)).toEqual(data); - expect(openAs(sealed, recipient3, getRecipientID(sender), nOnceMaterial)).toBeUndefined(); + expect( + openAs(sealed, recipient1, getRecipientID(sender), nOnceMaterial) + ).toEqual(data); + expect( + openAs(sealed, recipient2, getRecipientID(sender), nOnceMaterial) + ).toEqual(data); + expect( + openAs(sealed, recipient3, getRecipientID(sender), nOnceMaterial) + ).toBeUndefined(); // trying with wrong recipient secret, by hand const nOnce = blake3( - (new TextEncoder).encode(stableStringify(nOnceMaterial)) + new TextEncoder().encode(stableStringify(nOnceMaterial)) ).slice(0, 24); const recipient3priv = base58.decode( recipient3.substring("recipientSecret_z".length) ); - const senderPub = base58.decode(getRecipientID(sender).substring("recipient_z".length)); - const sealedBytes = base64url.decode(sealed[getRecipientID(recipient1)].substring("sealed_U".length)); + const senderPub = base58.decode( + getRecipientID(sender).substring("recipient_z".length) + ); + const sealedBytes = base64url.decode( + sealed[getRecipientID(recipient1)].substring("sealed_U".length) + ); const sharedSecret = x25519.getSharedSecret(recipient3priv, senderPub); expect(() => { - const _ = xsalsa20_poly1305(sharedSecret, nOnce).decrypt( - sealedBytes - ); + const _ = xsalsa20_poly1305(sharedSecret, nOnce).decrypt(sealedBytes); }).toThrow("Wrong tag"); }); @@ -91,39 +102,49 @@ test("Hashing is deterministic", () => { }); 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 { secret } = newRandomKeySecret(); const encryptedChunks = [ - encryptionStream.encrypt({ a: "hello" }), - encryptionStream.encrypt({ b: "world" }), + encrypt({ a: "hello" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + }), + encrypt({ b: "world" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + }), ]; - const decryptedChunks = encryptedChunks.map((chunk) => - decryptionStream.decrypt(chunk) + const decryptedChunks = encryptedChunks.map((chunk, i) => + decrypt(chunk, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: i }, + }) ); 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 { secret } = newRandomKeySecret(); + const { secret: secret2 } = newRandomKeySecret(); const encryptedChunks = [ - encryptionStream.encrypt({ a: "hello" }), - encryptionStream.encrypt({ b: "world" }), + encrypt({ a: "hello" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + }), + encrypt({ b: "world" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + }), ]; - const decryptedChunks = encryptedChunks.map((chunk) => - decryptionStream.decrypt(chunk) + const decryptedChunks = encryptedChunks.map((chunk, i) => + decrypt(chunk, secret2, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: i }, + }) ); expect(decryptedChunks).toEqual([undefined, undefined]); diff --git a/src/crypto.ts b/src/crypto.ts index bde8c8bb8..2c9a8140e 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -5,7 +5,7 @@ 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 { MultiLogID, TransactionID } from "./multilog"; +import { MultiLogID, SessionID, TransactionID } from "./multilog"; export type SignatorySecret = `signatorySecret_z${string}`; export type SignatoryID = `signatory_z${string}`; @@ -64,7 +64,7 @@ export function getRecipientID(secret: RecipientSecret): RecipientID { )}`; } -type SealedSet = { +export type SealedSet = { [recipient: RecipientID]: Sealed; }; @@ -124,7 +124,7 @@ export function openAs( const senderPub = base58.decode(from.substring("recipient_z".length)); const sealed = sealedSet[getRecipientID(recipient)]; - console.log("sealed", sealed); + if (!sealed) { return undefined; } @@ -181,87 +181,56 @@ export function shortHash(value: JsonValue): ShortHash { )}`; } -export type EncryptedStreamChunk = - `encryptedChunk_U${string}`; +export type Encrypted = + `encrypted_U${string}`; -export type SecretKey = `secretKey_z${string}`; +export type KeySecret = `keySecret_z${string}`; +export type KeyID = `key_z${string}`; -export function newRandomSecretKey(): SecretKey { - return `secretKey_z${base58.encode(randomBytes(32))}`; +export function newRandomKeySecret(): { secret: KeySecret; id: KeyID } { + return { + secret: `keySecret_z${base58.encode(randomBytes(32))}`, + id: `key_z${base58.encode(randomBytes(12))}`, + }; } -export class EncryptionStream { - secretKey: Uint8Array; - nonce: Uint8Array; - counter: number; +export function encrypt(value: T, keySecret: KeySecret, nOnceMaterial: {in: MultiLogID, tx: TransactionID}): Encrypted { + const keySecretBytes = base58.decode( + keySecret.substring("keySecret_z".length) + ); + const nOnce = blake3( + textEncoder.encode(stableStringify(nOnceMaterial)) + ).slice(0, 24); - constructor(secretKey: SecretKey, nonce: Uint8Array) { - this.secretKey = base58.decode( - secretKey.substring("secretKey_z".length) - ); - this.nonce = nonce; - this.counter = 0; + const plaintext = textEncoder.encode(stableStringify(value)); + const ciphertext = xsalsa20( + keySecretBytes, + nOnce, + plaintext, + ); + return `encrypted_U${base64url.encode(ciphertext)}`; +}; + +export function decrypt(encrypted: Encrypted, keySecret: KeySecret, nOnceMaterial: {in: MultiLogID, tx: TransactionID}): 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; } - - static resume(secretKey: SecretKey, nonce: Uint8Array, counter: number) { - const stream = new EncryptionStream(secretKey, nonce); - stream.counter = counter; - return stream; - } - - encrypt(value: T): EncryptedStreamChunk { - const plaintext = textEncoder.encode(stableStringify(value)); - const ciphertext = xsalsa20( - this.secretKey, - this.nonce, - plaintext, - new Uint8Array(plaintext.length), - this.counter - ); - this.counter++; - - return `encryptedChunk_U${base64url.encode(ciphertext)}`; - } -} - -export class DecryptionStream { - secretKey: Uint8Array; - nonce: Uint8Array; - counter: number; - - constructor(secretKey: SecretKey, nonce: Uint8Array) { - this.secretKey = base58.decode( - secretKey.substring("secretKey_z".length) - ); - this.nonce = nonce; - this.counter = 0; - } - - static resume(secretKey: SecretKey, nonce: Uint8Array, counter: number) { - const stream = new DecryptionStream(secretKey, nonce); - stream.counter = counter; - return stream; - } - - decrypt( - encryptedChunk: EncryptedStreamChunk - ): T | undefined { - const ciphertext = base64url.decode( - encryptedChunk.substring("encryptedChunk_U".length) - ); - const plaintext = xsalsa20( - this.secretKey, - this.nonce, - ciphertext, - new Uint8Array(ciphertext.length), - this.counter - ); - this.counter++; - - try { - return JSON.parse(textDecoder.decode(plaintext)); - } catch (e) { - return undefined; - } - } -} +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index fc26e7985..36d9a29d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,8 @@ type Value = JsonValue | CoValue; export { JsonValue, - CoValue as CoJsonValue, + CoValue, Value, - LocalNode as Node, + LocalNode, MultiLog } diff --git a/src/multilog.ts b/src/multilog.ts index 4e1828e2f..43678d875 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -1,14 +1,9 @@ import { randomBytes } from "@noble/hashes/utils"; +import { CoList, CoMap, CoValue, Static, MultiStream } from "./cojsonValue"; import { - CoList, - CoMap, - CoValue, - Static, - MultiStream, -} from "./cojsonValue"; -import { - EncryptedStreamChunk, + Encrypted, Hash, + KeySecret, RecipientID, RecipientSecret, SignatoryID, @@ -19,21 +14,25 @@ import { getSignatoryID, newRandomRecipient, newRandomSignatory, + openAs, shortHash, sign, verify, + encrypt, + decrypt, } 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']; + type: CoValue["type"]; ruleset: RulesetDef; meta: JsonValue; }; @@ -63,7 +62,7 @@ type SessionLog = { export type PrivateTransaction = { privacy: "private"; madeAt: number; - encryptedChanges: EncryptedStreamChunk; + encryptedChanges: Encrypted; }; export type TrustingTransaction = { @@ -145,6 +144,14 @@ export class MultiLog { 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[], @@ -224,9 +231,18 @@ export class MultiLog { const transaction: Transaction = privacy === "private" - ? (() => { - throw new Error("Not implemented"); - })() + ? { + privacy: "private", + madeAt, + encryptedChanges: encrypt( + changes, + this.getCurrentReadKey(), + { + in: this.id, + tx: this.nextTransactionID(), + } + ), + } : { privacy: "trusting", madeAt, @@ -277,15 +293,21 @@ export class MultiLog { 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, - }; - } + return { + txID, + madeAt: tx.madeAt, + changes: + tx.privacy === "private" + ? decrypt( + tx.encryptedChanges, + this.getCurrentReadKey(), + { + in: this.id, + tx: txID, + } + ) || (() => {throw new Error("Couldn't decrypt changes")})() + : tx.changes, + }; } ); // TODO: sort by timestamp, then by txID @@ -293,6 +315,56 @@ export class MultiLog { return allTransactions; } + + getCurrentReadKey(): KeySecret { + 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 secret as KeySecret; + } 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" + ); + } + } } type MultilogKnownState = { diff --git a/src/permissions.test.ts b/src/permissions.test.ts index e2cb6b336..194097a29 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -1,15 +1,14 @@ 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"; +import { expectMap } from "./cojsonValue"; +import { expectTeam } from "./permissions"; +import { getRecipientID, newRandomKeySecret, seal } from "./crypto"; function teamWithTwoAdmins() { const { team, admin, adminID } = newTeam(); @@ -56,22 +55,6 @@ function newTeam() { return { node, team, admin, adminID }; } -function expectTeam(content: CoJsonValue): CoMap<{[key: AgentID]: Role}, {}> { - if (content.type !== "comap") { - throw new Error("Expected map"); - } - - return content as CoMap<{[key: AgentID]: Role}, {}>; -} - -function expectMap(content: CoJsonValue): CoMap<{[key: string]: string}, {}> { - if (content.type !== "comap") { - throw new Error("Expected map"); - } - - return content as CoMap<{[key: string]: string}, {}>; -} - test("Initial admin can add another admin to a team", () => { teamWithTwoAdmins(); }); @@ -119,7 +102,9 @@ test("Admins can't demote other admins in a team", () => { newRandomSessionID(otherAdminID) ); - let teamContentAsOtherAdmin = expectTeam(teamAsOtherAdmin.getCurrentContent()); + let teamContentAsOtherAdmin = expectTeam( + teamAsOtherAdmin.getCurrentContent() + ); teamContentAsOtherAdmin.edit((editable) => { editable.set(adminID, "writer", "trusting"); @@ -304,4 +289,45 @@ test("Readers can not write to an object that is owned by their team", () => { childContent = expectMap(childObjectAsReader.getCurrentContent()); expect(childContent.get("foo")).toBeUndefined(); -}); \ No newline at end of file +}); + +test("Admins can set team read key and then use it to create and read private transactions in owned objects", () => { + const { node, team, admin, adminID } = newTeam(); + + const teamContent = expectTeam(team.getCurrentContent()); + + teamContent.edit((editable) => { + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); + const revelation = seal( + readKey, + admin.recipientSecret, + new Set([getRecipientID(admin.recipientSecret)]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set("readKey", { keyID: readKeyID, revelation }); + expect(editable.get("readKey")).toEqual({ + keyID: readKeyID, + revelation, + }); + expect(team.getCurrentReadKey()).toEqual(readKey); + }); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + }); + + let childContent = expectMap(childObject.getCurrentContent()); + + childContent.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + childContent = expectMap(childObject.getCurrentContent()); + expect(childContent.get("foo")).toEqual("bar"); +}); diff --git a/src/permissions.ts b/src/permissions.ts index a44427ef8..a45c117cc 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,5 +1,6 @@ -import { MapOpPayload } from "./cojsonValue"; -import { RecipientID, SignatoryID } from "./crypto"; +import { CoMap, CoValue, MapOpPayload } from "./cojsonValue"; +import { JsonValue } from "./jsonValue"; +import { KeyID, RecipientID, SealedSet, SignatoryID } from "./crypto"; import { AgentID, MultiLog, @@ -69,19 +70,32 @@ export function determineValidTransactions( // console.log("before", { memberState, validTransactions }); const transactor = agentIDfromSessionID(sessionID); - const change = tx.changes[0] as MapOpPayload; + const change = tx.changes[0] as + | MapOpPayload + | MapOpPayload<"readKey", JsonValue>; 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"); + 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; + } + + // 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 ( @@ -169,3 +183,15 @@ export function determineValidTransactions( throw new Error("Unknown ruleset type " + multilog.header.ruleset.type); } } + +export type TeamContent = { [key: AgentID]: Role } & { + readKey: { keyID: KeyID; revelation: SealedSet }; +}; + +export function expectTeam(content: CoValue): CoMap { + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + return content as CoMap; +}