Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions app/modules/privateGroup/docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,43 @@ The `privateGroup` module owns policy that distinguishes browser-encrypted
private groups from public publishing groups while reusing the group, post,
post-content, post-event, and reconciliation foundations.

The initial capability:
The current capability:

- identifies new private groups with `GroupType.PrivateGroup`;
- keeps creation disabled by default until `PRIVATE_GROUP_ENABLED=1` is set;
- normalizes them to non-public, closed, encrypted groups;
- routes completed private post manifests through
`afterPrivatePostManifestUpdate` instead of public post hooks;
- keeps shared post mutation under the original author's control;
- stores immutable, monotonically versioned snapshots of the private group's
account membership and registered non-revoked public device bundles;
- serializes snapshot updates under the group row lock, rejects stale expected
versions, and returns the current snapshot for an idempotent retry;
- leaves legacy `GroupType.PersonalChat` and browser-first direct `ChatEvent`
behavior unchanged.

## Boundary

This module is the integration point for later versioned member-device keys,
This module is the integration point for versioned member-device keys, later
membership/key epochs, private delivery policy, and encrypted-post callbacks.
It must not receive plaintext messages, plaintext attachments, attachment keys,
or browser private keys.

Membership snapshots are not MLS epochs. They record the deterministic public
device set that an accepted browser protocol transition can reference later.
Historical snapshots retain copied public bundles so revoking a device does not
rewrite the membership facts attached to older encrypted posts.

Modules that intentionally process private posts must implement the private hook
explicitly. Public integrations must continue to use
`afterPostManifestUpdate`; they are not called for private-group posts.

## Current Limitations

The module does not yet create browser-facing private groups, store device-key
membership, run membership/key transitions, or migrate legacy chat events.
Those capabilities remain gated by the secure-chat implementation plan and
multi-node browser verification.
The module does not yet expose browser-facing membership routes, bind snapshots
to private posts, run membership/key transitions, or migrate legacy chat
events. Those capabilities remain gated by the secure-chat implementation plan
and multi-node browser verification.

Enabling the initial capability is intended for development and compatibility
testing. It does not make private group chat ready for users.
236 changes: 234 additions & 2 deletions app/modules/privateGroup/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import {createHash} from 'node:crypto';
import {Op} from 'sequelize';
import type {IGeesomeApp} from '../../interface.js';
import {GroupType, IGroup, IPost} from '../group/interface.js';
import IGeesomePrivateGroupModule, {
IPrivateGroupMembershipSnapshot,
privateGroupPostManifestHook,
publicPostManifestHook
} from './interface.js';

export default async function (app: IGeesomeApp): Promise<IGeesomePrivateGroupModule> {
return getModule(app);
const models = await (await import('./models.js')).default(
app.ms.database.sequelize
);
return getModule(app, models);
}

