diff --git a/app/modules/privateGroup/docs/overview.md b/app/modules/privateGroup/docs/overview.md index 6f572e8c..50eff5e7 100644 --- a/app/modules/privateGroup/docs/overview.md +++ b/app/modules/privateGroup/docs/overview.md @@ -6,7 +6,7 @@ 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; @@ -14,26 +14,35 @@ The initial capability: - 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. diff --git a/app/modules/privateGroup/index.ts b/app/modules/privateGroup/index.ts index fead2616..c9287fa2 100644 --- a/app/modules/privateGroup/index.ts +++ b/app/modules/privateGroup/index.ts @@ -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 { - 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; @@ -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 @@ -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 { + 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(); +} diff --git a/app/modules/privateGroup/interface.ts b/app/modules/privateGroup/interface.ts index 9a538ce1..9a45edb7 100644 --- a/app/modules/privateGroup/interface.ts +++ b/app/modules/privateGroup/interface.ts @@ -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 | null | undefined): boolean; normalizeGroupData(groupData: Partial): Partial; getPostManifestHook(group: Partial | null | undefined): string; canMutateSharedPost(userId: number, post: Partial): boolean; + createMembershipSnapshot( + userId: number, + groupId: number, + expectedVersion: string | number + ): Promise; + getMembershipSnapshot( + userId: number, + groupId: number, + version?: string | number + ): Promise; afterPrivatePostManifestUpdate( userId: number, postId: number ): Promise; + flushDatabase(): Promise; } diff --git a/app/modules/privateGroup/models.ts b/app/modules/privateGroup/models.ts new file mode 100644 index 00000000..71e64ee7 --- /dev/null +++ b/app/modules/privateGroup/models.ts @@ -0,0 +1,107 @@ +import {DataTypes, Sequelize} from 'sequelize'; + +export default async function initializePrivateGroupModels(sequelize: Sequelize) { + const PrivateGroupMembershipSnapshot = sequelize.define( + 'privateGroupMembershipSnapshot', + { + groupId: { + type: DataTypes.INTEGER, + allowNull: false + }, + version: { + type: DataTypes.BIGINT, + allowNull: false + }, + createdByUserId: { + type: DataTypes.INTEGER, + allowNull: false + }, + membershipHash: { + type: DataTypes.STRING(64), + allowNull: false + }, + memberCount: { + type: DataTypes.INTEGER, + allowNull: false + }, + deviceCount: { + type: DataTypes.INTEGER, + allowNull: false + } + } as any, + { + indexes: [ + { + name: 'private_group_membership_group_version_unique', + fields: ['groupId', 'version'], + unique: true + }, + { + name: 'private_group_membership_group_created_idx', + fields: ['groupId', 'createdAt', 'id'] + } + ] + } as any + ); + + const PrivateGroupMembershipDevice = sequelize.define( + 'privateGroupMembershipDevice', + { + privateGroupMembershipSnapshotId: { + type: DataTypes.INTEGER, + allowNull: false + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false + }, + ownerId: { + type: DataTypes.STRING(500), + allowNull: false + }, + deviceId: { + type: DataTypes.STRING(200), + allowNull: false + }, + keyId: { + type: DataTypes.STRING(200), + allowNull: false + }, + bundleJson: { + type: DataTypes.TEXT, + allowNull: false + } + } as any, + { + indexes: [ + { + name: 'private_group_membership_devices_snapshot_key_unique', + fields: ['privateGroupMembershipSnapshotId', 'keyId'], + unique: true + }, + { + name: 'private_group_membership_devices_user_snapshot_idx', + fields: ['userId', 'privateGroupMembershipSnapshotId', 'id'] + } + ] + } as any + ); + + PrivateGroupMembershipSnapshot.hasMany(PrivateGroupMembershipDevice, { + as: 'devices', + foreignKey: 'privateGroupMembershipSnapshotId', + onDelete: 'CASCADE' + }); + PrivateGroupMembershipDevice.belongsTo(PrivateGroupMembershipSnapshot, { + as: 'snapshot', + foreignKey: 'privateGroupMembershipSnapshotId' + }); + + await PrivateGroupMembershipSnapshot.sync({}); + await PrivateGroupMembershipDevice.sync({}); + + return { + PrivateGroupMembershipSnapshot, + PrivateGroupMembershipDevice + }; +} diff --git a/check/databaseMigrationIntegrity.ts b/check/databaseMigrationIntegrity.ts index 14ff20d4..baffb60e 100644 --- a/check/databaseMigrationIntegrity.ts +++ b/check/databaseMigrationIntegrity.ts @@ -274,6 +274,8 @@ const expectedColumns: ExpectedColumn[] = [ {table: 'chatSyncJobs', columns: ['claimedAt'], type: 'timestamp with time zone'}, {table: 'chatSyncJobs', columns: ['claimExpiresAt'], type: 'timestamp with time zone'}, {table: 'chatSyncJobs', columns: ['claimToken'], type: 'character varying'}, + {table: 'privateGroupMembershipSnapshots', columns: ['version'], type: 'bigint'}, + {table: 'privateGroupMembershipDevices', columns: ['bundleJson'], type: 'text'}, ]; const expectedIndexes: ExpectedIndex[] = [ @@ -403,6 +405,28 @@ const expectedIndexes: ExpectedIndex[] = [ table: 'chatSyncJobs', columns: ['recipientOwnerId', 'nextAttemptAt', 'id'] }, + { + name: 'private_group_membership_group_version_unique', + table: 'privateGroupMembershipSnapshots', + columns: ['groupId', 'version'], + unique: true + }, + { + name: 'private_group_membership_group_created_idx', + table: 'privateGroupMembershipSnapshots', + columns: ['groupId', 'createdAt', 'id'] + }, + { + name: 'private_group_membership_devices_snapshot_key_unique', + table: 'privateGroupMembershipDevices', + columns: ['privateGroupMembershipSnapshotId', 'keyId'], + unique: true + }, + { + name: 'private_group_membership_devices_user_snapshot_idx', + table: 'privateGroupMembershipDevices', + columns: ['userId', 'privateGroupMembershipSnapshotId', 'id'] + }, ]; const storageObjectMetadataColumns = [ @@ -586,6 +610,66 @@ const countChecks: CountCheck[] = [ WHERE state.id IS NULL `, }, + { + name: 'private group membership snapshot counts match immutable device rows', + requirements: [ + { + table: 'privateGroupMembershipSnapshots', + columns: ['id', 'memberCount', 'deviceCount'], + }, + { + table: 'privateGroupMembershipDevices', + columns: ['privateGroupMembershipSnapshotId', 'userId'], + }, + ], + sql: ` + SELECT COUNT(*) AS count + FROM "privateGroupMembershipSnapshots" snapshot + LEFT JOIN ( + SELECT + device."privateGroupMembershipSnapshotId" AS "snapshotId", + COUNT(*) AS "deviceCount", + COUNT(DISTINCT device."userId") AS "memberCount" + FROM "privateGroupMembershipDevices" device + GROUP BY device."privateGroupMembershipSnapshotId" + ) stored + ON stored."snapshotId" = snapshot.id + WHERE COALESCE(stored."deviceCount", 0) <> snapshot."deviceCount" + OR COALESCE(stored."memberCount", 0) <> snapshot."memberCount" + `, + }, + { + name: 'private group membership devices reference existing snapshots', + requirements: [ + { + table: 'privateGroupMembershipDevices', + columns: ['privateGroupMembershipSnapshotId'], + }, + {table: 'privateGroupMembershipSnapshots', columns: ['id']}, + ], + sql: ` + SELECT COUNT(*) AS count + FROM "privateGroupMembershipDevices" device + LEFT JOIN "privateGroupMembershipSnapshots" snapshot + ON snapshot.id = device."privateGroupMembershipSnapshotId" + WHERE snapshot.id IS NULL + `, + }, + { + name: 'private group membership snapshots reference private groups', + requirements: [ + {table: 'privateGroupMembershipSnapshots', columns: ['groupId']}, + {table: 'groups', columns: ['id', 'type']}, + ], + sql: ` + SELECT COUNT(*) AS count + FROM "privateGroupMembershipSnapshots" snapshot + LEFT JOIN groups + ON groups.id = snapshot."groupId" + WHERE groups.id IS NULL + OR groups.type <> 'private_group' + `, + }, duplicateCheck('storage object storageId duplicates', 'storageObjects', ['storageId']), duplicateCheck('storage object reference duplicates', 'storageObjectReferences', ['sourceStorageId', 'targetStorageId', 'referenceType']), { diff --git a/check/databaseSyncModels.ts b/check/databaseSyncModels.ts index bffee550..c36a52b5 100644 --- a/check/databaseSyncModels.ts +++ b/check/databaseSyncModels.ts @@ -30,6 +30,13 @@ const moduleModelSyncers = [ return (await import('../app/modules/group/models/index.js')).default(sequelize, models); }, }, + { + name: 'privateGroup', + sync: async (sequelize: Sequelize, models: any) => { + Object.assign(models, await (await import('../app/modules/privateGroup/models.js')).default(sequelize)); + return models; + }, + }, { name: 'chat', sync: async (sequelize: Sequelize, models: any) => { diff --git a/test/group.test.ts b/test/group.test.ts index 0e763bdf..019a913a 100644 --- a/test/group.test.ts +++ b/test/group.test.ts @@ -10,6 +10,7 @@ import assert from 'assert'; import commonHelper from "geesome-libs/src/common.js"; import trieHelper from "geesome-libs/src/base36Trie.js"; +import browserE2eeHelper from "geesome-libs/src/browserE2eeHelper.js"; import {ContentStorageType, ContentView, CorePermissionName} from "../app/modules/database/interface.js"; import { GroupType, @@ -78,6 +79,16 @@ describe("group", function () { password: 'private-group-member', permissions: [CorePermissionName.UserAll] }); + const creatorDevice = await browserE2eeHelper.generateDeviceKeys({ + ownerId: testUser.storageAccountId, + deviceId: 'private-group-creator-browser' + }); + const memberDevice = await browserE2eeHelper.generateDeviceKeys({ + ownerId: secondUser.storageAccountId, + deviceId: 'private-group-member-browser' + }); + await app.ms.chat.registerDevice(testUser.id, creatorDevice.publicBundle); + await app.ms.chat.registerDevice(secondUser.id, memberDevice.publicBundle); const privateGroup = await app.ms.group.createGroup(testUser.id, { name: 'private-group', title: 'Private group', @@ -88,6 +99,16 @@ describe("group", function () { }); await app.ms.group.addMemberToGroup(testUser.id, privateGroup.id, secondUser.id); await app.ms.group.addAdminToGroup(testUser.id, privateGroup.id, secondUser.id); + const firstMembership = await app.ms.privateGroup.createMembershipSnapshot( + testUser.id, + privateGroup.id, + '0' + ); + const replayedMembership = await app.ms.privateGroup.createMembershipSnapshot( + testUser.id, + privateGroup.id, + '0' + ); let privateHookCalls = 0; let publicHookCalls = 0; @@ -111,6 +132,10 @@ describe("group", function () { assert.equal(privateGroup.isOpen, false); assert.equal(privateGroup.isEncrypted, true); assert.equal(await app.ms.group.isMemberInGroup(testUser.id, privateGroup.id), true); + assert.equal(firstMembership.version, '1'); + assert.equal(firstMembership.memberCount, 2); + assert.equal(firstMembership.deviceCount, 2); + assert.equal(replayedMembership.id, firstMembership.id); assert.equal(privateHookCalls, 1); assert.equal(publicHookCalls, 0); await app.ms.group.updateGroup(testUser.id, privateGroup.id, { @@ -141,6 +166,56 @@ describe("group", function () { const updatedPost = await app.ms.group.getPostPure(post.id); assert.equal(updatedPost.view, 'author-edit'); + const secondCreatorDevice = await browserE2eeHelper.generateDeviceKeys({ + ownerId: testUser.storageAccountId, + deviceId: 'private-group-creator-second-browser' + }); + await app.ms.chat.registerDevice(testUser.id, secondCreatorDevice.publicBundle); + await assert.rejects( + () => app.ms.privateGroup.createMembershipSnapshot( + testUser.id, + privateGroup.id, + '0' + ), + (error: Error) => error.message === 'private_group_membership_version_conflict' + ); + const secondMembership = await app.ms.privateGroup.createMembershipSnapshot( + testUser.id, + privateGroup.id, + '1' + ); + assert.equal(secondMembership.version, '2'); + assert.equal(secondMembership.deviceCount, 3); + + await app.ms.chat.revokeDevice(secondUser.id, memberDevice.publicBundle.deviceId); + await assert.rejects( + () => app.ms.privateGroup.createMembershipSnapshot( + testUser.id, + privateGroup.id, + '2' + ), + (error: Error) => error.message === 'private_group_member_device_required' + ); + await app.ms.group.removeMemberFromGroup(testUser.id, privateGroup.id, secondUser.id); + const thirdMembership = await app.ms.privateGroup.createMembershipSnapshot( + testUser.id, + privateGroup.id, + '2' + ); + const historicalMembership = await app.ms.privateGroup.getMembershipSnapshot( + testUser.id, + privateGroup.id, + '1' + ); + assert.equal(thirdMembership.version, '3'); + assert.equal(thirdMembership.memberCount, 1); + assert.equal(thirdMembership.deviceCount, 2); + assert.equal(historicalMembership.deviceCount, 2); + assert.equal( + historicalMembership.devices.some(device => device.userId === secondUser.id), + true + ); + app.ms.privateGroup.afterPrivatePostManifestUpdate = privateManifestHook; app.ms.activityPub.afterPostManifestUpdate = activityPubManifestHook; });