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 d531eeb798..a441794e26 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -14,6 +14,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 +34,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 +113,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() @@ -302,6 +353,123 @@ 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!, + }) + const encryptedEntry = channelsService.encryptChannelEntry(publicChannel) + + await expect(channelsService.validateEntry(channelPutEntry(publicChannel.id, encryptedEntry))).resolves.toBe(true) + }) + + 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, + 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 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, + 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('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, + 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) + }) + + 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, + 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) + }) + + it('rejects channel metadata stored under a different key', async () => { + const publicChannel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + const encryptedEntry = channelsService.encryptChannelEntry(publicChannel) + + await expect(channelsService.validateEntry(channelPutEntry('wrong-channel-id', encryptedEntry))).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 2783c879b4..7b67a9718c 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -246,6 +246,122 @@ 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.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 + } + + 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) + } + + 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. @@ -261,6 +377,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) {