Key rotation with previousKeys support

This commit is contained in:
Anselm
2023-07-25 16:22:19 +01:00
parent 94d8fbf62d
commit f1bb371619
5 changed files with 278 additions and 48 deletions

View File

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

View File

@@ -181,8 +181,7 @@ export function shortHash(value: JsonValue): ShortHash {
)}`;
}
export type Encrypted<T extends JsonValue> =
`encrypted_U${string}`;
export type Encrypted<T extends JsonValue> = `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<T extends JsonValue>(value: T, keySecret: KeySecret, nOnceMaterial: {in: MultiLogID, tx: TransactionID}): Encrypted<T> {
function encrypt<T extends JsonValue, N extends JsonValue>(
value: T,
keySecret: KeySecret,
nOnceMaterial: N
): Encrypted<T> {
const keySecretBytes = base58.decode(
keySecret.substring("keySecret_z".length)
);
@@ -203,15 +206,43 @@ export function encrypt<T extends JsonValue>(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<T extends JsonValue>(encrypted: Encrypted<T>, keySecret: KeySecret, nOnceMaterial: {in: MultiLogID, tx: TransactionID}): T | undefined {
export function encryptForTransaction<T extends JsonValue>(
value: T,
keySecret: KeySecret,
nOnceMaterial: { in: MultiLogID; tx: TransactionID }
): Encrypted<T> {
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<KeySecret> } {
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<T extends JsonValue, N extends JsonValue>(
encrypted: Encrypted<T>,
keySecret: KeySecret,
nOnceMaterial: N
): T | undefined {
const keySecretBytes = base58.decode(
keySecret.substring("keySecret_z".length)
);
@@ -222,15 +253,28 @@ export function decrypt<T extends JsonValue>(encrypted: Encrypted<T>, 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;
}
}
}
export function decryptForTransaction<T extends JsonValue>(
encrypted: Encrypted<T>,
keySecret: KeySecret,
nOnceMaterial: { in: MultiLogID; tx: TransactionID }
): T | undefined {
return decrypt(encrypted, keySecret, nOnceMaterial);
}
export function unsealKeySecret(
sealedInfo: { sealed: KeyID; sealing: KeyID; encrypted: Encrypted<KeySecret> },
sealingSecret: KeySecret
): KeySecret | undefined {
const nOnceMaterial = { sealed: sealedInfo.sealed, sealing: sealedInfo.sealing };
return decrypt(sealedInfo.encrypted, sealingSecret, nOnceMaterial);
}

View File

@@ -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"

View File

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

View File

@@ -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<KeySecret>
} };
};
export function expectTeam(content: CoValue): CoMap<TeamContent, {}> {