relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
996 lines (960 loc) • 45 kB
JavaScript
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import fs from "node:fs";
import {
acceptAll,
createApplicationMessage,
createCommit,
createGroup,
decodeGroupState,
decodeMlsMessage,
emptyPskIndex,
encodeGroupState,
encodeMlsMessage,
joinGroup,
processMessage,
processPrivateMessage,
zeroOutUint8Array,
} from "ts-mls";
import { getGroupMembers } from "ts-mls/clientState.js";
import { decodeRatchetTree, encodeRatchetTree } from "ts-mls/ratchetTree.js";
import e2eeIdentityModule from "./e2ee-identity.cjs";
import {
assertE2eeRecipientDevicesTrusted,
assertE2eeSenderDeviceTrusted,
} from "./e2ee-device-trust.js";
import {
b64,
cipherSuite,
clientConfigFor,
decodeExact,
ensureE2eeKeyPackages,
generatePackage,
identityOrThrow,
decryptAttachmentMetadata,
importE2eeAttachmentPlaintexts,
materializeE2eeAttachments,
privatePackageFromJson,
} from "./e2ee-mls.js";
import {
activeDeviceFromTransparency,
assertKnownCheckpoint,
completeGroupOutbox,
completeGroupTransition,
e2eeRequestHash,
mutateKeyPackageState,
readEncryptedGroupState,
readKeyPackageState,
readPendingGroupOutbox,
readPendingGroupTransition,
readProcessedGroupEvent,
readProcessedGroupEvents,
rollbackPendingGroupTransition,
parseMlsCredential,
syncTransparency,
writeEncryptedGroupState,
} from "./e2ee-state.js";
const { E2EE_PROTOCOL } = e2eeIdentityModule;
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder("utf-8", { fatal: true });
const GROUP_EVENT_CONTEXT = "relay-e2ee-group-event-v1";
const GROUP_TRANSITION_CONTEXT = "relay-e2ee-group-transition-v1";
const HISTORY_ARCHIVE_CONTEXT = "relay-e2ee-history-archive-v1";
const HISTORY_ARCHIVE_WIRE_VERSION = 1;
function sha256(value) {
return createHash("sha256").update(value).digest("base64url");
}
function groupEpoch(state) {
return state.groupContext.epoch.toString();
}
function persistGroup(identity, groupId, membershipRevision, state, expectedStateRevision, changes = {}) {
writeEncryptedGroupState(identity, groupId, {
bytes: encodeGroupState(state),
epoch: groupEpoch(state),
membershipRevision,
...(expectedStateRevision !== undefined ? { expectedStateRevision } : {}),
...(changes.processedEvent ? { processedEvent: changes.processedEvent } : {}),
...(changes.pendingOutbox ? { pendingOutbox: changes.pendingOutbox } : {}),
...(changes.pendingTransition ? { pendingTransition: changes.pendingTransition } : {}),
});
}
function groupEventIdentity(identity, input) {
const eventId = String(input.eventId || `egmsg_${createHash("sha256")
.update("relay-e2ee-group-message-id-v1")
.update(input.groupId)
.update(identity.deviceId)
.update(String(input.idempotencyKey || ""))
.digest("base64url")}`);
const requestHash = e2eeRequestHash("relay-e2ee-group-send-request-v1", {
eventId,
groupId: String(input.groupId || ""),
membershipRevision: String(input.membershipRevision || ""),
type: input.type,
body: input.body,
attachments: (input.attachments || []).map((attachment) => ({
attachmentId: attachment.attachmentId,
encryptedMetadata: attachment.encryptedMetadata,
ciphertextSize: attachment.ciphertextSize,
ciphertextSha256: attachment.ciphertextSha256,
})),
historyImport: input.historyImport === true,
createdAt: input.createdAt || null,
idempotencyKey: String(input.idempotencyKey || ""),
});
return { eventId, requestHash };
}
function groupTransitionIdentity(identity, input) {
const transitionId = `egtransition_${createHash("sha256")
.update("relay-e2ee-group-transition-id-v1")
.update(String(input.groupId || ""))
.update(identity.deviceId)
.update(String(input.idempotencyKey || ""))
.digest("base64url")}`;
const requestHash = e2eeRequestHash("relay-e2ee-group-transition-request-v1", {
groupId: String(input.groupId || ""),
idempotencyKey: String(input.idempotencyKey || ""),
});
return { transitionId, requestHash };
}
function groupTransitionAuthenticatedData(input) {
return textEncoder.encode(JSON.stringify([
GROUP_TRANSITION_CONTEXT,
input.groupId,
String(input.previousEpoch),
String(input.epoch),
input.previousMembershipRevision,
input.membershipRevision,
[...input.participantUserIds].sort(),
[...input.memberDeviceIds].sort(),
input.senderCheckpoint.size,
input.senderCheckpoint.headHash,
]));
}
function loadGroup(identity, groupId, transparency, checkpoint = null) {
const stored = readEncryptedGroupState(identity, groupId);
if (!stored) throw new Error(`This device has not joined encrypted group ${groupId}.`);
const decoded = decodeExact(decodeGroupState, b64(stored.bytes), "local MLS GroupState");
return {
...stored,
groupId,
state: { ...decoded, clientConfig: clientConfigFor(transparency, checkpoint) },
};
}
function assertPreparedGroupTargets(identity, transparency, preparation) {
const participants = new Set(preparation.participantUserIds || []);
if (!participants.has(identity.userId)) throw new Error("The encrypted group preparation omitted its creator.");
const expected = [];
for (const relayUserId of participants) {
for (const [deviceId, device] of Object.entries(transparency.users?.[relayUserId]?.devices || {})) {
if (device.revokedAt || (device.expiresAt && Date.parse(device.expiresAt) <= Date.now())) continue;
if (relayUserId === identity.userId && deviceId === identity.deviceId) continue;
expected.push(`${relayUserId}:${deviceId}`);
}
}
const actual = [];
for (const target of preparation.targets || []) {
const key = `${target.relayUserId}:${target.device.deviceId}`;
if (actual.includes(key)) throw new Error("Relay returned a duplicate encrypted group device.");
const known = activeDeviceFromTransparency(transparency, target.relayUserId, target.device.deviceId);
if (
!known ||
known.protocol !== target.device.protocol ||
known.cipherSuite !== target.device.cipherSuite ||
known.signaturePublicKey !== target.device.signaturePublicKey ||
known.fingerprint !== target.device.fingerprint
) throw new Error("Relay's group KeyPackage does not match the verified device directory.");
actual.push(key);
}
expected.sort();
actual.sort();
if (!expected.length || expected.length !== actual.length || expected.some((value, index) => value !== actual[index])) {
throw new Error("Relay omitted or added a device relative to the verified group participant directory.");
}
}
async function assertGroupParticipantsTrusted(client, identity, participantUserIds) {
for (const relayUserId of [...new Set(participantUserIds || [])]) {
await assertE2eeRecipientDevicesTrusted(client, relayUserId, { identity });
}
}
function assertJoinedGroupMembers(identity, state, wire) {
if (!Buffer.from(state.groupContext.groupId).equals(Buffer.from(`relay-group:${wire.groupId}`, "utf8"))) {
throw new Error("Encrypted group Welcome is bound to a different Relay room.");
}
const members = getGroupMembers(state).map((leaf) => {
if (leaf.credential?.credentialType !== "basic") throw new Error("Encrypted group contains a non-basic credential.");
const parsed = parseMlsCredential(textDecoder.decode(leaf.credential.identity));
if (!parsed) throw new Error("Encrypted group contains an invalid Relay device credential.");
return parsed;
});
const actualDevices = members.map((member) => member.deviceId).sort();
const expectedDevices = [...wire.memberDeviceIds].sort();
const actualUsers = [...new Set(members.map((member) => member.relayUserId))].sort();
const expectedUsers = [...new Set(wire.participantUserIds)].sort();
const selfExpected = expectedDevices.includes(identity.deviceId);
if (
actualDevices.includes(identity.deviceId) !== selfExpected ||
actualDevices.length !== expectedDevices.length ||
actualDevices.some((deviceId, index) => deviceId !== expectedDevices[index]) ||
actualUsers.length !== expectedUsers.length ||
actualUsers.some((userId, index) => userId !== expectedUsers[index])
) throw new Error("Encrypted group membership does not match the announced room roster.");
}
function indexedGroupMembers(state) {
const members = [];
for (let nodeIndex = 0; nodeIndex < state.ratchetTree.length; nodeIndex += 2) {
const node = state.ratchetTree[nodeIndex];
if (!node || node.nodeType !== "leaf") continue;
if (node.leaf.credential?.credentialType !== "basic") {
throw new Error("Encrypted group contains a non-basic credential.");
}
const credential = parseMlsCredential(textDecoder.decode(node.leaf.credential.identity));
if (!credential) throw new Error("Encrypted group contains an invalid Relay device credential.");
members.push({ ...credential, leafIndex: nodeIndex / 2 });
}
return members;
}
function preparedTransitionProposals(identity, transparency, loaded, preparation) {
if (
preparation.groupId !== loaded.groupId ||
String(preparation.baseEpoch) !== loaded.epoch ||
preparation.previousMembershipRevision !== loaded.membershipRevision
) throw new Error("Encrypted group preparation does not start from this device's current epoch.");
const currentMembers = indexedGroupMembers(loaded.state);
const currentByDevice = new Map(currentMembers.map((member) => [member.deviceId, member]));
if (!currentByDevice.has(identity.deviceId)) {
throw new Error("This device is not a member of the encrypted group's current epoch.");
}
const finalDeviceIds = [...new Set(preparation.memberDeviceIds || [])].sort();
if (finalDeviceIds.length !== (preparation.memberDeviceIds || []).length || !finalDeviceIds.includes(identity.deviceId)) {
throw new Error("Encrypted group preparation has an invalid final device roster.");
}
const addedDeviceIds = finalDeviceIds.filter((deviceId) => !currentByDevice.has(deviceId));
const removedMembers = currentMembers.filter((member) => !finalDeviceIds.includes(member.deviceId));
const announcedRemoved = [...new Set(preparation.removedDeviceIds || [])].sort();
const actualRemoved = removedMembers.map((member) => member.deviceId).sort();
if (
announcedRemoved.length !== actualRemoved.length ||
announcedRemoved.some((deviceId, index) => deviceId !== actualRemoved[index])
) throw new Error("Relay's encrypted group removal set does not match the current MLS leaves.");
const targets = new Map();
const addProposals = [];
for (const target of preparation.addedTargets || []) {
const deviceId = target.device?.deviceId;
if (!deviceId || targets.has(deviceId)) throw new Error("Relay returned a duplicate added group device.");
const known = activeDeviceFromTransparency(transparency, target.relayUserId, deviceId);
if (
!known ||
known.protocol !== target.device.protocol ||
known.cipherSuite !== target.device.cipherSuite ||
known.signaturePublicKey !== target.device.signaturePublicKey ||
known.fingerprint !== target.device.fingerprint
) throw new Error("Relay's added group KeyPackage does not match the verified device directory.");
const decoded = decodeExact(decodeMlsMessage, target.keyPackage, "added group MLS KeyPackage");
if (decoded.wireformat !== "mls_key_package") throw new Error("Expected an added-device MLS KeyPackage.");
targets.set(deviceId, target);
addProposals.push({ proposalType: "add", add: { keyPackage: decoded.keyPackage } });
}
const actualAdded = [...targets.keys()].sort();
if (
actualAdded.length !== addedDeviceIds.length ||
actualAdded.some((deviceId, index) => deviceId !== addedDeviceIds[index])
) throw new Error("Relay's added KeyPackages do not match the final encrypted group roster.");
const finalUsers = [...new Set([
...currentMembers.filter((member) => finalDeviceIds.includes(member.deviceId)).map((member) => member.relayUserId),
...(preparation.addedTargets || []).map((target) => target.relayUserId),
])].sort();
const announcedUsers = [...new Set(preparation.participantUserIds || [])].sort();
if (
finalUsers.length !== announcedUsers.length ||
finalUsers.some((userId, index) => userId !== announcedUsers[index])
) throw new Error("Encrypted group preparation's participant accounts do not match its device roster.");
return {
proposals: [
...removedMembers.map((member) => ({ proposalType: "remove", remove: { removed: member.leafIndex } })),
...addProposals,
],
addedDeviceIds,
};
}
/**
* Create one durable MLS group for a stable Relay room. This is intentionally
* separate from the existing group-send path until membership commits and UI
* recovery are integrated end to end.
*/
export async function createE2eeGroupBootstrap(client, preparation) {
const identity = identityOrThrow();
await ensureE2eeKeyPackages(client);
const transparency = await syncTransparency(client, identity);
assertKnownCheckpoint(transparency, preparation.checkpoint);
await assertGroupParticipantsTrusted(client, identity, preparation.participantUserIds);
assertPreparedGroupTargets(identity, transparency, preparation);
const cs = await cipherSuite();
const senderPackage = await generatePackage(identity, cs);
const senderMessage = decodeExact(decodeMlsMessage, senderPackage.keyPackage, "sender MLS KeyPackage");
if (senderMessage.wireformat !== "mls_key_package") throw new Error("Expected a sender MLS KeyPackage.");
let state = await createGroup(
textEncoder.encode(`relay-group:${preparation.groupId}`),
senderMessage.keyPackage,
privatePackageFromJson(senderPackage.privatePackage),
[],
cs,
clientConfigFor(transparency, preparation.checkpoint),
);
const proposals = preparation.targets.map((target) => {
const decoded = decodeExact(decodeMlsMessage, target.keyPackage, "group member MLS KeyPackage");
if (decoded.wireformat !== "mls_key_package") throw new Error("Expected a group member MLS KeyPackage.");
return { proposalType: "add", add: { keyPackage: decoded.keyPackage } };
});
const committed = await createCommit(
{ state, cipherSuite: cs },
{ extraProposals: proposals, ratchetTreeExtension: true },
);
if (!committed.welcome) throw new Error("MLS did not create a Welcome for the group devices.");
state = committed.newState;
committed.consumed.forEach(zeroOutUint8Array);
assertJoinedGroupMembers(identity, state, {
groupId: preparation.groupId,
participantUserIds: preparation.participantUserIds,
memberDeviceIds: [identity.deviceId, ...preparation.targets.map((target) => target.device.deviceId)],
});
persistGroup(identity, preparation.groupId, preparation.membershipRevision, state);
return {
preparationId: preparation.preparationId,
groupId: preparation.groupId,
membershipRevision: preparation.membershipRevision,
participantUserIds: [...preparation.participantUserIds],
memberDeviceIds: [identity.deviceId, ...preparation.targets.map((target) => target.device.deviceId)].sort(),
epoch: groupEpoch(state),
welcome: b64(encodeMlsMessage({ version: E2EE_PROTOCOL, wireformat: "mls_welcome", welcome: committed.welcome })),
ratchetTree: b64(encodeRatchetTree(state.ratchetTree)),
senderCheckpoint: { ...preparation.checkpoint },
};
}
/** Join from a Welcome exactly once, then erase the consumed private KeyPackage. */
export async function joinE2eeGroupWelcome(client, wire) {
const identity = identityOrThrow();
const transparency = await syncTransparency(client, identity);
assertKnownCheckpoint(transparency, wire.senderCheckpoint);
await assertE2eeSenderDeviceTrusted(client, wire.sender.relayUserId, wire.sender.deviceId, { identity });
const existing = readEncryptedGroupState(identity, wire.groupId);
if (
existing &&
existing.epoch === String(wire.epoch) &&
existing.membershipRevision === wire.membershipRevision
) {
mutateKeyPackageState(identity, (packages) => { delete packages.packages[wire.keyPackageRef]; });
return { groupId: wire.groupId, epoch: existing.epoch, membershipRevision: existing.membershipRevision };
}
const localPackage = readKeyPackageState(identity).packages[wire.keyPackageRef];
if (!localPackage) throw new Error("This device no longer has the one-time MLS KeyPackage needed to join the group.");
const packageMessage = decodeExact(decodeMlsMessage, localPackage.keyPackage, "local MLS KeyPackage");
const welcomeMessage = decodeExact(decodeMlsMessage, wire.welcome, "group MLS Welcome");
const ratchetTree = decodeExact(decodeRatchetTree, wire.ratchetTree, "group MLS ratchet tree");
if (packageMessage.wireformat !== "mls_key_package" || welcomeMessage.wireformat !== "mls_welcome") {
throw new Error("Encrypted group Welcome contains the wrong MLS wire formats.");
}
const state = await joinGroup(
welcomeMessage.welcome,
packageMessage.keyPackage,
privatePackageFromJson(localPackage.privatePackage),
emptyPskIndex,
await cipherSuite(),
ratchetTree,
undefined,
clientConfigFor(transparency, wire.senderCheckpoint),
);
if (groupEpoch(state) !== String(wire.epoch)) throw new Error("Encrypted group Welcome announced the wrong epoch.");
assertJoinedGroupMembers(identity, state, wire);
persistGroup(identity, wire.groupId, wire.membershipRevision, state);
mutateKeyPackageState(identity, (packages) => { delete packages.packages[wire.keyPackageRef]; });
return { groupId: wire.groupId, epoch: groupEpoch(state), membershipRevision: wire.membershipRevision };
}
/** Join and acknowledge every pending Welcome in order. */
export async function syncE2eeGroupWelcomes(client) {
const response = await client.e2eeGroupWelcomes();
const joined = [];
for (const welcome of response.items || []) {
if (welcome.acknowledgedAt) continue;
joined.push(await joinE2eeGroupWelcome(client, welcome));
await client.e2eeAcknowledgeGroupWelcome(welcome.groupId, welcome.epoch);
}
return joined;
}
async function deliverPendingGroupTransition(client, identity, groupId, pending) {
try {
const response = await client.e2eeCommitGroupRekey(pending.wire);
return completeGroupTransition(
identity,
groupId,
pending.transitionId,
pending.requestHash,
response,
);
} catch (error) {
if (error?.body?.error === "group_epoch_conflict") {
rollbackPendingGroupTransition(
identity,
groupId,
pending.transitionId,
pending.requestHash,
);
error.message = "Another device advanced this encrypted group first. Relay restored the prior local epoch; sync and retry.";
error.localE2eeStateRolledBack = true;
}
throw error;
}
}
/** Create, durably persist, and publish one exact MLS add/remove membership Commit. */
export async function updateE2eeGroupMembership(client, input) {
if (!input?.groupId || !input?.idempotencyKey) {
throw new Error("Encrypted group membership updates require a group id and idempotency key.");
}
const identity = identityOrThrow();
const { transitionId, requestHash } = groupTransitionIdentity(identity, input);
const existing = readPendingGroupTransition(identity, input.groupId);
if (existing) {
if (existing.transitionId === transitionId) {
if (existing.requestHash !== requestHash) {
throw new Error("That encrypted group transition idempotency key is bound to different content.");
}
if (existing.status === "completed") return existing.response;
return deliverPendingGroupTransition(client, identity, input.groupId, existing);
}
if (existing.status === "pending") {
throw new Error("Another encrypted group membership transition is still pending.");
}
}
let transparency = await syncTransparency(client, identity);
let loaded = loadGroup(identity, input.groupId, transparency);
const preparation = await client.e2eePrepareGroupRekey({
groupId: input.groupId,
expectedEpoch: loaded.epoch,
expectedMembershipRevision: loaded.membershipRevision,
idempotencyKey: input.idempotencyKey,
});
transparency = await syncTransparency(client, identity);
assertKnownCheckpoint(transparency, preparation.checkpoint);
await assertGroupParticipantsTrusted(client, identity, preparation.participantUserIds);
loaded = loadGroup(identity, input.groupId, transparency, preparation.checkpoint);
const { proposals, addedDeviceIds } = preparedTransitionProposals(identity, transparency, loaded, preparation);
const cs = await cipherSuite();
const committed = await createCommit(
{ state: loaded.state, cipherSuite: cs },
{
extraProposals: proposals,
ratchetTreeExtension: true,
authenticatedData: groupTransitionAuthenticatedData({
groupId: input.groupId,
previousEpoch: loaded.epoch,
epoch: (BigInt(loaded.epoch) + 1n).toString(),
previousMembershipRevision: loaded.membershipRevision,
membershipRevision: preparation.membershipRevision,
participantUserIds: preparation.participantUserIds,
memberDeviceIds: preparation.memberDeviceIds,
senderCheckpoint: preparation.checkpoint,
}),
},
);
const nextEpoch = (BigInt(loaded.epoch) + 1n).toString();
if (groupEpoch(committed.newState) !== nextEpoch) throw new Error("MLS produced the wrong next group epoch.");
if (Boolean(committed.welcome) !== (addedDeviceIds.length > 0)) {
throw new Error("MLS Welcome creation did not match the devices added by this transition.");
}
assertJoinedGroupMembers(identity, committed.newState, {
groupId: input.groupId,
participantUserIds: preparation.participantUserIds,
memberDeviceIds: preparation.memberDeviceIds,
});
const wire = {
preparationId: preparation.preparationId,
groupId: input.groupId,
previousEpoch: loaded.epoch,
epoch: nextEpoch,
previousMembershipRevision: loaded.membershipRevision,
membershipRevision: preparation.membershipRevision,
commitCiphertext: b64(encodeMlsMessage(committed.commit)),
...(committed.welcome ? {
welcome: b64(encodeMlsMessage({ version: E2EE_PROTOCOL, wireformat: "mls_welcome", welcome: committed.welcome })),
ratchetTree: b64(encodeRatchetTree(committed.newState.ratchetTree)),
} : {}),
senderCheckpoint: { ...preparation.checkpoint },
idempotencyKey: input.idempotencyKey,
};
try {
persistGroup(
identity,
input.groupId,
preparation.membershipRevision,
committed.newState,
loaded.stateRevision,
{
pendingTransition: {
transitionId,
requestHash,
wire,
rollbackState: {
bytes: b64(loaded.bytes),
epoch: loaded.epoch,
membershipRevision: loaded.membershipRevision,
},
},
},
);
} finally {
committed.consumed.forEach(zeroOutUint8Array);
}
const pending = readPendingGroupTransition(identity, input.groupId);
if (!pending || pending.transitionId !== transitionId || pending.requestHash !== requestHash) {
throw new Error("Relay could not recover the durable encrypted group transition; nothing was sent.");
}
const response = await deliverPendingGroupTransition(client, identity, input.groupId, pending);
return { ...response, addedDeviceIds };
}
/** Apply one server-routed MLS Commit or added-device Welcome and persist before acknowledgement. */
export async function applyE2eeGroupEpochUpdate(client, wire) {
const identity = identityOrThrow();
const transparency = await syncTransparency(client, identity);
assertKnownCheckpoint(transparency, wire.senderCheckpoint);
await assertE2eeSenderDeviceTrusted(client, wire.sender.relayUserId, wire.sender.deviceId, { identity });
const existing = readEncryptedGroupState(identity, wire.groupId);
if (
existing &&
existing.epoch === String(wire.epoch) &&
existing.membershipRevision === wire.membershipRevision
) {
if (wire.kind === "welcome") {
mutateKeyPackageState(identity, (packages) => { delete packages.packages[wire.keyPackageRef]; });
}
return { groupId: wire.groupId, epoch: existing.epoch, membershipRevision: existing.membershipRevision };
}
if (existing && BigInt(existing.epoch) > BigInt(wire.epoch)) {
return { groupId: wire.groupId, epoch: existing.epoch, membershipRevision: existing.membershipRevision };
}
const cs = await cipherSuite();
if (wire.kind === "welcome") {
const localPackage = readKeyPackageState(identity).packages[wire.keyPackageRef];
if (!localPackage) throw new Error("This device no longer has the one-time MLS KeyPackage needed to join the updated group.");
const packageMessage = decodeExact(decodeMlsMessage, localPackage.keyPackage, "local MLS KeyPackage");
const welcomeMessage = decodeExact(decodeMlsMessage, wire.welcome, "group MLS update Welcome");
const ratchetTree = decodeExact(decodeRatchetTree, wire.ratchetTree, "group MLS update ratchet tree");
if (packageMessage.wireformat !== "mls_key_package" || welcomeMessage.wireformat !== "mls_welcome") {
throw new Error("Encrypted group update Welcome contains the wrong MLS wire formats.");
}
const state = await joinGroup(
welcomeMessage.welcome,
packageMessage.keyPackage,
privatePackageFromJson(localPackage.privatePackage),
emptyPskIndex,
cs,
ratchetTree,
undefined,
clientConfigFor(transparency, wire.senderCheckpoint),
);
if (groupEpoch(state) !== String(wire.epoch)) throw new Error("Encrypted group update Welcome announced the wrong epoch.");
assertJoinedGroupMembers(identity, state, wire);
persistGroup(identity, wire.groupId, wire.membershipRevision, state);
mutateKeyPackageState(identity, (packages) => { delete packages.packages[wire.keyPackageRef]; });
return { groupId: wire.groupId, epoch: groupEpoch(state), membershipRevision: wire.membershipRevision };
}
if (!existing) throw new Error(`This device has not joined encrypted group ${wire.groupId}.`);
if (
existing.epoch !== String(wire.previousEpoch) ||
existing.membershipRevision !== wire.previousMembershipRevision
) throw new Error("Encrypted group Commit does not continue this device's current epoch.");
const loaded = loadGroup(identity, wire.groupId, transparency, wire.senderCheckpoint);
const message = decodeExact(decodeMlsMessage, wire.commitCiphertext, "group MLS Commit");
if (message.wireformat !== "mls_private_message" && message.wireformat !== "mls_public_message") {
throw new Error("Encrypted group epoch update did not contain an MLS Commit.");
}
const authenticatedData = message.wireformat === "mls_private_message"
? message.privateMessage.authenticatedData
: message.publicMessage.content.authenticatedData;
if (!Buffer.from(authenticatedData).equals(Buffer.from(groupTransitionAuthenticatedData(wire)))) {
throw new Error("Encrypted group Commit metadata does not match its authenticated MLS envelope.");
}
const processed = await processMessage(message, loaded.state, emptyPskIndex, acceptAll, cs);
if (processed.kind !== "newState") throw new Error("Encrypted group epoch update was not an MLS Commit.");
try {
if (groupEpoch(processed.newState) !== String(wire.epoch)) {
throw new Error("Encrypted group Commit produced the wrong epoch.");
}
assertJoinedGroupMembers(identity, processed.newState, wire);
persistGroup(
identity,
wire.groupId,
wire.membershipRevision,
processed.newState,
loaded.stateRevision,
);
} finally {
processed.consumed.forEach(zeroOutUint8Array);
}
return { groupId: wire.groupId, epoch: String(wire.epoch), membershipRevision: wire.membershipRevision };
}
/** Apply and acknowledge every pending epoch update in server order. */
export async function syncE2eeGroupEpochUpdates(client) {
const response = await client.e2eeGroupEpochUpdates();
const applied = [];
for (const update of response.items || []) {
if (update.acknowledgedAt) continue;
applied.push(await applyE2eeGroupEpochUpdate(client, update));
await client.e2eeAcknowledgeGroupWelcome(update.groupId, update.epoch);
}
return applied;
}
/** Encrypt any authenticated group event while advancing the local MLS send ratchet. */
export async function encryptE2eeGroupEvent(client, input) {
const identity = identityOrThrow();
const transition = readPendingGroupTransition(identity, input.groupId);
if (transition?.status === "pending") {
throw new Error("Finish the pending encrypted group membership transition before sending messages.");
}
const { eventId, requestHash } = groupEventIdentity(identity, input);
const existingOutbox = readPendingGroupOutbox(identity, input.groupId, eventId);
if (existingOutbox) {
if (existingOutbox.requestHash !== requestHash) {
throw new Error("That encrypted group idempotency key is already bound to different content.");
}
if (existingOutbox.status === "completed") {
throw new Error("That encrypted group event was already delivered.");
}
return existingOutbox.wire;
}
const transparency = await syncTransparency(client, identity);
const loaded = loadGroup(identity, input.groupId, transparency);
if (loaded.membershipRevision !== input.membershipRevision) {
throw new Error("The group roster changed; create and apply an MLS membership commit before sending.");
}
const plaintext = {
version: 2,
context: GROUP_EVENT_CONTEXT,
eventId,
eventType: input.type,
messageId: input.messageId || eventId,
groupId: input.groupId,
epoch: loaded.epoch,
membershipRevision: loaded.membershipRevision,
senderUserId: identity.userId,
senderDeviceId: identity.deviceId,
authoredAt: input.authoredAt || input.createdAt || new Date().toISOString(),
conversation: {
conversationId: input.groupId,
threadId: input.threadId || input.messageId || eventId,
...(input.replyToMessageId ? { replyToMessageId: input.replyToMessageId } : {}),
groupId: input.groupId,
...(input.groupSendId ? { groupSendId: input.groupSendId } : {}),
},
transparencyCheckpoint: { ...transparency.checkpoint },
...(input.historyImport ? { historyImport: { version: 1 } } : {}),
type: input.type,
body: input.body,
};
const encrypted = await createApplicationMessage(
loaded.state,
textEncoder.encode(JSON.stringify(plaintext)),
await cipherSuite(),
);
encrypted.consumed.forEach(zeroOutUint8Array);
const wire = {
eventId,
groupId: input.groupId,
epoch: loaded.epoch,
membershipRevision: loaded.membershipRevision,
ciphertext: b64(encodeMlsMessage({
version: E2EE_PROTOCOL,
wireformat: "mls_private_message",
privateMessage: encrypted.privateMessage,
})),
attachments: input.attachments || [],
senderCheckpoint: { ...transparency.checkpoint },
idempotencyKey: input.idempotencyKey,
...(input.historyImport ? { historyImport: true } : {}),
};
persistGroup(
identity,
input.groupId,
loaded.membershipRevision,
encrypted.newState,
loaded.stateRevision,
{
pendingOutbox: { eventId, requestHash, wire },
// The author never consumes its own MLS Welcome. Cache the authenticated
// event beside the advanced send ratchet so Sent and chat history survive
// restarts exactly like recipient history.
processedEvent: {
eventId,
plaintext,
wire: {
...wire,
sender: { relayUserId: identity.userId, deviceId: identity.deviceId, name: "You" },
createdAt: plaintext.authoredAt,
updatedAt: plaintext.authoredAt,
},
},
},
);
return readPendingGroupOutbox(identity, input.groupId, eventId).wire;
}
/** Send only a durable exact-wire event, retaining it across ambiguous failures. */
export async function sendE2eeGroupEvent(client, input) {
const identity = identityOrThrow();
const { eventId, requestHash } = groupEventIdentity(identity, input);
const existing = readPendingGroupOutbox(identity, input.groupId, eventId);
if (existing) {
if (existing.requestHash !== requestHash) {
throw new Error("That encrypted group idempotency key is already bound to different content.");
}
if (existing.status === "completed") return existing.response;
const response = await client.e2eeSendGroupMessage(existing.wire);
return completeGroupOutbox(identity, input.groupId, eventId, requestHash, response);
}
const wire = await encryptE2eeGroupEvent(client, input);
const outbox = readPendingGroupOutbox(identity, input.groupId, eventId);
if (!outbox || outbox.requestHash !== requestHash) {
throw new Error("Relay could not recover the durable encrypted group event; nothing was sent.");
}
if (outbox.status === "completed") return outbox.response;
const response = await client.e2eeSendGroupMessage(wire);
return completeGroupOutbox(identity, input.groupId, eventId, requestHash, response);
}
/** Authenticate, decrypt, validate, and advance the local MLS receive ratchet. */
export async function decryptE2eeGroupEvent(client, wire) {
const identity = identityOrThrow();
const transparency = await syncTransparency(client, identity);
assertKnownCheckpoint(transparency, wire.senderCheckpoint);
await assertE2eeSenderDeviceTrusted(client, wire.sender.relayUserId, wire.sender.deviceId, { identity });
const cached = readProcessedGroupEvent(identity, wire.groupId, wire.eventId);
if (cached) return validateGroupPlaintext(cached, wire);
const loaded = loadGroup(identity, wire.groupId, transparency, wire.senderCheckpoint);
if (loaded.epoch !== String(wire.epoch) || loaded.membershipRevision !== wire.membershipRevision) {
throw new Error("Encrypted group event does not match this device's roster epoch.");
}
const message = decodeExact(decodeMlsMessage, wire.ciphertext, "group MLS ciphertext");
if (message.wireformat !== "mls_private_message") throw new Error("Expected an MLS private group message.");
const opened = await processPrivateMessage(loaded.state, message.privateMessage, emptyPskIndex, await cipherSuite());
if (opened.kind !== "applicationMessage") throw new Error("Encrypted group event did not contain application data.");
let plaintext;
try { plaintext = JSON.parse(textDecoder.decode(opened.message)); } catch {
throw new Error("Encrypted group event is not valid UTF-8 JSON.");
} finally {
opened.consumed.forEach(zeroOutUint8Array);
}
validateGroupPlaintext(plaintext, wire);
persistGroup(
identity,
wire.groupId,
loaded.membershipRevision,
opened.newState,
loaded.stateRevision,
{ processedEvent: { eventId: wire.eventId, plaintext, wire } },
);
return plaintext;
}
function validateGroupPlaintext(plaintext, wire) {
if (
plaintext?.version !== 2 ||
plaintext.context !== GROUP_EVENT_CONTEXT ||
plaintext.eventId !== wire.eventId ||
typeof plaintext.eventType !== "string" ||
typeof plaintext.messageId !== "string" ||
typeof plaintext.authoredAt !== "string" ||
plaintext.conversation?.groupId !== wire.groupId ||
plaintext.conversation?.conversationId !== wire.groupId ||
typeof plaintext.conversation?.threadId !== "string" ||
plaintext.groupId !== wire.groupId ||
plaintext.epoch !== String(wire.epoch) ||
plaintext.membershipRevision !== wire.membershipRevision ||
plaintext.senderUserId !== wire.sender.relayUserId ||
plaintext.senderDeviceId !== wire.sender.deviceId ||
(plaintext.historyImport !== undefined && plaintext.historyImport?.version !== 1) ||
plaintext.transparencyCheckpoint?.size !== wire.senderCheckpoint.size ||
plaintext.transparencyCheckpoint?.headHash !== wire.senderCheckpoint.headHash
) throw new Error("Encrypted group routing metadata did not authenticate against its MLS plaintext.");
return plaintext;
}
/** Decrypt and acknowledge queued group events only after local MLS state is durable. */
export async function syncE2eeGroupEvents(client) {
await syncE2eeGroupWelcomes(client);
await syncE2eeGroupEpochUpdates(client);
const response = await client.e2eeGroupMessages();
const opened = [];
for (const wire of response.items || []) {
if (wire.acknowledgedAt) continue;
opened.push(await decryptE2eeGroupEvent(client, wire));
await client.e2eeAcknowledgeGroupMessage(wire.eventId);
}
return opened;
}
/**
* Share the locally retained product history with devices added to the newest
* epoch. The one-time archive key travels only inside an MLS application
* message; the service receives ciphertext and routing device ids.
*/
export async function shareE2eeGroupHistory(client, input) {
const identity = identityOrThrow();
const recipientDeviceIds = [...new Set(input.recipientDeviceIds || [])]
.filter((deviceId) => deviceId && deviceId !== identity.deviceId)
.sort();
if (!recipientDeviceIds.length) return null;
const fresh = await client.e2eeGroupMessages();
const freshWires = new Map((fresh.items || []).map((wire) => [wire.eventId, wire]));
const selected = readProcessedGroupEvents(identity)
.filter((record) => record.groupId === input.groupId && record.wire && record.plaintext?.type !== "history.key")
.sort((left, right) => String(left.plaintext?.authoredAt).localeCompare(String(right.plaintext?.authoredAt)));
const messages = [];
for (const record of selected) {
const wire = freshWires.get(record.eventId) || record.wire;
const keys = record.plaintext?.body?.attachments || [];
const metadata = decryptAttachmentMetadata(record.eventId, keys, wire.attachments || []);
const files = await materializeE2eeAttachments(record.eventId, metadata);
messages.push({
wire,
plaintext: record.plaintext,
attachmentContents: files.map((attachment) => ({
attachmentId: attachment.id,
contentBase64: fs.readFileSync(attachment.localPath).toString("base64"),
})),
});
}
if (!messages.length) return null;
const archive = createE2eeHistoryArchive({ groupId: input.groupId, messages });
const group = readEncryptedGroupState(identity, input.groupId);
if (!group) throw new Error(`This device has not joined encrypted group ${input.groupId}.`);
const keyEvent = await sendE2eeGroupEvent(client, {
groupId: input.groupId,
membershipRevision: group.membershipRevision,
type: "history.key",
body: {
archiveId: archive.archiveId,
archiveKey: archive.archiveKey,
ciphertextSha256: archive.ciphertextSha256,
ciphertextSize: archive.ciphertextSize,
expiresAt: archive.expiresAt,
recipientDeviceIds,
},
idempotencyKey: `${input.idempotencyKey}:key`,
});
await client.e2eeUploadHistoryArchive({
archiveId: archive.archiveId,
groupId: input.groupId,
ciphertext: archive.ciphertext,
ciphertextSize: archive.ciphertextSize,
ciphertextSha256: archive.ciphertextSha256,
expiresAt: archive.expiresAt,
recipientDeviceIds,
keyMessageId: keyEvent.eventId,
});
return { archiveId: archive.archiveId, keyMessageId: keyEvent.eventId, recipientDeviceIds };
}
/** Import every offered history archive before acknowledging its temporary bytes. */
export async function syncE2eeGroupHistoryArchives(client) {
const identity = identityOrThrow();
const response = await client.e2eeHistoryArchives();
const imported = [];
for (const wire of response.items || []) {
const keyEvent = readProcessedGroupEvent(identity, wire.groupId, wire.keyMessageId);
const key = keyEvent?.type === "history.key" ? keyEvent.body : null;
if (
!key || key.archiveId !== wire.archiveId || key.ciphertextSha256 !== wire.ciphertextSha256 ||
key.ciphertextSize !== wire.ciphertextSize || key.expiresAt !== wire.expiresAt ||
!Array.isArray(key.recipientDeviceIds) || !key.recipientDeviceIds.includes(identity.deviceId)
) throw new Error("Encrypted group history was not authorized by its MLS key event.");
const archive = openE2eeHistoryArchive({ ...wire, archiveKey: key.archiveKey });
for (const record of archive.messages) {
if (
record?.plaintext?.groupId !== wire.groupId ||
record?.wire?.groupId !== wire.groupId ||
record?.wire?.eventId !== record?.plaintext?.eventId
) throw new Error("Encrypted group history contains an event for another room.");
if (readProcessedGroupEvent(identity, wire.groupId, record.wire.eventId)) continue;
const group = readEncryptedGroupState(identity, wire.groupId);
if (!group) throw new Error(`This device has not joined encrypted group ${wire.groupId}.`);
writeEncryptedGroupState(identity, wire.groupId, {
bytes: group.bytes,
epoch: group.epoch,
membershipRevision: group.membershipRevision,
expectedStateRevision: group.stateRevision,
processedEvent: { eventId: record.wire.eventId, plaintext: record.plaintext, wire: record.wire },
});
if (record.attachmentContents?.length) {
const metadata = decryptAttachmentMetadata(
record.wire.eventId,
record.plaintext?.body?.attachments || [],
record.wire.attachments || [],
);
importE2eeAttachmentPlaintexts(record.wire.eventId, metadata, record.attachmentContents);
}
}
await client.e2eeAcknowledgeHistoryArchive(wire.archiveId);
imported.push({ archiveId: wire.archiveId, messageCount: archive.messages.length });
}
return imported;
}
/**
* Re-encrypt a selected plaintext history snapshot under a fresh, one-time
* archive key. The caller shares that key only after the new device has joined
* the current MLS epoch; the server sees only this temporary ciphertext.
*/
export function createE2eeHistoryArchive(input) {
const archiveId = String(input.archiveId || `ehist_${randomBytes(18).toString("base64url")}`);
const archiveKey = randomBytes(32);
const nonce = randomBytes(12);
const createdAt = input.createdAt || new Date().toISOString();
const expiresAt = input.expiresAt || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
const archive = {
version: 1,
context: HISTORY_ARCHIVE_CONTEXT,
archiveId,
groupId: input.groupId,
createdAt,
expiresAt,
messages: input.messages,
};
const aad = Buffer.from(JSON.stringify([HISTORY_ARCHIVE_CONTEXT, input.groupId, archiveId]), "utf8");
const cipher = createCipheriv("aes-256-gcm", archiveKey, nonce);
cipher.setAAD(aad);
const encrypted = Buffer.concat([
cipher.update(Buffer.from(JSON.stringify(archive), "utf8")),
cipher.final(),
]);
const packed = Buffer.concat([Buffer.from([HISTORY_ARCHIVE_WIRE_VERSION]), nonce, cipher.getAuthTag(), encrypted]);
return {
archiveId,
groupId: input.groupId,
archiveKey: archiveKey.toString("base64url"),
ciphertext: packed.toString("base64url"),
ciphertextSha256: sha256(packed),
ciphertextSize: packed.length,
expiresAt,
};
}
export function openE2eeHistoryArchive(input) {
const packed = Buffer.from(input.ciphertext, "base64url");
if (packed.length < 30 || packed[0] !== HISTORY_ARCHIVE_WIRE_VERSION) throw new Error("Invalid encrypted history archive.");
if (input.ciphertextSha256 && sha256(packed) !== input.ciphertextSha256) {
throw new Error("Encrypted history archive failed its ciphertext integrity check.");
}
const nonce = packed.subarray(1, 13);
const tag = packed.subarray(13, 29);
const ciphertext = packed.subarray(29);
const aad = Buffer.from(JSON.stringify([HISTORY_ARCHIVE_CONTEXT, input.groupId, input.archiveId]), "utf8");
try {
const decipher = createDecipheriv("aes-256-gcm", Buffer.from(input.archiveKey, "base64url"), nonce);
decipher.setAAD(aad);
decipher.setAuthTag(tag);
const archive = JSON.parse(Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"));
if (
archive?.version !== 1 ||
archive.context !== HISTORY_ARCHIVE_CONTEXT ||
archive.archiveId !== input.archiveId ||
archive.groupId !== input.groupId ||
(input.expiresAt && archive.expiresAt !== input.expiresAt) ||
!Number.isFinite(Date.parse(archive.expiresAt)) ||
Date.parse(archive.expiresAt) <= Date.now() ||
!Array.isArray(archive.messages)
) throw new Error("Encrypted history archive metadata did not authenticate.");
return archive;
} catch (error) {
if (error?.message === "Encrypted history archive metadata did not authenticate.") throw error;
throw new Error("Encrypted history archive could not be authenticated.");
}
}