Simple tx encryption based on current key
This commit is contained in:
@@ -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);
|
||||
});
|
||||
})
|
||||
@@ -108,6 +108,17 @@ export class CoMap<
|
||||
}
|
||||
}
|
||||
|
||||
getLastTxID<KK extends K>(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<T extends JsonValue> {
|
||||
this.id = multilog.id as CoValueID<Static<T>>;
|
||||
}
|
||||
}
|
||||
|
||||
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 }, {}>;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
131
src/crypto.ts
131
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<T extends JsonValue> =
|
||||
`encryptedChunk_U${string}`;
|
||||
export type Encrypted<T extends JsonValue> =
|
||||
`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<T extends JsonValue>(value: T, keySecret: KeySecret, nOnceMaterial: {in: MultiLogID, tx: TransactionID}): Encrypted<T> {
|
||||
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<T extends JsonValue>(encrypted: Encrypted<T>, 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<T extends JsonValue>(value: T): EncryptedStreamChunk<T> {
|
||||
const plaintext = textEncoder.encode(stableStringify(value));
|
||||
const ciphertext = xsalsa20(
|
||||
this.secretKey,
|
||||
this.nonce,
|
||||
plaintext,
|
||||
new Uint8Array(plaintext.length),
|
||||
this.counter
|
||||
);
|
||||
this.counter++;
|
||||
|
||||
return `encryptedChunk_U${base64url.encode(ciphertext)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class DecryptionStream {
|
||||
secretKey: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
counter: number;
|
||||
|
||||
constructor(secretKey: SecretKey, nonce: Uint8Array) {
|
||||
this.secretKey = base58.decode(
|
||||
secretKey.substring("secretKey_z".length)
|
||||
);
|
||||
this.nonce = nonce;
|
||||
this.counter = 0;
|
||||
}
|
||||
|
||||
static resume(secretKey: SecretKey, nonce: Uint8Array, counter: number) {
|
||||
const stream = new DecryptionStream(secretKey, nonce);
|
||||
stream.counter = counter;
|
||||
return stream;
|
||||
}
|
||||
|
||||
decrypt<T extends JsonValue>(
|
||||
encryptedChunk: EncryptedStreamChunk<T>
|
||||
): T | undefined {
|
||||
const ciphertext = base64url.decode(
|
||||
encryptedChunk.substring("encryptedChunk_U".length)
|
||||
);
|
||||
const plaintext = xsalsa20(
|
||||
this.secretKey,
|
||||
this.nonce,
|
||||
ciphertext,
|
||||
new Uint8Array(ciphertext.length),
|
||||
this.counter
|
||||
);
|
||||
this.counter++;
|
||||
|
||||
try {
|
||||
return JSON.parse(textDecoder.decode(plaintext));
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ type Value = JsonValue | CoValue;
|
||||
|
||||
export {
|
||||
JsonValue,
|
||||
CoValue as CoJsonValue,
|
||||
CoValue,
|
||||
Value,
|
||||
LocalNode as Node,
|
||||
LocalNode,
|
||||
MultiLog
|
||||
}
|
||||
|
||||
116
src/multilog.ts
116
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<JsonValue[]>;
|
||||
encryptedChanges: Encrypted<JsonValue[]>;
|
||||
};
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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<AgentID, Role>;
|
||||
const change = tx.changes[0] as
|
||||
| MapOpPayload<AgentID, Role>
|
||||
| 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<TeamContent, {}> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<TeamContent, {}>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user