Key rotation with previousKeys support
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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, {}> {
|
||||
|
||||
Reference in New Issue
Block a user