From a1a6df0348dd7c3952f787159b982fae809078d5 Mon Sep 17 00:00:00 2001 From: holmes Date: Fri, 19 Jun 2026 12:24:15 -0700 Subject: [PATCH 1/2] fix channel metadata validation --- .../storage/channels/channels.service.spec.ts | 178 ++++++++++++++++ .../nest/storage/channels/channels.service.ts | 194 ++++++++++++++++++ 2 files changed, 372 insertions(+) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index 18872c6101..9ef34377fd 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -2,8 +2,10 @@ import { jest } from '@jest/globals' import { Test, TestingModule } from '@nestjs/testing' import { getBaseTypesFactory } from '@quiet/state-manager' +import { generateDmChannelId } from '@quiet/common' import { ChannelMessage, + ChannelType, Community, DeleteChannelResponse, FileMetadata, @@ -14,6 +16,7 @@ import { import path from 'path' import { type PeerId } from '@libp2p/interface' +import { type LogEntry } from '@orbitdb/core' import waitForExpect from 'wait-for-expect' import { TestModule } from '../../common/test.module' import { createArbitraryFile, libp2pInstanceParams } from '../../common/utils' @@ -33,6 +36,11 @@ import { createLogger } from '../../common/logger' import { ChannelsService } from './channels.service' import { SigChainService } from '../../auth/sigchain.service' import { CID } from 'multiformats/cid' +import { SigChain } from '../../auth/sigchain' +import { RoleName } from '../../auth/services/roles/roles' +import { EncryptedAndSignedPayload, EncryptionScopeType } from '../../auth/services/crypto/types' +import { InviteService } from '../../auth/services/invites/invite.service' +import { UserService } from '../../auth/services/members/user.service' const logger = createLogger('channelsService:test') @@ -107,6 +115,51 @@ describe('ChannelsService', () => { }) }) + const createNonAdminMemberChain = (username: string): SigChain => { + const adminChain = sigChainService.getActiveChain() + const invite = adminChain.invites.createUserInvite() + const salt = `${username}-metadata-validation-salt` + + adminChain.lockbox.createInviteLockboxes(invite.seed, salt, RoleName.MEMBER) + + const invitedChain = SigChain.createFromInvite(username, invite.seed) + adminChain.invites.admitMemberFromInvite( + InviteService.generateProof(invite.seed), + invitedChain.user.userName, + invitedChain.user.userId, + UserService.redactUser(invitedChain.user).keys + ) + + const joinedChain = SigChain.joinForTesting( + { + user: invitedChain.user, + device: invitedChain.device, + }, + adminChain.save(), + adminChain.team!.teamKeyring() + ) + joinedChain.roles.addSelf(RoleName.MEMBER, invite.seed, salt) + + expect(joinedChain.roles.amIAdmin()).toBe(false) + expect(joinedChain.roles.amIMemberOfRole(RoleName.MEMBER)).toBe(true) + + return joinedChain + } + + const channelPutEntry = ( + key: string, + value: EncryptedAndSignedPayload, + hash: string = 'test-channel-metadata-entry' + ): LogEntry => + ({ + hash, + payload: { + op: 'PUT', + key, + value, + }, + }) as unknown as LogEntry + afterEach(async () => { await storageService.stop() await libp2pService.close() @@ -305,6 +358,131 @@ describe('ChannelsService', () => { }) }) + describe('Channel metadata validation', () => { + it('accepts legitimate DM metadata encrypted to the deterministic DM role', async () => { + const bobChain = createNonAdminMemberChain('bob') + const activeChain = sigChainService.getActiveChain() + const dmMemberIds = [aliceUserId, bobChain.user.userId].sort() + const legitimateDmRoleName = activeChain.dms.createWithMembers(dmMemberIds) + const dmChannelId = generateDmChannelId(dmMemberIds) + + const dmChannel: PublicChannel = { + id: dmChannelId, + name: 'alice, bob', + description: 'legitimate DM metadata', + owner: aliceUserId, + timestamp: Date.now(), + public: false, + roleName: legitimateDmRoleName, + type: ChannelType.DM, + memberIds: dmMemberIds, + teamId: community.teamId!, + } + const encryptedEntry = channelsService.encryptChannelEntry(dmChannel) + + await expect(channelsService.validateEntry(channelPutEntry(dmChannel.id, encryptedEntry))).resolves.toBe(true) + }) + + it('rejects forged DM metadata encrypted to the broad member role', async () => { + const bobChain = createNonAdminMemberChain('bob') + const malloryChain = createNonAdminMemberChain('mallory') + const activeChain = sigChainService.getActiveChain() + const dmMemberIds = [aliceUserId, bobChain.user.userId].sort() + activeChain.dms.createWithMembers(dmMemberIds) + const dmChannelId = generateDmChannelId(dmMemberIds) + + const forgedDmChannel: PublicChannel = { + id: dmChannelId, + name: 'alice, bob', + description: 'forged DM metadata', + owner: malloryChain.user.userId, + timestamp: Date.now(), + public: false, + roleName: RoleName.MEMBER, + type: ChannelType.DM, + memberIds: dmMemberIds, + teamId: community.teamId!, + } + const forgedEntry = malloryChain.crypto.encryptAndSign(forgedDmChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) + + await expect( + channelsService.validateEntry(channelPutEntry(forgedDmChannel.id, forgedEntry, 'forged-dm-metadata')) + ).resolves.toBe(false) + }) + + it('rejects forged private channel metadata encrypted to the broad member role', async () => { + const malloryChain = createNonAdminMemberChain('mallory') + const forgedPrivateChannel: PublicChannel = { + id: 'private-channel-id', + name: 'private-channel', + description: 'forged private channel metadata', + owner: malloryChain.user.userId, + timestamp: Date.now(), + public: false, + roleName: RoleName.MEMBER, + type: ChannelType.CHANNEL, + teamId: community.teamId!, + } + const forgedEntry = malloryChain.crypto.encryptAndSign(forgedPrivateChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) + + await expect( + channelsService.validateEntry( + channelPutEntry(forgedPrivateChannel.id, forgedEntry, 'forged-private-channel-metadata') + ) + ).resolves.toBe(false) + }) + + it('accepts legitimate private channel metadata encrypted to the channel role', async () => { + const activeChain = sigChainService.getActiveChain() + const privateChannel: PublicChannel = { + id: 'legitimate-private-channel-id', + name: 'legitimate-private-channel', + description: 'legitimate private channel metadata', + owner: aliceUserId, + timestamp: Date.now(), + public: false, + type: ChannelType.CHANNEL, + teamId: community.teamId!, + } + privateChannel.roleName = activeChain.channels.create(privateChannel.id) + const encryptedEntry = channelsService.encryptChannelEntry(privateChannel) + + await expect(channelsService.validateEntry(channelPutEntry(privateChannel.id, encryptedEntry))).resolves.toBe( + true + ) + }) + + it('rejects private channel metadata encrypted outside the channel role', async () => { + const activeChain = sigChainService.getActiveChain() + const privateChannel: PublicChannel = { + id: 'team-scoped-private-channel-id', + name: 'team-scoped-private-channel', + description: 'private channel metadata encrypted to team scope', + owner: aliceUserId, + timestamp: Date.now(), + public: false, + type: ChannelType.CHANNEL, + teamId: community.teamId!, + } + privateChannel.roleName = activeChain.channels.create(privateChannel.id) + const teamScopedEntry = activeChain.crypto.encryptAndSign(privateChannel, { + type: EncryptionScopeType.TEAM, + }) + + await expect( + channelsService.validateEntry( + channelPutEntry(privateChannel.id, teamScopedEntry, 'team-scoped-private-channel-metadata') + ) + ).resolves.toBe(false) + }) + }) + describe('Files deletion', () => { let realFilePath: string let messages: { diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index 81471ee837..ec9ad0e8bc 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -44,6 +44,7 @@ import { DateTime } from 'luxon' import { isChannel } from '../../validation/validators' import { NotAMemberError } from './channels.errors' import { SigchainEvents } from '../../auth/types' +import { generateDmChannelId } from '@quiet/common' /** * Manages storage-level logic for all channels in Quiet @@ -248,6 +249,196 @@ export class ChannelsService extends EventEmitter { } } + private validateChannelEntryMetadata( + entry: LogEntry, + encPayload: EncryptedAndSignedPayload, + decEntry: PublicChannel + ): boolean { + const key = entry.payload.key + const sigAuthor = encPayload.signature.author.name + const chain = this.sigchainService.getActiveChain(false) + + if (chain == null) { + this.logger.error('Cannot validate channel entry without an active chain:', entry.hash) + return false + } + + if (!key || key !== decEntry.id) { + this.logger.error('Failed to validate channel entry: key must match decrypted channel id:', entry.hash, { + key, + channelId: decEntry.id, + }) + return false + } + + if (!encPayload.userId || !sigAuthor || encPayload.userId !== sigAuthor) { + this.logger.error('Failed to validate channel entry: payload userId must match signature author:', entry.hash, { + userId: encPayload.userId, + sigAuthor, + }) + return false + } + + if (encPayload.teamId !== chain.team!.id) { + this.logger.error('Failed to validate channel entry: payload teamId must match active chain:', entry.hash, { + payloadTeamId: encPayload.teamId, + activeTeamId: chain.team!.id, + }) + return false + } + + if (decEntry.type === ChannelType.DM) { + return this.validateDmChannelEntry(entry, encPayload, decEntry, sigAuthor) + } + + if (!(decEntry.public ?? true)) { + return this.validatePrivateChannelEntry(entry, encPayload, decEntry, sigAuthor) + } + + if (decEntry.roleName != null) { + this.logger.error('Failed to validate public channel entry: public channels cannot declare a role:', entry.hash, { + roleName: decEntry.roleName, + }) + return false + } + + return this.validateChannelEncryptionScope(entry, encPayload, RoleName.MEMBER) + } + + private validateDmChannelEntry( + entry: LogEntry, + encPayload: EncryptedAndSignedPayload, + decEntry: PublicChannel, + sigAuthor: string + ): boolean { + if (decEntry.public ?? true) { + this.logger.error('Failed to validate DM channel entry: DMs must be private:', entry.hash) + return false + } + + const memberIds = decEntry.memberIds + if (memberIds == null || memberIds.length === 0) { + this.logger.error('Failed to validate DM channel entry: memberIds are required:', entry.hash) + return false + } + + const uniqueMemberIds = new Set(memberIds) + if (uniqueMemberIds.size !== memberIds.length) { + this.logger.error('Failed to validate DM channel entry: memberIds must be unique:', entry.hash) + return false + } + + if (!uniqueMemberIds.has(sigAuthor)) { + this.logger.error('Failed to validate DM channel entry: signer must be a DM participant:', entry.hash, { + sigAuthor, + memberIds, + }) + return false + } + + if (decEntry.owner !== sigAuthor) { + this.logger.error('Failed to validate DM channel entry: owner must match signature author:', entry.hash, { + owner: decEntry.owner, + sigAuthor, + }) + return false + } + + const expectedChannelId = generateDmChannelId(memberIds) + if (decEntry.id !== expectedChannelId) { + this.logger.error('Failed to validate DM channel entry: id must match DM participants:', entry.hash, { + channelId: decEntry.id, + expectedChannelId, + }) + return false + } + + const chain = this.sigchainService.getActiveChain() + const expectedRoleName = chain.dms.generateDmRoleName([...memberIds, sigAuthor]) + if (decEntry.roleName !== expectedRoleName) { + this.logger.error('Failed to validate DM channel entry: roleName must match DM participants:', entry.hash, { + roleName: decEntry.roleName, + expectedRoleName, + }) + return false + } + + try { + chain.users.getUsersById(memberIds) + const allMembersHaveRole = memberIds.every(memberId => chain.roles.memberHasRole(memberId, expectedRoleName)) + if (!allMembersHaveRole) { + this.logger.error('Failed to validate DM channel entry: all participants must have the DM role:', entry.hash, { + roleName: expectedRoleName, + memberIds, + }) + return false + } + } catch (err) { + this.logger.error( + 'Failed to validate DM channel entry: all participants must be valid team members:', + entry.hash, + { + memberIds, + err, + } + ) + return false + } + + return this.validateChannelEncryptionScope(entry, encPayload, expectedRoleName) + } + + private validatePrivateChannelEntry( + entry: LogEntry, + encPayload: EncryptedAndSignedPayload, + decEntry: PublicChannel, + sigAuthor: string + ): boolean { + if (decEntry.owner !== sigAuthor) { + this.logger.error('Failed to validate private channel entry: owner must match signature author:', entry.hash, { + owner: decEntry.owner, + sigAuthor, + }) + return false + } + + const chain = this.sigchainService.getActiveChain() + const expectedRoleName = chain.channels.generateChannelRoleName(decEntry.id) + if (decEntry.roleName !== expectedRoleName) { + this.logger.error('Failed to validate private channel entry: roleName must match channel id:', entry.hash, { + roleName: decEntry.roleName, + expectedRoleName, + }) + return false + } + + if (!chain.roles.memberHasRole(sigAuthor, expectedRoleName)) { + this.logger.error('Failed to validate private channel entry: signer must have the channel role:', entry.hash, { + sigAuthor, + roleName: expectedRoleName, + }) + return false + } + + return this.validateChannelEncryptionScope(entry, encPayload, expectedRoleName) + } + + private validateChannelEncryptionScope( + entry: LogEntry, + encPayload: EncryptedAndSignedPayload, + expectedRoleName: string + ): boolean { + const scope = encPayload.encrypted.scope + if (scope.type !== EncryptionScopeType.ROLE || scope.name !== expectedRoleName) { + this.logger.error('Failed to validate channel entry: encryption scope must match channel role:', entry.hash, { + scope, + expectedRoleName, + }) + return false + } + return true + } + /** * Validates a log entry in the OrbitDB store. * @param entry The log entry to validate. @@ -263,6 +454,9 @@ export class ChannelsService extends EventEmitter { this.logger.error('Decrypted channel entry is not a valid channel:', entry.hash, decEntry) return false } + if (!this.validateChannelEntryMetadata(entry, encPayload, decEntry)) { + return false + } } if (entry.payload.op === 'DEL') { if (!entry.payload.key) { From be51c047253eab009c510d146114b41bed257481 Mon Sep 17 00:00:00 2001 From: holmes Date: Fri, 19 Jun 2026 12:55:21 -0700 Subject: [PATCH 2/2] Reject public metadata for private channel ids --- .../storage/channels/channels.service.spec.ts | 39 +++++++++++++++++++ .../nest/storage/channels/channels.service.ts | 13 +++++++ 2 files changed, 52 insertions(+) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index 9ef34377fd..4d89959940 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -359,6 +359,17 @@ describe('ChannelsService', () => { }) describe('Channel metadata validation', () => { + it('accepts legitimate public channel metadata encrypted to the member role', async () => { + const publicChannel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + type: ChannelType.CHANNEL, + }) + const encryptedEntry = channelsService.encryptChannelEntry(publicChannel) + + await expect(channelsService.validateEntry(channelPutEntry(publicChannel.id, encryptedEntry))).resolves.toBe(true) + }) + it('accepts legitimate DM metadata encrypted to the deterministic DM role', async () => { const bobChain = createNonAdminMemberChain('bob') const activeChain = sigChainService.getActiveChain() @@ -481,6 +492,34 @@ describe('ChannelsService', () => { ) ).resolves.toBe(false) }) + + it('rejects public metadata forged for an existing private channel id', async () => { + const activeChain = sigChainService.getActiveChain() + const malloryChain = createNonAdminMemberChain('mallory') + const privateChannelId = 'downgraded-private-channel-id' + activeChain.channels.create(privateChannelId) + + const forgedPublicChannel: PublicChannel = { + id: privateChannelId, + name: 'downgraded-private-channel', + description: 'forged public metadata for a private channel', + owner: malloryChain.user.userId, + timestamp: Date.now(), + public: true, + type: ChannelType.CHANNEL, + teamId: community.teamId!, + } + const forgedEntry = malloryChain.crypto.encryptAndSign(forgedPublicChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) + + await expect( + channelsService.validateEntry( + channelPutEntry(forgedPublicChannel.id, forgedEntry, 'downgraded-private-channel-metadata') + ) + ).resolves.toBe(false) + }) }) describe('Files deletion', () => { diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index ec9ad0e8bc..83e8fec1bc 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -302,6 +302,19 @@ export class ChannelsService extends EventEmitter { return false } + const expectedPrivateRoleName = chain.channels.generateChannelRoleName(decEntry.id) + if (chain.roles.getAllRoles().some(role => role.roleName === expectedPrivateRoleName)) { + this.logger.error( + 'Failed to validate public channel entry: channel id already has a private channel role:', + entry.hash, + { + channelId: decEntry.id, + roleName: expectedPrivateRoleName, + } + ) + return false + } + return this.validateChannelEncryptionScope(entry, encPayload, RoleName.MEMBER) }