Simple tx encryption based on current key

This commit is contained in:
Anselm
2023-07-20 13:57:15 +01:00
parent a335cc0526
commit cb4f75801c
8 changed files with 335 additions and 167 deletions

View File

@@ -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);
});
})

View File

@@ -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 }, {}>;
}

View File

@@ -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]);

View File

@@ -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;
}
}
}
}

View File

@@ -7,8 +7,8 @@ type Value = JsonValue | CoValue;
export {
JsonValue,
CoValue as CoJsonValue,
CoValue,
Value,
LocalNode as Node,
LocalNode,
MultiLog
}

View File

@@ -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 = {

View File

@@ -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");
});

View File

@@ -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, {}>;
}