diff --git a/src/coValue.ts b/src/coValue.ts index c04d99801..b498f77c9 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -153,9 +153,10 @@ export class CoMap< return json; } - edit(changer: (editable: WriteableCoMap) => void): void { + edit(changer: (editable: WriteableCoMap) => void): CoMap { const editable = new WriteableCoMap(this.multiLog); changer(editable); + return new CoMap(this.multiLog); } } diff --git a/src/crypto.test.ts b/src/crypto.test.ts index f6714ce83..fc4e0b1a2 100644 --- a/src/crypto.test.ts +++ b/src/crypto.test.ts @@ -106,50 +106,54 @@ test("Hashing is deterministic", () => { test("Encryption for transactions round-trips", () => { const { secret } = newRandomKeySecret(); - const encryptedChunks = [ - encryptForTransaction({ a: "hello" }, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, - }), - encryptForTransaction({ b: "world" }, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, - }), - ]; + const encrypted1 = encryptForTransaction({ a: "hello" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + }); - const decryptedChunks = encryptedChunks.map((chunk, i) => - decryptForTransaction(chunk, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: i }, - }) - ); + const encrypted2 = encryptForTransaction({ b: "world" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + }); - expect(decryptedChunks).toEqual([{ a: "hello" }, { b: "world" }]); + const decrypted1 = decryptForTransaction(encrypted1, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + }); + + const decrypted2 = decryptForTransaction(encrypted2, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_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 encryptedChunks = [ - encryptForTransaction({ a: "hello" }, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, - }), - encryptForTransaction({ b: "world" }, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, - }), - ]; + const encrypted1 = encryptForTransaction({ a: "hello" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + }); - const decryptedChunks = encryptedChunks.map((chunk, i) => - decryptForTransaction(chunk, secret2, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: i }, - }) - ); + const encrypted2 = encryptForTransaction({ b: "world" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + }); - expect(decryptedChunks).toEqual([undefined, undefined]); + const decrypted1 = decryptForTransaction(encrypted1, secret2, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + }); + + const decrypted2 = decryptForTransaction(encrypted2, secret2, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + }); + + expect([decrypted1, decrypted2]).toEqual([undefined, undefined]); }); test("Encryption of keySecrets round-trips", () => { diff --git a/src/crypto.ts b/src/crypto.ts index d472cbb23..b5ba53ece 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -13,7 +13,7 @@ export type Signature = `signature_z${string}`; export type RecipientSecret = `recipientSecret_z${string}`; export type RecipientID = `recipient_z${string}`; -export type Sealed = `sealed_U${string}`; +export type Sealed = `sealed_U${string}` & { __type: T }; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -64,16 +64,16 @@ export function getRecipientID(secret: RecipientSecret): RecipientID { )}`; } -export type SealedSet = { - [recipient: RecipientID]: Sealed; +export type SealedSet = { + [recipient: RecipientID]: Sealed; }; -export function seal( - message: JsonValue, +export function seal( + message: T, from: RecipientSecret, to: Set, nOnceMaterial: { in: MultiLogID; tx: TransactionID } -): SealedSet { +): SealedSet { const nOnce = blake3( textEncoder.encode(stableStringify(nOnceMaterial)) ).slice(0, 24); @@ -88,7 +88,7 @@ export function seal( const plaintext = textEncoder.encode(stableStringify(message)); - const sealedSet: SealedSet = {}; + const sealedSet: SealedSet = {}; for (let i = 0; i < recipientsSorted.length; i++) { const recipient = recipientsSorted[i]; @@ -101,18 +101,20 @@ export function seal( plaintext ); - sealedSet[recipient] = `sealed_U${base64url.encode(sealedBytes)}`; + sealedSet[recipient] = `sealed_U${base64url.encode( + sealedBytes + )}` as Sealed; } return sealedSet; } -export function openAs( - sealedSet: SealedSet, +export function openAs( + sealedSet: SealedSet, recipient: RecipientSecret, from: RecipientID, nOnceMaterial: { in: MultiLogID; tx: TransactionID } -): JsonValue | undefined { +): T | undefined { const nOnce = blake3( textEncoder.encode(stableStringify(nOnceMaterial)) ).slice(0, 24); @@ -140,6 +142,7 @@ export function openAs( try { return JSON.parse(textDecoder.decode(plaintext)); } catch (e) { + console.error("Failed to decrypt/parse sealed message", e); return undefined; } } @@ -181,7 +184,10 @@ export function shortHash(value: JsonValue): ShortHash { )}`; } -export type Encrypted = `encrypted_U${string}`; +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}`; @@ -197,7 +203,7 @@ function encrypt( value: T, keySecret: KeySecret, nOnceMaterial: N -): Encrypted { +): Encrypted { const keySecretBytes = base58.decode( keySecret.substring("keySecret_z".length) ); @@ -207,21 +213,25 @@ function encrypt( const plaintext = textEncoder.encode(stableStringify(value)); const ciphertext = xsalsa20(keySecretBytes, nOnce, plaintext); - return `encrypted_U${base64url.encode(ciphertext)}`; + return `encrypted_U${base64url.encode(ciphertext)}` as Encrypted; } export function encryptForTransaction( value: T, keySecret: KeySecret, nOnceMaterial: { in: MultiLogID; tx: TransactionID } -): Encrypted { +): Encrypted { return encrypt(value, keySecret, nOnceMaterial); } export function sealKeySecret(keys: { toSeal: { id: KeyID; secret: KeySecret }; sealing: { id: KeyID; secret: KeySecret }; -}): { sealed: KeyID; sealing: KeyID; encrypted: Encrypted } { +}): { + sealed: KeyID; + sealing: KeyID; + encrypted: Encrypted; +} { const nOnceMaterial = { sealed: keys.toSeal.id, sealing: keys.sealing.id, @@ -239,7 +249,7 @@ export function sealKeySecret(keys: { } function decrypt( - encrypted: Encrypted, + encrypted: Encrypted, keySecret: KeySecret, nOnceMaterial: N ): T | undefined { @@ -263,7 +273,7 @@ function decrypt( } export function decryptForTransaction( - encrypted: Encrypted, + encrypted: Encrypted, keySecret: KeySecret, nOnceMaterial: { in: MultiLogID; tx: TransactionID } ): T | undefined { @@ -271,10 +281,17 @@ export function decryptForTransaction( } export function unsealKeySecret( - sealedInfo: { sealed: KeyID; sealing: KeyID; encrypted: Encrypted }, + sealedInfo: { + sealed: KeyID; + sealing: KeyID; + encrypted: Encrypted; + }, sealingSecret: KeySecret ): KeySecret | undefined { - const nOnceMaterial = { sealed: sealedInfo.sealed, sealing: sealedInfo.sealing }; + const nOnceMaterial = { + sealed: sealedInfo.sealed, + sealing: sealedInfo.sealing, + }; return decrypt(sealedInfo.encrypted, sealingSecret, nOnceMaterial); } diff --git a/src/multilog.ts b/src/multilog.ts index 786dc0f11..221fe66ff 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -28,7 +28,7 @@ import { base58 } from "@scure/base"; import { PermissionsDef as RulesetDef, determineValidTransactions, - expectTeam, + expectTeamContent, } from "./permissions"; export type MultiLogID = `coval_${string}`; @@ -65,7 +65,7 @@ export type PrivateTransaction = { privacy: "private"; madeAt: number; keyUsed: KeyID; - encryptedChanges: Encrypted; + encryptedChanges: Encrypted; }; export type TrustingTransaction = { @@ -243,7 +243,7 @@ export class MultiLog { let transaction: Transaction; if (privacy === "private") { - const { keySecret, keyID } = this.getCurrentReadKey(); + const { secret: keySecret, id: keyID } = this.getCurrentReadKey(); transaction = { privacy: "private", @@ -336,46 +336,21 @@ export class MultiLog { return allTransactions; } - getCurrentReadKey(): { keySecret: KeySecret; keyID: KeyID } { + getCurrentReadKey(): { secret: KeySecret; id: KeyID } { if (this.header.ruleset.type === "team") { - const content = expectTeam(this.getCurrentContent()); + const content = expectTeamContent(this.getCurrentContent()); - const currentRevelation = content.get("readKey"); + const currentKeyId = content.get("readKey")?.keyID; - if (!currentRevelation) { - throw new Error("No readKey"); + if (!currentKeyId) { + throw new Error("No readKey set"); } - 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"); - } + const secret = this.getReadKey(currentKeyId); return { - keySecret: secret as KeySecret, - keyID: currentRevelation.keyID, + secret: secret, + id: currentKeyId, }; } else if (this.header.ruleset.type === "ownedByTeam") { return this.requiredMultiLogs[ @@ -390,36 +365,36 @@ export class MultiLog { getReadKey(keyID: KeyID): KeySecret { if (this.header.ruleset.type === "team") { - const content = expectTeam(this.getCurrentContent()); + const content = expectTeamContent(this.getCurrentContent()); const readKeyHistory = content.getHistory("readKey"); - const matchingEntry = readKeyHistory.find( - (entry) => entry.value?.keyID === keyID - ); + // Try to find direct relevation of key for us - if (!matchingEntry || !matchingEntry.value) { - throw new Error("No matching readKey"); - } + for (const entry of readKeyHistory) { + if (entry.value?.keyID === keyID) { + const revealer = agentIDfromSessionID(entry.txID.sessionID); + const revealerAgent = this.knownAgents[revealer]; - const revealer = agentIDfromSessionID(matchingEntry.txID.sessionID); - const revealerAgent = this.knownAgents[revealer]; + if (!revealerAgent) { + throw new Error("Unknown revealer"); + } - if (!revealerAgent) { - throw new Error("Unknown revealer"); - } + const secret = openAs( + entry.value.revelation, + this.agentCredential.recipientSecret, + revealerAgent.recipientID, + { + in: this.id, + tx: entry.txID, + } + ); - const secret = openAs( - matchingEntry.value.revelation, - this.agentCredential.recipientSecret, - revealerAgent.recipientID, - { - in: this.id, - tx: matchingEntry.txID, + if (secret) return secret as KeySecret; } - ); + } - if (secret) return secret as KeySecret; + // Try to find indirect revelation through previousKeys for (const entry of readKeyHistory) { if (entry.value?.previousKeys?.[keyID]) { @@ -430,15 +405,29 @@ export class MultiLog { continue; } - const secret = unsealKeySecret({ sealed: keyID, sealing: sealingKeyID, encrypted: entry.value.previousKeys[keyID] }, sealingKeySecret); + const secret = unsealKeySecret( + { + sealed: keyID, + sealing: sealingKeyID, + encrypted: entry.value.previousKeys[keyID], + }, + sealingKeySecret + ); if (secret) { return secret; + } else { + console.error(`Sealing ${sealingKeyID} key didn't unseal ${keyID}`); } } } - throw new Error("readKey " + keyID + " not revealed for " + getAgentID(getAgent(this.agentCredential))); + 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 diff --git a/src/node.ts b/src/node.ts index ce34db012..9e37ad48e 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,3 +1,5 @@ +import { CoMap } from "./coValue"; +import { newRandomKeySecret, seal } from "./crypto"; import { MultiLogID, MultiLog, @@ -10,6 +12,7 @@ import { getAgentMultilogHeader, MultiLogHeader, } from "./multilog"; +import { Team, expectTeamContent } from "./permissions"; export class LocalNode { multilogs: { [key: MultiLogID]: Promise | MultiLog } = {}; @@ -38,9 +41,14 @@ export class LocalNode { } createMultiLog(header: MultiLogHeader): MultiLog { - const requiredMultiLogs = header.ruleset.type === "ownedByTeam" ? { - [header.ruleset.team]: this.expectMultiLogLoaded(header.ruleset.team) - } : {}; + const requiredMultiLogs = + header.ruleset.type === "ownedByTeam" + ? { + [header.ruleset.team]: this.expectMultiLogLoaded( + header.ruleset.team + ), + } + : {}; const multilog = new MultiLog( header, @@ -63,6 +71,44 @@ export class LocalNode { } return multilog; } + + addKnownAgent(agent: Agent) { + const agentID = getAgentID(agent); + this.knownAgents[agentID] = agent; + } + + createTeam(): Team { + const teamMultilog = this.createMultiLog({ + type: "comap", + ruleset: { type: "team", initialAdmin: this.agentID }, + meta: null, + }); + + let teamContent = expectTeamContent(teamMultilog.getCurrentContent()); + + teamContent = teamContent.edit((editable) => { + editable.set(this.agentID, "admin", "trusting"); + + const readKey = newRandomKeySecret(); + const revelation = seal( + readKey.secret, + this.agentCredential.recipientSecret, + new Set([getAgent(this.agentCredential).recipientID]), + { + in: teamMultilog.id, + tx: teamMultilog.nextTransactionID(), + } + ); + + editable.set( + "readKey", + { keyID: readKey.id, revelation }, + "trusting" + ); + }); + + return new Team(teamContent, this); + } } // type Hostname = string; diff --git a/src/permissions.test.ts b/src/permissions.test.ts index e17955c48..041e5a0ac 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -7,7 +7,7 @@ import { } from "./multilog"; import { LocalNode } from "./node"; import { expectMap } from "./coValue"; -import { expectTeam } from "./permissions"; +import { expectTeamContent } from "./permissions"; import { getRecipientID, newRandomKeySecret, @@ -21,14 +21,14 @@ function teamWithTwoAdmins() { const otherAdmin = newRandomAgentCredential(); const otherAdminID = getAgentID(getAgent(otherAdmin)); - let content = expectTeam(team.getCurrentContent()); + let content = expectTeamContent(team.getCurrentContent()); content.edit((editable) => { editable.set(otherAdminID, "admin", "trusting"); expect(editable.get(otherAdminID)).toEqual("admin"); }); - content = expectTeam(team.getCurrentContent()); + content = expectTeamContent(team.getCurrentContent()); if (content.type !== "comap") { throw new Error("Expected map"); @@ -50,7 +50,7 @@ function newTeam() { meta: null, }); - const teamContent = expectTeam(team.getCurrentContent()); + const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { editable.set(adminID, "admin", "trusting"); @@ -64,6 +64,34 @@ test("Initial admin can add another admin to a team", () => { teamWithTwoAdmins(); }); +function newTeamHighLevel() { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node.createTeam(); + + return { admin, adminID, node, team }; +} + +function teamWithTwoAdminsHighLevel() { + const { admin, adminID, node, team } = newTeamHighLevel(); + + const otherAdmin = newRandomAgentCredential(); + const otherAdminID = getAgentID(getAgent(otherAdmin)); + + node.addKnownAgent(getAgent(otherAdmin)); + + team.addMember(otherAdminID, "admin"); + + return { admin, adminID, node, team, otherAdmin, otherAdminID }; +} + +test("Initial admin can add another admin to a team (high level)", () => { + teamWithTwoAdminsHighLevel(); +}); + test("Added admin can add a third admin to a team", () => { const { team, otherAdmin, otherAdminID } = teamWithTwoAdmins(); @@ -72,7 +100,7 @@ test("Added admin can add a third admin to a team", () => { newRandomSessionID(otherAdminID) ); - let otherContent = expectTeam(teamAsOtherAdmin.getCurrentContent()); + let otherContent = expectTeamContent(teamAsOtherAdmin.getCurrentContent()); expect(otherContent.get(otherAdminID)).toEqual("admin"); @@ -84,22 +112,41 @@ test("Added admin can add a third admin to a team", () => { expect(editable.get(thirdAdminID)).toEqual("admin"); }); - otherContent = expectTeam(teamAsOtherAdmin.getCurrentContent()); + otherContent = expectTeamContent(teamAsOtherAdmin.getCurrentContent()); expect(otherContent.get(thirdAdminID)).toEqual("admin"); }); +test("Added adming can add a third admin to a team (high level)", () => { + const { team, otherAdmin, otherAdminID, node } = + teamWithTwoAdminsHighLevel(); + + const teamAsOtherAdmin = team.testWithDifferentCredentials( + otherAdmin, + newRandomSessionID(otherAdminID) + ); + + const thirdAdmin = newRandomAgentCredential(); + const thirdAdminID = getAgentID(getAgent(thirdAdmin)); + + node.addKnownAgent(getAgent(thirdAdmin)); + + teamAsOtherAdmin.addMember(thirdAdminID, "admin"); + + expect(teamAsOtherAdmin.teamMap.get(thirdAdminID)).toEqual("admin"); +}); + test("Admins can't demote other admins in a team", () => { const { team, adminID, otherAdmin, otherAdminID } = teamWithTwoAdmins(); - let teamContent = expectTeam(team.getCurrentContent()); + let teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { editable.set(otherAdminID, "writer", "trusting"); expect(editable.get(otherAdminID)).toEqual("admin"); }); - teamContent = expectTeam(team.getCurrentContent()); + teamContent = expectTeamContent(team.getCurrentContent()); expect(teamContent.get(otherAdminID)).toEqual("admin"); const teamAsOtherAdmin = team.testWithDifferentCredentials( @@ -107,7 +154,7 @@ test("Admins can't demote other admins in a team", () => { newRandomSessionID(otherAdminID) ); - let teamContentAsOtherAdmin = expectTeam( + let teamContentAsOtherAdmin = expectTeamContent( teamAsOtherAdmin.getCurrentContent() ); @@ -116,24 +163,42 @@ test("Admins can't demote other admins in a team", () => { expect(editable.get(adminID)).toEqual("admin"); }); - teamContentAsOtherAdmin = expectTeam(teamAsOtherAdmin.getCurrentContent()); + teamContentAsOtherAdmin = expectTeamContent( + teamAsOtherAdmin.getCurrentContent() + ); expect(teamContentAsOtherAdmin.get(adminID)).toEqual("admin"); }); +test("Admins can't demote other admins in a team (high level)", () => { + const { team, adminID, otherAdmin, otherAdminID } = + teamWithTwoAdminsHighLevel(); + + const teamAsOtherAdmin = team.testWithDifferentCredentials( + otherAdmin, + newRandomSessionID(otherAdminID) + ); + + expect(() => teamAsOtherAdmin.addMember(adminID, "writer")).toThrow( + "Failed to set role" + ); + + expect(teamAsOtherAdmin.teamMap.get(adminID)).toEqual("admin"); +}); + test("Admins an add writers to a team, who can't add admins, writers, or readers", () => { const { team } = newTeam(); const writer = newRandomAgentCredential(); const writerID = getAgentID(getAgent(writer)); - let teamContent = expectTeam(team.getCurrentContent()); + let teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { editable.set(writerID, "writer", "trusting"); expect(editable.get(writerID)).toEqual("writer"); }); - teamContent = expectTeam(team.getCurrentContent()); + teamContent = expectTeamContent(team.getCurrentContent()); expect(teamContent.get(writerID)).toEqual("writer"); const teamAsWriter = team.testWithDifferentCredentials( @@ -141,7 +206,9 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers newRandomSessionID(writerID) ); - let teamContentAsWriter = expectTeam(teamAsWriter.getCurrentContent()); + let teamContentAsWriter = expectTeamContent( + teamAsWriter.getCurrentContent() + ); expect(teamContentAsWriter.get(writerID)).toEqual("writer"); @@ -159,24 +226,60 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers expect(editable.get(otherAgentID)).toBeUndefined(); }); - teamContentAsWriter = expectTeam(teamAsWriter.getCurrentContent()); + teamContentAsWriter = expectTeamContent(teamAsWriter.getCurrentContent()); expect(teamContentAsWriter.get(otherAgentID)).toBeUndefined(); }); +test("Admins an add writers to a team, who can't add admins, writers, or readers (high level)", () => { + const { team, node } = newTeamHighLevel(); + + const writer = newRandomAgentCredential(); + const writerID = getAgentID(getAgent(writer)); + + node.addKnownAgent(getAgent(writer)); + + team.addMember(writerID, "writer"); + expect(team.teamMap.get(writerID)).toEqual("writer"); + + const teamAsWriter = team.testWithDifferentCredentials( + writer, + newRandomSessionID(writerID) + ); + + expect(teamAsWriter.teamMap.get(writerID)).toEqual("writer"); + + const otherAgent = newRandomAgentCredential(); + const otherAgentID = getAgentID(getAgent(otherAgent)); + + node.addKnownAgent(getAgent(otherAgent)); + + expect(() => teamAsWriter.addMember(otherAgentID, "admin")).toThrow( + "Failed to set role" + ); + expect(() => teamAsWriter.addMember(otherAgentID, "writer")).toThrow( + "Failed to set role" + ); + expect(() => teamAsWriter.addMember(otherAgentID, "reader")).toThrow( + "Failed to set role" + ); + + expect(teamAsWriter.teamMap.get(otherAgentID)).toBeUndefined(); +}); + test("Admins can add readers to a team, who can't add admins, writers, or readers", () => { const { team } = newTeam(); const reader = newRandomAgentCredential(); const readerID = getAgentID(getAgent(reader)); - let teamContent = expectTeam(team.getCurrentContent()); + let teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { editable.set(readerID, "reader", "trusting"); expect(editable.get(readerID)).toEqual("reader"); }); - teamContent = expectTeam(team.getCurrentContent()); + teamContent = expectTeamContent(team.getCurrentContent()); expect(teamContent.get(readerID)).toEqual("reader"); const teamAsReader = team.testWithDifferentCredentials( @@ -184,7 +287,9 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader newRandomSessionID(readerID) ); - let teamContentAsReader = expectTeam(teamAsReader.getCurrentContent()); + let teamContentAsReader = expectTeamContent( + teamAsReader.getCurrentContent() + ); expect(teamContentAsReader.get(readerID)).toEqual("reader"); @@ -202,11 +307,47 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader expect(editable.get(otherAgentID)).toBeUndefined(); }); - teamContentAsReader = expectTeam(teamAsReader.getCurrentContent()); + teamContentAsReader = expectTeamContent(teamAsReader.getCurrentContent()); expect(teamContentAsReader.get(otherAgentID)).toBeUndefined(); }); +test("Admins can add readers to a team, who can't add admins, writers, or readers (high level)", () => { + const { team, node } = newTeamHighLevel(); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + + node.addKnownAgent(getAgent(reader)); + + team.addMember(readerID, "reader"); + expect(team.teamMap.get(readerID)).toEqual("reader"); + + const teamAsReader = team.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + + expect(teamAsReader.teamMap.get(readerID)).toEqual("reader"); + + const otherAgent = newRandomAgentCredential(); + const otherAgentID = getAgentID(getAgent(otherAgent)); + + node.addKnownAgent(getAgent(otherAgent)); + + expect(() => teamAsReader.addMember(otherAgentID, "admin")).toThrow( + "Failed to set role" + ); + expect(() => teamAsReader.addMember(otherAgentID, "writer")).toThrow( + "Failed to set role" + ); + expect(() => teamAsReader.addMember(otherAgentID, "reader")).toThrow( + "Failed to set role" + ); + + expect(teamAsReader.teamMap.get(otherAgentID)).toBeUndefined(); +}); + test("Admins can write to an object that is owned by their team", () => { const { node, team } = newTeam(); @@ -228,13 +369,26 @@ test("Admins can write to an object that is owned by their team", () => { expect(childContent.get("foo")).toEqual("bar"); }); +test("Admins can write to an object that is owned by their team (high level)", () => { + const { node, team } = newTeamHighLevel(); + + let childObject = team.createMap(); + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "trusting"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObject.get("foo")).toEqual("bar"); +}); + test("Writers can write to an object that is owned by their team", () => { const { node, team } = newTeam(); const writer = newRandomAgentCredential(); const writerID = getAgentID(getAgent(writer)); - expectTeam(team.getCurrentContent()).edit((editable) => { + expectTeamContent(team.getCurrentContent()).edit((editable) => { editable.set(writerID, "writer", "trusting"); expect(editable.get(writerID)).toEqual("writer"); }); @@ -264,13 +418,39 @@ test("Writers can write to an object that is owned by their team", () => { expect(childContentAsWriter.get("foo")).toEqual("bar"); }); +test("Writers can write to an object that is owned by their team (high level)", () => { + const { node, team } = newTeamHighLevel(); + + const writer = newRandomAgentCredential(); + const writerID = getAgentID(getAgent(writer)); + + node.addKnownAgent(getAgent(writer)); + + team.addMember(writerID, "writer"); + + const childObject = team.createMap(); + + let childObjectAsWriter = expectMap( + childObject.multiLog + .testWithDifferentCredentials(writer, newRandomSessionID(writerID)) + .getCurrentContent() + ); + + childObjectAsWriter = childObjectAsWriter.edit((editable) => { + editable.set("foo", "bar", "trusting"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObjectAsWriter.get("foo")).toEqual("bar"); +}); + test("Readers can not write to an object that is owned by their team", () => { const { node, team } = newTeam(); const reader = newRandomAgentCredential(); const readerID = getAgentID(getAgent(reader)); - expectTeam(team.getCurrentContent()).edit((editable) => { + expectTeamContent(team.getCurrentContent()).edit((editable) => { editable.set(readerID, "reader", "trusting"); expect(editable.get(readerID)).toEqual("reader"); }); @@ -300,10 +480,36 @@ test("Readers can not write to an object that is owned by their team", () => { expect(childContentAsReader.get("foo")).toBeUndefined(); }); +test("Readers can not write to an object that is owned by their team (high level)", () => { + const { node, team } = newTeamHighLevel(); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + + node.addKnownAgent(getAgent(reader)); + + team.addMember(readerID, "reader"); + + const childObject = team.createMap(); + + let childObjectAsReader = expectMap( + childObject.multiLog + .testWithDifferentCredentials(reader, newRandomSessionID(readerID)) + .getCurrentContent() + ); + + childObjectAsReader = childObjectAsReader.edit((editable) => { + editable.set("foo", "bar", "trusting"); + expect(editable.get("foo")).toBeUndefined(); + }); + + expect(childObjectAsReader.get("foo")).toBeUndefined(); +}); + 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()); + const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { const { secret: readKey, id: readKeyID } = newRandomKeySecret(); @@ -321,7 +527,7 @@ test("Admins can set team read key and then use it to create and read private tr keyID: readKeyID, revelation, }); - expect(team.getCurrentReadKey().keySecret).toEqual(readKey); + expect(team.getCurrentReadKey().secret).toEqual(readKey); }); const childObject = node.createMultiLog({ @@ -341,6 +547,19 @@ test("Admins can set team read key and then use it to create and read private tr expect(childContent.get("foo")).toEqual("bar"); }); +test("Admins can set team read key and then use it to create and read private transactions in owned objects (high level)", () => { + const { node, team, admin } = newTeamHighLevel(); + + let childObject = team.createMap(); + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObject.get("foo")).toEqual("bar"); +}); + test("Admins can set team read key and then writers can use it to create and read private transactions in owned objects", () => { const { node, team, admin } = newTeam(); @@ -348,7 +567,7 @@ test("Admins can set team read key and then writers can use it to create and rea const writerID = getAgentID(getAgent(writer)); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); - const teamContent = expectTeam(team.getCurrentContent()); + const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { editable.set(writerID, "writer", "trusting"); @@ -380,7 +599,7 @@ test("Admins can set team read key and then writers can use it to create and rea newRandomSessionID(writerID) ); - expect(childObject.getCurrentReadKey().keySecret).toEqual(readKey); + expect(childObject.getCurrentReadKey().secret).toEqual(readKey); let childContentAsWriter = expectMap( childObjectAsWriter.getCurrentContent() @@ -396,6 +615,32 @@ test("Admins can set team read key and then writers can use it to create and rea expect(childContentAsWriter.get("foo")).toEqual("bar"); }); +test("Admins can set team read key and then writers can use it to create and read private transactions in owned objects (high level)", () => { + const { node, team, admin } = newTeamHighLevel(); + + const writer = newRandomAgentCredential(); + const writerID = getAgentID(getAgent(writer)); + + node.addKnownAgent(getAgent(writer)); + + team.addMember(writerID, "writer"); + + const childObject = team.createMap(); + + let childObjectAsWriter = expectMap( + childObject.multiLog + .testWithDifferentCredentials(writer, newRandomSessionID(writerID)) + .getCurrentContent() + ); + + childObjectAsWriter = childObjectAsWriter.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObjectAsWriter.get("foo")).toEqual("bar"); +}); + test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read", () => { const { node, team, admin } = newTeam(); @@ -403,7 +648,7 @@ test("Admins can set team read key and then use it to create private transaction const readerID = getAgentID(getAgent(reader)); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); - const teamContent = expectTeam(team.getCurrentContent()); + const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { editable.set(readerID, "reader", "trusting"); @@ -440,7 +685,7 @@ test("Admins can set team read key and then use it to create private transaction newRandomSessionID(readerID) ); - expect(childObjectAsReader.getCurrentReadKey().keySecret).toEqual(readKey); + expect(childObjectAsReader.getCurrentReadKey().secret).toEqual(readKey); const childContentAsReader = expectMap( childObjectAsReader.getCurrentContent() @@ -449,10 +694,161 @@ test("Admins can set team read key and then use it to create private transaction expect(childContentAsReader.get("foo")).toEqual("bar"); }); +test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read (high level)", () => { + const { node, team, admin } = newTeamHighLevel(); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + + node.addKnownAgent(getAgent(reader)); + + team.addMember(readerID, "reader"); + + let childObject = team.createMap(); + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + const childContentAsReader = expectMap(childObject.multiLog.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ).getCurrentContent()); + + expect(childContentAsReader.get("foo")).toEqual("bar"); +}); + + +test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read, even with a separate later revelation for the same read key", () => { + const { node, team, admin } = newTeam(); + + const reader1 = newRandomAgentCredential(); + const reader1ID = getAgentID(getAgent(reader1)); + const reader2 = newRandomAgentCredential(); + const reader2ID = getAgentID(getAgent(reader2)); + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); + + const teamContent = expectTeamContent(team.getCurrentContent()); + + teamContent.edit((editable) => { + editable.set(reader1ID, "reader", "trusting"); + expect(editable.get(reader1ID)).toEqual("reader"); + + const revelation1 = seal( + readKey, + admin.recipientSecret, + new Set([ + getRecipientID(admin.recipientSecret), + getRecipientID(reader1.recipientSecret), + ]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set( + "readKey", + { keyID: readKeyID, revelation: revelation1 }, + "trusting" + ); + + const revelation2 = seal( + readKey, + admin.recipientSecret, + new Set([getRecipientID(reader2.recipientSecret)]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set( + "readKey", + { keyID: readKeyID, revelation: revelation2 }, + "trusting" + ); + }); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + }); + + expectMap(childObject.getCurrentContent()).edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + const childObjectAsReader1 = childObject.testWithDifferentCredentials( + reader1, + newRandomSessionID(reader1ID) + ); + + expect(childObjectAsReader1.getCurrentReadKey().secret).toEqual(readKey); + + const childContentAsReader1 = expectMap( + childObjectAsReader1.getCurrentContent() + ); + + expect(childContentAsReader1.get("foo")).toEqual("bar"); + + const childObjectAsReader2 = childObject.testWithDifferentCredentials( + reader2, + newRandomSessionID(reader2ID) + ); + + expect(childObjectAsReader2.getCurrentReadKey().secret).toEqual(readKey); + + const childContentAsReader2 = expectMap( + childObjectAsReader2.getCurrentContent() + ); + + expect(childContentAsReader2.get("foo")).toEqual("bar"); +}); + +test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read, even with a separate later revelation for the same read key (high level)", () => { + const { node, team, admin } = newTeamHighLevel(); + + const reader1 = newRandomAgentCredential(); + const reader1ID = getAgentID(getAgent(reader1)); + const reader2 = newRandomAgentCredential(); + const reader2ID = getAgentID(getAgent(reader2)); + + node.addKnownAgent(getAgent(reader1)); + node.addKnownAgent(getAgent(reader2)); + + team.addMember(reader1ID, "reader"); + + let childObject = team.createMap(); + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + const childContentAsReader1 = expectMap(childObject.multiLog.testWithDifferentCredentials( + reader1, + newRandomSessionID(reader1ID) + ).getCurrentContent()); + + expect(childContentAsReader1.get("foo")).toEqual("bar"); + + team.addMember(reader2ID, "reader"); + + const childContentAsReader2 = expectMap(childObject.multiLog.testWithDifferentCredentials( + reader2, + newRandomSessionID(reader2ID) + ).getCurrentContent()); + + expect(childContentAsReader2.get("foo")).toEqual("bar"); +}); + + test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, make another private transaction, and both can be read by the admin", () => { const { node, team, admin, adminID } = newTeam(); - const teamContent = expectTeam(team.getCurrentContent()); + const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { const { secret: readKey, id: readKeyID } = newRandomKeySecret(); @@ -470,7 +866,7 @@ test("Admins can set team read key, make a private transaction in an owned objec keyID: readKeyID, revelation, }); - expect(team.getCurrentReadKey().keySecret).toEqual(readKey); + expect(team.getCurrentReadKey().secret).toEqual(readKey); }); const childObject = node.createMultiLog({ @@ -507,7 +903,7 @@ test("Admins can set team read key, make a private transaction in an owned objec keyID: readKeyID2, revelation, }); - expect(team.getCurrentReadKey().keySecret).toEqual(readKey2); + expect(team.getCurrentReadKey().secret).toEqual(readKey2); }); childContent = expectMap(childObject.getCurrentContent()); @@ -522,6 +918,33 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(childContent.get("foo2")).toEqual("bar2"); }); +test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, make another private transaction, and both can be read by the admin (high level)", () => { + const { node, team, admin, adminID } = newTeamHighLevel(); + + let childObject = team.createMap(); + + const firstReadKey = childObject.multiLog.getCurrentReadKey(); + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObject.get("foo")).toEqual("bar"); + + team.rotateReadKey(); + + expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(firstReadKey); + + childObject = childObject.edit((editable) => { + editable.set("foo2", "bar2", "private"); + expect(editable.get("foo2")).toEqual("bar2"); + }); + + expect(childObject.get("foo")).toEqual("bar"); + expect(childObject.get("foo2")).toEqual("bar2"); +}); + test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, add a reader, make another private transaction in the owned object, and both can be read by the reader", () => { const { node, team, admin, adminID } = newTeam(); @@ -531,7 +954,7 @@ test("Admins can set team read key, make a private transaction in an owned objec meta: null, }); - const teamContent = expectTeam(team.getCurrentContent()); + const teamContent = expectTeamContent(team.getCurrentContent()); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); teamContent.edit((editable) => { @@ -549,7 +972,7 @@ test("Admins can set team read key, make a private transaction in an owned objec keyID: readKeyID, revelation, }); - expect(team.getCurrentReadKey().keySecret).toEqual(readKey); + expect(team.getCurrentReadKey().secret).toEqual(readKey); }); let childContent = expectMap(childObject.getCurrentContent()); @@ -598,7 +1021,7 @@ test("Admins can set team read key, make a private transaction in an owned objec keyID: readKeyID2, revelation, }); - expect(team.getCurrentReadKey().keySecret).toEqual(readKey2); + expect(team.getCurrentReadKey().secret).toEqual(readKey2); editable.set(readerID, "reader", "trusting"); expect(editable.get(readerID)).toEqual("reader"); @@ -614,9 +1037,7 @@ test("Admins can set team read key, make a private transaction in an owned objec newRandomSessionID(readerID) ); - expect(childObjectAsReader.getCurrentReadKey().keySecret).toEqual(readKey2); - - console.log(readKeyID2); + expect(childObjectAsReader.getCurrentReadKey().secret).toEqual(readKey2); const childContentAsReader = expectMap( childObjectAsReader.getCurrentContent() @@ -625,3 +1046,229 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(childContentAsReader.get("foo")).toEqual("bar"); expect(childContentAsReader.get("foo2")).toEqual("bar2"); }); + +test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, add a reader, make another private transaction in the owned object, and both can be read by the reader (high level)", () => { + const { node, team, admin, adminID } = newTeamHighLevel(); + + let childObject = team.createMap(); + + const firstReadKey = childObject.multiLog.getCurrentReadKey(); + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObject.get("foo")).toEqual("bar"); + + team.rotateReadKey(); + + expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(firstReadKey); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + + node.addKnownAgent(getAgent(reader)); + + team.addMember(readerID, "reader"); + + childObject = childObject.edit((editable) => { + editable.set("foo2", "bar2", "private"); + expect(editable.get("foo2")).toEqual("bar2"); + }); + + const childContentAsReader = expectMap(childObject.multiLog.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ).getCurrentContent()); + + expect(childContentAsReader.get("foo")).toEqual("bar"); + expect(childContentAsReader.get("foo2")).toEqual("bar2"); +}) + + +test("Admins can set team read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions", () => { + const { node, team, admin, adminID } = newTeam(); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + }); + + const teamContent = expectTeamContent(team.getCurrentContent()); + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + const reader2 = newRandomAgentCredential(); + const reader2ID = getAgentID(getAgent(reader)); + + teamContent.edit((editable) => { + const revelation = seal( + readKey, + admin.recipientSecret, + new Set([ + getRecipientID(admin.recipientSecret), + getRecipientID(reader.recipientSecret), + getRecipientID(reader2.recipientSecret), + ]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); + expect(editable.get("readKey")).toEqual({ + keyID: readKeyID, + revelation, + }); + expect(team.getCurrentReadKey().secret).toEqual(readKey); + + editable.set(readerID, "reader", "trusting"); + expect(editable.get(readerID)).toEqual("reader"); + editable.set(reader2ID, "reader", "trusting"); + expect(editable.get(reader2ID)).toEqual("reader"); + }); + + 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"); + + let childObjectAsReader = childObject.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + + expect( + expectMap(childObjectAsReader.getCurrentContent()).get("foo") + ).toEqual("bar"); + + let childObjectAsReader2 = childObject.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + + expect( + expectMap(childObjectAsReader2.getCurrentContent()).get("foo") + ).toEqual("bar"); + + const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret(); + + teamContent.edit((editable) => { + const revelation = seal( + readKey2, + admin.recipientSecret, + new Set([ + getRecipientID(admin.recipientSecret), + getRecipientID(reader2.recipientSecret), + ]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set("readKey", { keyID: readKeyID2, revelation }, "trusting"); + expect(editable.get("readKey")).toEqual({ + keyID: readKeyID2, + revelation, + }); + expect(team.getCurrentReadKey().secret).toEqual(readKey2); + + editable.set(readerID, "revoked", "trusting"); + // expect(editable.get(readerID)).toEqual("revoked"); + }); + + expect(childObject.getCurrentReadKey().secret).toEqual(readKey2); + + childContent = expectMap(childObject.getCurrentContent()); + childContent.edit((editable) => { + editable.set("foo2", "bar2", "private"); + expect(editable.get("foo2")).toEqual("bar2"); + }); + + // TODO: make sure these instances of multilogs sync between each other so this isn't necessary? + childObjectAsReader = childObject.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + childObjectAsReader2 = childObject.testWithDifferentCredentials( + reader2, + newRandomSessionID(reader2ID) + ); + + expect(() => expectMap(childObjectAsReader.getCurrentContent())).toThrow( + /readKey (.+?) not revealed for (.+?)/ + ); + expect( + expectMap(childObjectAsReader2.getCurrentContent()).get("foo2") + ).toEqual("bar2"); + expect(() => { + childObjectAsReader.getCurrentContent(); + }).toThrow(); +}); + +test("Admins can set team read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions (high level)", () => { + const { node, team, admin, adminID } = newTeamHighLevel(); + + let childObject = team.createMap(); + + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObject.get("foo")).toEqual("bar"); + + team.rotateReadKey(); + + const secondReadKey = childObject.multiLog.getCurrentReadKey(); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + const reader2 = newRandomAgentCredential(); + const reader2ID = getAgentID(getAgent(reader2)); + + node.addKnownAgent(getAgent(reader)); + node.addKnownAgent(getAgent(reader2)); + + team.addMember(readerID, "reader"); + team.addMember(reader2ID, "reader"); + + childObject = childObject.edit((editable) => { + editable.set("foo2", "bar2", "private"); + expect(editable.get("foo2")).toEqual("bar2"); + }); + + expect(childObject.get("foo")).toEqual("bar"); + expect(childObject.get("foo2")).toEqual("bar2"); + + team.removeMember(readerID); + + expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(secondReadKey); + + childObject = childObject.edit((editable) => { + editable.set("foo3", "bar3", "private"); + expect(editable.get("foo3")).toEqual("bar3"); + }); + + const childContentAsReader2 = expectMap(childObject.multiLog.testWithDifferentCredentials( + reader2, + newRandomSessionID(reader2ID) + ).getCurrentContent()); + + expect(childContentAsReader2.get("foo")).toEqual("bar"); + expect(childContentAsReader2.get("foo2")).toEqual("bar2"); + expect(childContentAsReader2.get("foo3")).toEqual("bar3"); + + expect(() => childObject.multiLog.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ).getCurrentContent()).toThrow(/readKey (.+?) not revealed for (.+?)/); +}); diff --git a/src/permissions.ts b/src/permissions.ts index 474bb5b6f..a2f4f1ce8 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,7 +1,19 @@ import { CoMap, CoValue, MapOpPayload } from "./coValue"; import { JsonValue } from "./jsonValue"; -import { Encrypted, KeyID, KeySecret, RecipientID, SealedSet, SignatoryID } from "./crypto"; import { + Encrypted, + KeyID, + KeySecret, + RecipientID, + SealedSet, + SignatoryID, + encryptForTransaction, + newRandomKeySecret, + seal, + sealKeySecret, +} from "./crypto"; +import { + AgentCredential, AgentID, MultiLog, MultiLogID, @@ -11,6 +23,7 @@ import { TrustingTransaction, agentIDfromSessionID, } from "./multilog"; +import { LocalNode } from "."; export type PermissionsDef = | { type: "team"; initialAdmin: AgentID; parentTeams?: MultiLogID[] } @@ -101,7 +114,8 @@ export function determineValidTransactions( if ( change.value !== "admin" && change.value !== "writer" && - change.value !== "reader" + change.value !== "reader" && + change.value !== "revoked" ) { console.warn("Team transaction must set a valid role"); continue; @@ -185,15 +199,150 @@ export function determineValidTransactions( } export type TeamContent = { [key: AgentID]: Role } & { - readKey: { keyID: KeyID; revelation: SealedSet, previousKeys?: { - [key: KeyID]: Encrypted - } }; + readKey: { + keyID: KeyID; + revelation: SealedSet; + previousKeys?: { + [key: KeyID]: Encrypted< + KeySecret, + { sealed: KeyID; sealing: KeyID } + >; + }; + }; }; -export function expectTeam(content: CoValue): CoMap { +export function expectTeamContent(content: CoValue): CoMap { if (content.type !== "comap") { throw new Error("Expected map"); } return content as CoMap; } + +export class Team { + teamMap: CoMap; + node: LocalNode; + + constructor(teamMap: CoMap, node: LocalNode) { + this.teamMap = teamMap; + this.node = node; + } + + addMember(agentID: AgentID, role: Role) { + this.teamMap = this.teamMap.edit((map) => { + const agent = this.node.knownAgents[agentID]; + + if (!agent) { + throw new Error("Unknown agent " + agentID); + } + + map.set(agentID, role, "trusting"); + if (map.get(agentID) !== role) { + throw new Error("Failed to set role"); + } + + const currentReadKey = this.teamMap.multiLog.getCurrentReadKey(); + + const revelation = seal( + currentReadKey.secret, + this.teamMap.multiLog.agentCredential.recipientSecret, + new Set([agent.recipientID]), + { + in: this.teamMap.multiLog.id, + tx: this.teamMap.multiLog.nextTransactionID(), + } + ); + + map.set( + "readKey", + { keyID: currentReadKey.id, revelation }, + "trusting" + ); + }); + } + + rotateReadKey() { + const currentlyPermittedReaders = this.teamMap.keys().filter((key) => { + if (key.startsWith("agent_")) { + const role = this.teamMap.get(key); + return ( + role === "admin" || role === "writer" || role === "reader" + ); + } else { + return false; + } + }) as AgentID[]; + + const currentReadKey = this.teamMap.multiLog.getCurrentReadKey(); + + const newReadKey = newRandomKeySecret(); + + const newReadKeyRevelation = seal( + newReadKey.secret, + this.teamMap.multiLog.agentCredential.recipientSecret, + new Set( + currentlyPermittedReaders.map( + (reader) => this.node.knownAgents[reader].recipientID + ) + ), + { + in: this.teamMap.multiLog.id, + tx: this.teamMap.multiLog.nextTransactionID(), + } + ); + + this.teamMap = this.teamMap.edit((map) => { + map.set( + "readKey", + { + keyID: newReadKey.id, + revelation: newReadKeyRevelation, + previousKeys: { + [currentReadKey.id]: sealKeySecret({ + sealing: newReadKey, + toSeal: currentReadKey, + }).encrypted, + }, + }, + "trusting" + ); + }); + } + + removeMember(agentID: AgentID) { + this.teamMap = this.teamMap.edit((map) => { + map.set(agentID, "revoked", "trusting"); + }); + + this.rotateReadKey(); + } + + createMap( + meta?: M + ): CoMap { + return this.node + .createMultiLog({ + type: "comap", + ruleset: { + type: "ownedByTeam", + team: this.teamMap.id, + }, + meta: meta || null, + }) + .getCurrentContent() as CoMap; + } + + testWithDifferentCredentials( + credential: AgentCredential, + sessionId: SessionID + ): Team { + return new Team( + expectTeamContent( + this.teamMap.multiLog + .testWithDifferentCredentials(credential, sessionId) + .getCurrentContent() + ), + this.node + ); + } +}