export function getModule(app: IGeesomeApp): IGeesomePrivateGroupModule {
export function getModule(app: IGeesomeApp, models: any = null): IGeesomePrivateGroupModule {
return {
isEnabled(): boolean {
return app.config?.privateGroupConfig?.enabled === true;
Expand Down Expand Up @@ -45,6 +51,111 @@ export function getModule(app: IGeesomeApp): IGeesomePrivateGroupModule {
return Number(post?.userId) === Number(userId);
},

async createMembershipSnapshot(userId, groupId, expectedVersion) {
assertMembershipModels(models);
const normalizedGroupId = normalizeId(groupId, 'private_group_id_invalid');
const normalizedUserId = normalizeId(userId, 'private_group_user_id_invalid');
assertPrivateGroupEnabled(this);
const sequelize = app.ms.database.sequelize;

return sequelize.transaction(async transaction => {
const dependencies = getMembershipDependencies(sequelize);
const lockedGroup = await dependencies.Group.findByPk(normalizedGroupId, {
transaction,
lock: transaction.LOCK.UPDATE
});
if (!this.isPrivateGroup(lockedGroup)) {
throw new Error('private_group_required');
}
const isAdministrator = await dependencies.GroupAdministrators.count({
where: {
groupId: normalizedGroupId,
userId: normalizedUserId
},
transaction
});
if (!isAdministrator) {
throw new Error('not_permitted');
}

const latestSnapshot = await models.PrivateGroupMembershipSnapshot.findOne({
where: {groupId: normalizedGroupId},
order: [['version', 'DESC']],
transaction,
lock: transaction.LOCK.UPDATE
});
const members = await dependencies.GroupMembers.findAll({
attributes: ['userId'],
where: {groupId: normalizedGroupId},
order: [['userId', 'ASC']],
transaction
});
const memberIds = members.map(member => Number(member.userId));
const devices = await dependencies.ChatDevice.findAll({
where: {
userId: {[Op.in]: memberIds},
revokedAt: null
},
order: [
['userId', 'ASC'],
['deviceId', 'ASC'],
['keyId', 'ASC'],
['id', 'ASC']
],
transaction
});
assertEveryMemberHasDevice(memberIds, devices);

const deviceRows = devices.map(serializeMembershipDeviceRow);
const membershipHash = hashMembershipDevices(deviceRows);
if (latestSnapshot?.membershipHash === membershipHash) {
return loadMembershipSnapshot(models, latestSnapshot.id, transaction);
}

const currentVersion = latestSnapshot
? normalizeVersion(latestSnapshot.version)
: '0';
if (normalizeVersion(expectedVersion) !== currentVersion) {
throw new Error('private_group_membership_version_conflict');
}

const snapshot = await models.PrivateGroupMembershipSnapshot.create({
groupId: normalizedGroupId,
version: (BigInt(currentVersion) + 1n).toString(),
createdByUserId: normalizedUserId,
membershipHash,
memberCount: memberIds.length,
deviceCount: deviceRows.length
}, {transaction});
await models.PrivateGroupMembershipDevice.bulkCreate(
deviceRows.map(device => ({
...device,
privateGroupMembershipSnapshotId: snapshot.id
})),
{transaction}
);
return loadMembershipSnapshot(models, snapshot.id, transaction);
});
},

async getMembershipSnapshot(userId, groupId, version?) {
assertMembershipModels(models);
const normalizedGroupId = normalizeId(groupId, 'private_group_id_invalid');
await assertCanReadPrivateGroup(app, this, userId, normalizedGroupId);
const where: any = {groupId: normalizedGroupId};
if (version !== undefined && version !== null) {
where.version = normalizeVersion(version);
}
const snapshot = await models.PrivateGroupMembershipSnapshot.findOne({
where,
order: [['version', 'DESC']]
});
if (!snapshot) {
return null;
}
return loadMembershipSnapshot(models, snapshot.id);
},

async afterPrivatePostManifestUpdate(
_userId: number,
postId: number
Expand All @@ -58,6 +169,127 @@ export function getModule(app: IGeesomeApp): IGeesomePrivateGroupModule {
postId: Number(post.id),
private: true
};
},

async flushDatabase() {
if (!models) {
return;
}
await models.PrivateGroupMembershipDevice.destroy({where: {}});
await models.PrivateGroupMembershipSnapshot.destroy({where: {}});
}
};
}

function assertPrivateGroupEnabled(module: IGeesomePrivateGroupModule) {
if (!module.isEnabled()) {
throw new Error('private_group_disabled');
}
}

async function assertCanReadPrivateGroup(
app: IGeesomeApp,
module: IGeesomePrivateGroupModule,
userId: number,
groupId: number
) {
if (!module.isEnabled()) {
throw new Error('private_group_disabled');
}
const group = await app.ms.group.getGroup(groupId);
if (!module.isPrivateGroup(group)) {
throw new Error('private_group_required');
}
const [isMember, isAdmin] = await Promise.all([
app.ms.group.isMemberInGroup(userId, groupId),
app.ms.group.isAdminInGroup(userId, groupId)
]);
if (!isMember && !isAdmin) {
throw new Error('not_permitted');
}
}

function getMembershipDependencies(sequelize) {
const Group = sequelize.models.group;
const GroupMembers = sequelize.models.groupMembers;
const GroupAdministrators = sequelize.models.groupAdministrators;
const ChatDevice = sequelize.models.chatDevice;
if (!Group || !GroupMembers || !GroupAdministrators || !ChatDevice) {
throw new Error('private_group_membership_dependencies_unavailable');
}
return {Group, GroupMembers, GroupAdministrators, ChatDevice};
}

function assertMembershipModels(models) {
if (!models?.PrivateGroupMembershipSnapshot || !models?.PrivateGroupMembershipDevice) {
throw new Error('private_group_membership_models_unavailable');
}
}

function assertEveryMemberHasDevice(memberIds: number[], devices) {
const deviceMemberIds = new Set(devices.map(device => Number(device.userId)));
if (memberIds.some(memberId => !deviceMemberIds.has(memberId))) {
throw new Error('private_group_member_device_required');
}
}

function serializeMembershipDeviceRow(device) {
return {
userId: Number(device.userId),
ownerId: String(device.ownerId),
deviceId: String(device.deviceId),
keyId: String(device.keyId),
bundleJson: String(device.bundleJson)
};
}

function hashMembershipDevices(devices): string {
return createHash('sha256')
.update(JSON.stringify(devices))
.digest('hex');
}

async function loadMembershipSnapshot(
models,
snapshotId: number,
transaction?
): Promise<IPrivateGroupMembershipSnapshot> {
const snapshot = await models.PrivateGroupMembershipSnapshot.findByPk(snapshotId, {
include: [{association: 'devices'}],
order: [[{model: models.PrivateGroupMembershipDevice, as: 'devices'}, 'id', 'ASC']],
transaction
});
return {
id: Number(snapshot.id),
groupId: Number(snapshot.groupId),
version: normalizeVersion(snapshot.version),
createdByUserId: Number(snapshot.createdByUserId),
membershipHash: String(snapshot.membershipHash),
memberCount: Number(snapshot.memberCount),
deviceCount: Number(snapshot.deviceCount),
devices: snapshot.devices.map(device => ({
userId: Number(device.userId),
ownerId: String(device.ownerId),
deviceId: String(device.deviceId),
keyId: String(device.keyId),
publicBundle: JSON.parse(device.bundleJson)
})),
createdAt: snapshot.createdAt
};
}

function normalizeId(value, errorCode: string): number {
const id = Number(value);
if (!Number.isSafeInteger(id) || id <= 0) {
throw new Error(errorCode);
}
return id;
}

function normalizeVersion(value): string {
const rawValue = String(value);
if (!/^\d+$/.test(rawValue)) {
throw new Error('private_group_membership_version_invalid');
}
return BigInt(rawValue).toString();
}
31 changes: 31 additions & 0 deletions app/modules/privateGroup/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,45 @@ export interface IPrivateGroupPostManifestResult {
private: true;
}

export interface IPrivateGroupMembershipDevice {
userId: number;
ownerId: string;
deviceId: string;
keyId: string;
publicBundle: any;
}

export interface IPrivateGroupMembershipSnapshot {
id: number;
groupId: number;
version: string;
createdByUserId: number;
membershipHash: string;
memberCount: number;
deviceCount: number;
devices: IPrivateGroupMembershipDevice[];
createdAt?: Date;
}

export default interface IGeesomePrivateGroupModule {
isEnabled(): boolean;
isPrivateGroup(group: Partial<IGroup> | null | undefined): boolean;
normalizeGroupData(groupData: Partial<IGroup>): Partial<IGroup>;
getPostManifestHook(group: Partial<IGroup> | null | undefined): string;
canMutateSharedPost(userId: number, post: Partial<IPost>): boolean;
createMembershipSnapshot(
userId: number,
groupId: number,
expectedVersion: string | number
): Promise<IPrivateGroupMembershipSnapshot>;
getMembershipSnapshot(
userId: number,
groupId: number,
version?: string | number
): Promise<IPrivateGroupMembershipSnapshot | null>;
afterPrivatePostManifestUpdate(
userId: number,
postId: number
): Promise<IPrivateGroupPostManifestResult>;
flushDatabase(): Promise<void>;
}
Loading