@signalapp/mock-server
Version:
Mock Signal Server for writing tests
247 lines (246 loc) • 12.2 kB
JavaScript
;
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ServerGroup = void 0;
const zkgroup_1 = require("@signalapp/libsignal-client/zkgroup");
const assert_1 = __importDefault(require("assert"));
const long_1 = __importDefault(require("long"));
const compiled_1 = require("../../protos/compiled");
const group_1 = require("../data/group");
const schemas_1 = require("../data/schemas");
const util_1 = require("../util");
const { AccessRequired } = compiled_1.signalservice.AccessControl;
const { Role } = compiled_1.signalservice.Member;
function getTodaysKey(zkSecret) {
const startOfDay = (0, util_1.getTodayInSeconds)();
const expiration = startOfDay + (0, util_1.daysToSeconds)(2);
return zkgroup_1.GroupSendDerivedKeyPair.forExpiration(new Date(1000 * expiration), zkSecret);
}
class ServerGroup extends group_1.Group {
profileOps;
zkSecret;
constructor({ profileOps, zkSecret, state }) {
super();
const parsedState = schemas_1.GroupStateSchema.parse(state);
this.privPublicParams = new zkgroup_1.GroupPublicParams(Buffer.from(parsedState.publicKey));
this.profileOps = profileOps;
this.zkSecret = zkSecret;
const unrolledState = { ...state };
unrolledState.members = (state.members ?? []).map((member) => this.unrollMember(member));
this.privChanges = {
groupChanges: [
{
groupState: unrolledState,
},
],
};
}
getGroupSendEndorsementResponse(sourceAci) {
const authMember = this.getMember(sourceAci);
if (!authMember) {
return null;
}
const members = this.state.members ?? [];
const groupCiphertexts = members.map((member) => {
(0, assert_1.default)(member.userId, 'Member must have a user ID');
return new zkgroup_1.UuidCiphertext(Buffer.from(member.userId));
});
const todaysKey = getTodaysKey(this.zkSecret);
return zkgroup_1.GroupSendEndorsementsResponse.issue(groupCiphertexts, todaysKey).serialize();
}
modify(sourceAci, sourcePni, actions) {
const appliedActions = {
version: actions.version,
sourceUserId: sourceAci.serialize(),
groupId: (0, util_1.fromBase64)(this.id),
};
assert_1.default.ok(actions.version, 'Actions should have a new version');
const timestamp = long_1.default.fromNumber(Date.now());
const newState = {
...this.state,
version: actions.version,
};
const authMember = this.getMember(sourceAci);
const { accessControl } = newState;
let changeEpoch = 1;
if (actions.modifyTitle) {
this.verifyAccess('title', authMember, accessControl?.attributes ?? AccessRequired.UNKNOWN);
appliedActions.modifyTitle = actions.modifyTitle;
newState.title = actions.modifyTitle.title;
}
const deleteMembers = actions.deleteMembers ?? [];
for (const { deletedUserId } of deleteMembers) {
assert_1.default.ok(deletedUserId, 'Missing deletedUserId');
this.verifyAccess('members', authMember, accessControl?.members ?? AccessRequired.UNKNOWN, deletedUserId);
const member = this.getMember(new zkgroup_1.UuidCiphertext(Buffer.from(deletedUserId)));
assert_1.default.ok(member, 'Pending member not found for deletion');
newState.members = (newState.members ?? []).filter((entry) => entry !== member);
appliedActions.deleteMembers = [
...(appliedActions.deleteMembers ?? []),
{ deletedUserId },
];
}
const addPendingMembers = actions.addPendingMembers ?? [];
for (const { added } of addPendingMembers) {
assert_1.default.ok(added, 'Missing addPendingMember.added');
const { member } = added;
assert_1.default.ok(member, 'Missing addPendingMembers.added.member');
const { userId, role } = member;
assert_1.default.ok(userId, 'Missing addPendingMembers.added.member.userId');
assert_1.default.ok(role, 'Missing addPendingMembers.added.member.role');
this.verifyAccess('pendingMembers', authMember, accessControl?.members ?? AccessRequired.UNKNOWN);
const newPendingMember = {
member: { userId, role },
addedByUserId: sourceAci.serialize(),
timestamp,
};
newState.membersPendingProfileKey = [
...(newState.membersPendingProfileKey ?? []),
newPendingMember,
];
appliedActions.addPendingMembers = [
...(appliedActions.addPendingMembers ?? []),
{ added: newPendingMember },
];
}
const deletePendingMembers = actions.deletePendingMembers ?? [];
for (const { deletedUserId } of deletePendingMembers) {
assert_1.default.ok(deletedUserId, 'Missing deletedUserId');
assert_1.default.ok(Buffer.from(deletedUserId).equals(sourceAci.serialize()) ||
Buffer.from(deletedUserId).equals(sourcePni.serialize()), 'Not a pending member');
const pendingMember = this.getPendingMember(new zkgroup_1.UuidCiphertext(Buffer.from(deletedUserId)));
assert_1.default.ok(pendingMember, 'Pending member not found for deletion');
newState.membersPendingProfileKey = (newState.membersPendingProfileKey ?? []).filter((entry) => entry !== pendingMember);
appliedActions.deletePendingMembers = [
...(appliedActions.deletePendingMembers ?? []),
{ deletedUserId },
];
}
const promotePendingMembers = actions.promotePendingMembers ?? [];
for (const { presentation } of promotePendingMembers) {
assert_1.default.ok(presentation, 'Missing presentation in promotePendingMembers');
const presentationFFI = new zkgroup_1.ProfileKeyCredentialPresentation(Buffer.from(presentation));
this.profileOps.verifyProfileKeyCredentialPresentation(this.publicParams, presentationFFI);
assert_1.default.ok(presentationFFI
.getUuidCiphertext()
.serialize()
.equals(sourceAci.serialize()), 'Not a pending member');
const pendingMember = this.getPendingMember(presentationFFI.getUuidCiphertext());
assert_1.default.ok(pendingMember, 'No pending member');
assert_1.default.ok(!this.getMember(presentationFFI.getUuidCiphertext()), 'Member is both pending and active');
newState.membersPendingProfileKey = (newState.membersPendingProfileKey ?? []).filter((entry) => entry !== pendingMember);
const userId = presentationFFI.getUuidCiphertext().serialize();
const profileKey = presentationFFI.getProfileKeyCiphertext().serialize();
newState.members = [
...(newState.members ?? []),
{
role: Role.DEFAULT,
userId,
profileKey,
},
];
appliedActions.promotePendingMembers = [
...(appliedActions.promotePendingMembers ?? []),
{ userId, profileKey },
];
}
const promotePNIMembers = actions.promoteMembersPendingPniAciProfileKey;
for (const { presentation } of promotePNIMembers ?? []) {
assert_1.default.ok(presentation, 'Missing presentation in promoteMembersPendingPniAciProfileKey');
const presentationFFI = new zkgroup_1.ProfileKeyCredentialPresentation(Buffer.from(presentation));
this.profileOps.verifyProfileKeyCredentialPresentation(this.publicParams, presentationFFI);
const aci = presentationFFI.getUuidCiphertext();
const pni = sourcePni;
const profileKey = presentationFFI.getProfileKeyCiphertext();
assert_1.default.ok(aci.serialize().equals(sourceAci.serialize()), 'Not a pending member');
const pendingMember = this.getPendingMember(pni);
assert_1.default.ok(pendingMember, 'No pending pni member');
assert_1.default.ok(!this.getMember(aci), 'ACI is already a member');
newState.membersPendingProfileKey = (newState.membersPendingProfileKey ?? []).filter((entry) => entry !== pendingMember);
newState.members = [
...(newState.members ?? []),
{
role: Role.DEFAULT,
userId: aci.serialize(),
profileKey: profileKey.serialize(),
},
];
changeEpoch = Math.max(changeEpoch, 5);
appliedActions.sourceUserId = sourcePni.serialize();
appliedActions.promoteMembersPendingPniAciProfileKey = [
...(appliedActions.promoteMembersPendingPniAciProfileKey ?? []),
{
userId: aci.serialize(),
pni: pni.serialize(),
profileKey: profileKey.serialize(),
},
];
}
const { version: oldVersion } = this.state;
assert_1.default.ok(typeof oldVersion === 'number', 'Group must have existing version');
if (actions.version !== oldVersion + 1) {
return { conflict: true, signedChange: undefined };
}
const encodedActions = compiled_1.signalservice.GroupChange.Actions.encode(appliedActions).finish();
const serverSignature = this.zkSecret
.sign(Buffer.from(encodedActions))
.serialize();
const groupChange = {
actions: encodedActions,
changeEpoch,
};
assert_1.default.ok(this.privChanges?.groupChanges, 'Must be initialized');
this.privChanges.groupChanges.push({
groupChange,
groupState: newState,
});
return {
conflict: false,
signedChange: {
...groupChange,
serverSignature,
},
};
}
//
// Private
//
verifyAccess(attribute, member, access, affectedUserId) {
// Changing something about ourselves is always allowed
if (member?.userId &&
affectedUserId &&
Buffer.from(member.userId).equals(affectedUserId)) {
return;
}
switch (access) {
case AccessRequired.ANY:
break;
case AccessRequired.MEMBER:
assert_1.default.ok(member, `Must be a member to access: ${attribute}`);
break;
case AccessRequired.ADMINISTRATOR:
assert_1.default.strictEqual(member?.role, Role.ADMINISTRATOR, `Must be an administrator to modify: ${attribute}`);
break;
case AccessRequired.UNSATISFIABLE:
throw new Error(`Unsatisfiable access attribute: ${attribute}`);
case AccessRequired.UNKNOWN:
throw new Error(`Unknown access for attribute: ${attribute}`);
}
}
unrollMember({ role, presentation }) {
assert_1.default.strictEqual(typeof role, 'number', 'Group member role is undefined');
assert_1.default.ok(presentation, 'Group member presentation is undefined');
const presentationFFI = new zkgroup_1.ProfileKeyCredentialPresentation(Buffer.from(presentation));
this.profileOps.verifyProfileKeyCredentialPresentation(this.publicParams, presentationFFI);
return {
role,
userId: presentationFFI.getUuidCiphertext().serialize(),
profileKey: presentationFFI.getProfileKeyCiphertext().serialize(),
};
}
}
exports.ServerGroup = ServerGroup;