diff --git a/src/crypto.test.ts b/src/crypto.test.ts index 5f6b2de92..f6714ce83 100644 --- a/src/crypto.test.ts +++ b/src/crypto.test.ts @@ -11,8 +11,10 @@ import { verify, shortHash, newRandomKeySecret, - encrypt, - decrypt, + encryptForTransaction, + decryptForTransaction, + sealKeySecret, + unsealKeySecret, } from "./crypto"; import { base58, base64url } from "@scure/base"; import { x25519 } from "@noble/curves/ed25519"; @@ -101,22 +103,22 @@ test("Hashing is deterministic", () => { ); }); -test("Encryption streams round-trip", () => { +test("Encryption for transactions round-trips", () => { const { secret } = newRandomKeySecret(); const encryptedChunks = [ - encrypt({ a: "hello" }, secret, { + encryptForTransaction({ a: "hello" }, secret, { in: "coval_zTEST", tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, }), - encrypt({ b: "world" }, secret, { + encryptForTransaction({ b: "world" }, secret, { in: "coval_zTEST", tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, }), ]; const decryptedChunks = encryptedChunks.map((chunk, i) => - decrypt(chunk, secret, { + decryptForTransaction(chunk, secret, { in: "coval_zTEST", tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: i }, }) @@ -125,23 +127,23 @@ test("Encryption streams round-trip", () => { expect(decryptedChunks).toEqual([{ a: "hello" }, { b: "world" }]); }); -test("Encryption streams don't decrypt with a wrong key", () => { +test("Encryption for transactions doesn't decrypt with a wrong key", () => { const { secret } = newRandomKeySecret(); const { secret: secret2 } = newRandomKeySecret(); const encryptedChunks = [ - encrypt({ a: "hello" }, secret, { + encryptForTransaction({ a: "hello" }, secret, { in: "coval_zTEST", tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, }), - encrypt({ b: "world" }, secret, { + encryptForTransaction({ b: "world" }, secret, { in: "coval_zTEST", tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, }), ]; const decryptedChunks = encryptedChunks.map((chunk, i) => - decrypt(chunk, secret2, { + decryptForTransaction(chunk, secret2, { in: "coval_zTEST", tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: i }, }) @@ -149,3 +151,36 @@ test("Encryption streams don't decrypt with a wrong key", () => { expect(decryptedChunks).toEqual([undefined, undefined]); }); + +test("Encryption of keySecrets round-trips", () => { + const toSeal = newRandomKeySecret(); + const sealing = newRandomKeySecret(); + + const keys = { + toSeal, + sealing, + }; + + const sealed = sealKeySecret(keys); + + const unsealed = unsealKeySecret(sealed, sealing.secret); + + expect(unsealed).toEqual(toSeal.secret); +}); + +test("Encryption of keySecrets doesn't unseal with a wrong key", () => { + const toSeal = newRandomKeySecret(); + const sealing = newRandomKeySecret(); + const sealingWrong = newRandomKeySecret(); + + const keys = { + toSeal, + sealing, + }; + + const sealed = sealKeySecret(keys); + + const unsealed = unsealKeySecret(sealed, sealingWrong.secret); + + expect(unsealed).toBeUndefined(); +}); diff --git a/src/crypto.ts b/src/crypto.ts index 2c9a8140e..d472cbb23 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -181,8 +181,7 @@ export function shortHash(value: JsonValue): ShortHash { )}`; } -export type Encrypted = - `encrypted_U${string}`; +export type Encrypted = `encrypted_U${string}`; export type KeySecret = `keySecret_z${string}`; export type KeyID = `key_z${string}`; @@ -194,7 +193,11 @@ export function newRandomKeySecret(): { secret: KeySecret; id: KeyID } { }; } -export function encrypt(value: T, keySecret: KeySecret, nOnceMaterial: {in: MultiLogID, tx: TransactionID}): Encrypted { +function encrypt( + value: T, + keySecret: KeySecret, + nOnceMaterial: N +): Encrypted { const keySecretBytes = base58.decode( keySecret.substring("keySecret_z".length) ); @@ -203,15 +206,43 @@ export function encrypt(value: T, keySecret: KeySecret, nOn ).slice(0, 24); const plaintext = textEncoder.encode(stableStringify(value)); - const ciphertext = xsalsa20( - keySecretBytes, - nOnce, - plaintext, - ); + 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 { +export function encryptForTransaction( + value: T, + keySecret: KeySecret, + nOnceMaterial: { in: MultiLogID; tx: TransactionID } +): 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 } { + const nOnceMaterial = { + sealed: keys.toSeal.id, + sealing: keys.sealing.id, + }; + + return { + sealed: keys.toSeal.id, + sealing: keys.sealing.id, + encrypted: encrypt( + keys.toSeal.secret, + keys.sealing.secret, + nOnceMaterial + ), + }; +} + +function decrypt( + encrypted: Encrypted, + keySecret: KeySecret, + nOnceMaterial: N +): T | undefined { const keySecretBytes = base58.decode( keySecret.substring("keySecret_z".length) ); @@ -222,15 +253,28 @@ export function decrypt(encrypted: Encrypted, keySecret: const ciphertext = base64url.decode( encrypted.substring("encrypted_U".length) ); - const plaintext = xsalsa20( - keySecretBytes, - nOnce, - ciphertext, - ); + const plaintext = xsalsa20(keySecretBytes, nOnce, ciphertext); try { return JSON.parse(textDecoder.decode(plaintext)); } catch (e) { return undefined; } -} \ No newline at end of file +} + +export function decryptForTransaction( + encrypted: Encrypted, + keySecret: KeySecret, + nOnceMaterial: { in: MultiLogID; tx: TransactionID } +): T | undefined { + return decrypt(encrypted, keySecret, nOnceMaterial); +} + +export function unsealKeySecret( + sealedInfo: { sealed: KeyID; sealing: KeyID; encrypted: Encrypted }, + sealingSecret: KeySecret +): KeySecret | undefined { + 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 d58a91f73..786dc0f11 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -18,9 +18,10 @@ import { shortHash, sign, verify, - encrypt, - decrypt, + encryptForTransaction, + decryptForTransaction, KeyID, + unsealKeySecret, } from "./crypto"; import { JsonValue } from "./jsonValue"; import { base58 } from "@scure/base"; @@ -122,7 +123,15 @@ export class MultiLog { agentCredential, ownSessionID, knownAgents, - this.requiredMultiLogs + Object.fromEntries( + Object.entries(this.requiredMultiLogs).map(([id, multilog]) => [ + id, + multilog.testWithDifferentCredentials( + agentCredential, + ownSessionID + ), + ]) + ) ); cloned.sessions = JSON.parse(JSON.stringify(this.sessions)); @@ -240,7 +249,7 @@ export class MultiLog { privacy: "private", madeAt, keyUsed: keyID, - encryptedChanges: encrypt(changes, keySecret, { + encryptedChanges: encryptForTransaction(changes, keySecret, { in: this.id, tx: this.nextTransactionID(), }), @@ -302,7 +311,7 @@ export class MultiLog { madeAt: tx.madeAt, changes: tx.privacy === "private" - ? decrypt( + ? decryptForTransaction( tx.encryptedChanges, this.getReadKey(tx.keyUsed), { @@ -385,7 +394,9 @@ export class MultiLog { const readKeyHistory = content.getHistory("readKey"); - const matchingEntry = readKeyHistory.find(entry => entry.value?.keyID === keyID); + const matchingEntry = readKeyHistory.find( + (entry) => entry.value?.keyID === keyID + ); if (!matchingEntry || !matchingEntry.value) { throw new Error("No matching readKey"); @@ -408,15 +419,30 @@ export class MultiLog { } ); - if (!secret) { - throw new Error("Couldn't decrypt readKey"); + 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; + } + } } - return secret as KeySecret; + 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); + return this.requiredMultiLogs[this.header.ruleset.team].getReadKey( + keyID + ); } else { throw new Error( "Only teams or values owned by teams have read secrets" diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 6586892c4..e17955c48 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -8,7 +8,12 @@ import { import { LocalNode } from "./node"; import { expectMap } from "./coValue"; import { expectTeam } from "./permissions"; -import { getRecipientID, newRandomKeySecret, seal } from "./crypto"; +import { + getRecipientID, + newRandomKeySecret, + seal, + sealKeySecret, +} from "./crypto"; function teamWithTwoAdmins() { const { team, admin, adminID } = newTeam(); @@ -245,7 +250,9 @@ test("Writers can write to an object that is owned by their team", () => { newRandomSessionID(writerID) ); - let childContentAsWriter = expectMap(childObjectAsWriter.getCurrentContent()); + let childContentAsWriter = expectMap( + childObjectAsWriter.getCurrentContent() + ); childContentAsWriter.edit((editable) => { editable.set("foo", "bar", "trusting"); @@ -279,7 +286,9 @@ test("Readers can not write to an object that is owned by their team", () => { newRandomSessionID(readerID) ); - let childContentAsReader = expectMap(childObjectAsReader.getCurrentContent()); + let childContentAsReader = expectMap( + childObjectAsReader.getCurrentContent() + ); childContentAsReader.edit((editable) => { editable.set("foo", "bar", "trusting"); @@ -348,7 +357,10 @@ test("Admins can set team read key and then writers can use it to create and rea const revelation = seal( readKey, admin.recipientSecret, - new Set([getRecipientID(admin.recipientSecret), getRecipientID(writer.recipientSecret)]), + new Set([ + getRecipientID(admin.recipientSecret), + getRecipientID(writer.recipientSecret), + ]), { in: team.id, tx: team.nextTransactionID(), @@ -370,7 +382,9 @@ test("Admins can set team read key and then writers can use it to create and rea expect(childObject.getCurrentReadKey().keySecret).toEqual(readKey); - let childContentAsWriter = expectMap(childObjectAsWriter.getCurrentContent()); + let childContentAsWriter = expectMap( + childObjectAsWriter.getCurrentContent() + ); childContentAsWriter.edit((editable) => { editable.set("foo", "bar", "private"); @@ -398,7 +412,10 @@ test("Admins can set team read key and then use it to create private transaction const revelation = seal( readKey, admin.recipientSecret, - new Set([getRecipientID(admin.recipientSecret), getRecipientID(reader.recipientSecret)]), + new Set([ + getRecipientID(admin.recipientSecret), + getRecipientID(reader.recipientSecret), + ]), { in: team.id, tx: team.nextTransactionID(), @@ -425,7 +442,9 @@ test("Admins can set team read key and then use it to create private transaction expect(childObjectAsReader.getCurrentReadKey().keySecret).toEqual(readKey); - const childContentAsReader = expectMap(childObjectAsReader.getCurrentContent()); + const childContentAsReader = expectMap( + childObjectAsReader.getCurrentContent() + ); expect(childContentAsReader.get("foo")).toEqual("bar"); }); @@ -501,4 +520,108 @@ test("Admins can set team read key, make a private transaction in an owned objec childContent = expectMap(childObject.getCurrentContent()); expect(childContent.get("foo")).toEqual("bar"); expect(childContent.get("foo2")).toEqual("bar2"); -}) \ No newline at end of file +}); + +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(); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + }); + + const teamContent = expectTeam(team.getCurrentContent()); + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); + + teamContent.edit((editable) => { + const revelation = seal( + readKey, + admin.recipientSecret, + new Set([getRecipientID(admin.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().keySecret).toEqual(readKey); + }); + + 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"); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret(); + + teamContent.edit((editable) => { + const revelation = seal( + readKey2, + admin.recipientSecret, + new Set([ + getRecipientID(admin.recipientSecret), + getRecipientID(reader.recipientSecret), + ]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + + editable.set( + "readKey", + { + keyID: readKeyID2, + revelation, + previousKeys: { + [readKeyID]: sealKeySecret({ + toSeal: { id: readKeyID, secret: readKey }, + sealing: { id: readKeyID2, secret: readKey2 }, + }).encrypted, + }, + }, + "trusting" + ); + expect(editable.get("readKey")).toMatchObject({ + keyID: readKeyID2, + revelation, + }); + expect(team.getCurrentReadKey().keySecret).toEqual(readKey2); + + editable.set(readerID, "reader", "trusting"); + expect(editable.get(readerID)).toEqual("reader"); + }); + + childContent.edit((editable) => { + editable.set("foo2", "bar2", "private"); + expect(editable.get("foo2")).toEqual("bar2"); + }); + + const childObjectAsReader = childObject.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + + expect(childObjectAsReader.getCurrentReadKey().keySecret).toEqual(readKey2); + + console.log(readKeyID2); + + const childContentAsReader = expectMap( + childObjectAsReader.getCurrentContent() + ); + + expect(childContentAsReader.get("foo")).toEqual("bar"); + expect(childContentAsReader.get("foo2")).toEqual("bar2"); +}); diff --git a/src/permissions.ts b/src/permissions.ts index d9fd25441..474bb5b6f 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,6 +1,6 @@ import { CoMap, CoValue, MapOpPayload } from "./coValue"; import { JsonValue } from "./jsonValue"; -import { KeyID, RecipientID, SealedSet, SignatoryID } from "./crypto"; +import { Encrypted, KeyID, KeySecret, RecipientID, SealedSet, SignatoryID } from "./crypto"; import { AgentID, MultiLog, @@ -185,7 +185,9 @@ export function determineValidTransactions( } export type TeamContent = { [key: AgentID]: Role } & { - readKey: { keyID: KeyID; revelation: SealedSet }; + readKey: { keyID: KeyID; revelation: SealedSet, previousKeys?: { + [key: KeyID]: Encrypted + } }; }; export function expectTeam(content: CoValue): CoMap {