Skip to content
Draft
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
168 changes: 168 additions & 0 deletions packages/backend/src/nest/storage/channels/channels.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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')

Expand Down Expand Up @@ -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<EncryptedAndSignedPayload> =>
({
hash,
payload: {
op: 'PUT',
key,
value,
},
}) as unknown as LogEntry<EncryptedAndSignedPayload>

afterEach(async () => {
await storageService.stop()
await libp2pService.close()
Expand Down Expand Up @@ -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>('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>('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: {
Expand Down
119 changes: 119 additions & 0 deletions packages/backend/src/nest/storage/channels/channels.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,122 @@ export class ChannelsService extends EventEmitter {
}
}

private validateChannelEntryMetadata(
entry: LogEntry<EncryptedAndSignedPayload>,
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<EncryptedAndSignedPayload>,
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<EncryptedAndSignedPayload>,
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.
Expand All @@ -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) {
Expand Down
Loading