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..3d736d89e7 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 { Entry, 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,13 @@ 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' +import { OrbitDbService } from '../orbitDb/orbitDb.service' +import { generateChannelId } from '@quiet/common' const logger = createLogger('channelsService:test') @@ -46,6 +54,7 @@ describe('ChannelsService', () => { let libp2pService: Libp2pService let localDbService: LocalDbService let channelsService: ChannelsService + let orbitDbService: OrbitDbService let sigChainService: SigChainService let peerId: PeerId @@ -73,6 +82,7 @@ describe('ChannelsService', () => { storageService = await module.resolve(StorageService) channelsService = await module.resolve(ChannelsService) + orbitDbService = await module.resolve(OrbitDbService) localDbService = await module.resolve(LocalDbService) libp2pService = await module.resolve(Libp2pService) ipfsService = await module.resolve(IpfsService) @@ -107,6 +117,96 @@ 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', + identity: string = 'test-channel-put-identity' + ): LogEntry => + ({ + hash, + identity, + payload: { + op: 'PUT', + key, + value, + }, + }) as unknown as LogEntry + + const channelDelEntry = ( + key: string, + identity: string = 'test-channel-delete-identity', + hash: string = 'test-channel-metadata-delete-entry' + ): LogEntry => + ({ + hash, + identity, + payload: { + op: 'DEL', + key, + }, + }) as unknown as LogEntry + + const mockChannelEntryIdentity = (userId: string): (() => void) => { + const identities = orbitDbService.identities + expect(identities).toBeDefined() + const teamId = sigChainService.getActiveChain().team!.id + const getIdentitySpy = jest.spyOn(identities!, 'getIdentity').mockResolvedValue({ id: userId, teamId } as any) + const verifyIdentitySpy = jest.spyOn(identities!, 'verifyIdentity').mockResolvedValue(true) + const entryVerifySpy = jest.spyOn(Entry, 'verify').mockResolvedValue(true) + + return () => { + getIdentitySpy.mockRestore() + verifyIdentitySpy.mockRestore() + entryVerifySpy.mockRestore() + } + } + + const expectChannelEntryValidation = async ( + entry: LogEntry, + writerUserId: string, + expected: boolean + ): Promise => { + const restoreIdentityMocks = mockChannelEntryIdentity(writerUserId) + + try { + await expect(channelsService.validateEntry(entry)).resolves.toBe(expected) + } finally { + restoreIdentityMocks() + } + } + afterEach(async () => { await storageService.stop() await libp2pService.close() @@ -302,6 +402,239 @@ 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 expectChannelEntryValidation(channelPutEntry(publicChannel.id, encryptedEntry), aliceUserId, true) + }) + + it('rejects public channel metadata when owner does not match the encrypted signature author', async () => { + const malloryChain = createNonAdminMemberChain('mallory') + const forgedPublicChannel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + const forgedEntry = malloryChain.crypto.encryptAndSign(forgedPublicChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) + + await expectChannelEntryValidation( + channelPutEntry(forgedPublicChannel.id, forgedEntry, 'owner-encrypted-signature-mismatch'), + malloryChain.user.userId, + false + ) + }) + + it('rejects public channel metadata when owner does not match the entry signature author', async () => { + const malloryChain = createNonAdminMemberChain('mallory') + const publicChannel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + const encryptedEntry = channelsService.encryptChannelEntry(publicChannel) + + await expectChannelEntryValidation( + channelPutEntry(publicChannel.id, encryptedEntry, 'owner-entry-signature-mismatch'), + malloryChain.user.userId, + 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, + teamId: community.teamId!, + } + privateChannel.roleName = activeChain.channels.create(privateChannel.id) + const encryptedEntry = channelsService.encryptChannelEntry(privateChannel) + + await expectChannelEntryValidation(channelPutEntry(privateChannel.id, encryptedEntry), aliceUserId, 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 expectChannelEntryValidation( + channelPutEntry(forgedPrivateChannel.id, forgedEntry, 'forged-private-channel-metadata'), + malloryChain.user.userId, + 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 expectChannelEntryValidation( + channelPutEntry(privateChannel.id, teamScopedEntry, 'team-scoped-private-channel-metadata'), + aliceUserId, + 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 expectChannelEntryValidation( + channelPutEntry(forgedPublicChannel.id, forgedEntry, 'downgraded-private-channel-metadata'), + malloryChain.user.userId, + false + ) + }) + + it('rejects metadata whose bound id does not commit to the writer (no prior state needed)', async () => { + // Alice owns a channel whose id cryptographically commits to her. Mallory tries to take it over + // by writing under the same id. This must be rejected purely from the id, with NOTHING in the + // store yet, which is what makes the check safe during initial sync / index rebuild. + const boundId = generateChannelId('takeover-target', aliceUserId) + const malloryChain = createNonAdminMemberChain('mallory') + const hijackChannel: PublicChannel = { + id: boundId, + name: 'hijacked', + description: 'hijacked channel metadata', + owner: malloryChain.user.userId, + timestamp: Date.now(), + public: true, + teamId: community.teamId!, + } + const hijackEntry = malloryChain.crypto.encryptAndSign(hijackChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) + + await expectChannelEntryValidation( + channelPutEntry(boundId, hijackEntry, 'bound-channel-takeover-metadata'), + malloryChain.user.userId, + false + ) + }) + + it('accepts metadata whose bound id commits to the writer', async () => { + const boundId = generateChannelId('owned-by-alice', aliceUserId) + const boundChannel: PublicChannel = { + id: boundId, + name: 'owned-by-alice', + description: 'legitimate bound channel metadata', + owner: aliceUserId, + timestamp: Date.now(), + public: true, + teamId: community.teamId!, + } + const encryptedEntry = channelsService.encryptChannelEntry(boundChannel) + + await expectChannelEntryValidation(channelPutEntry(boundId, encryptedEntry), aliceUserId, true) + }) + + it('rejects legacy-id metadata that overwrites an existing channel owned by another member', async () => { + // Legacy (unbound) ids fall back to the stateful check, which requires the original entry to be + // present/indexed first. + const publicChannel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + // Alice legitimately creates the channel + await channelsService.setChannel(publicChannel) + + // Mallory tries to take it over by writing a fresh, self-signed entry under the same id + const malloryChain = createNonAdminMemberChain('mallory') + const hijackChannel: PublicChannel = { + ...publicChannel, + owner: malloryChain.user.userId, + name: 'hijacked', + description: 'hijacked channel metadata', + } + const hijackEntry = malloryChain.crypto.encryptAndSign(hijackChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) + + await expectChannelEntryValidation( + channelPutEntry(publicChannel.id, hijackEntry, 'channel-takeover-metadata'), + malloryChain.user.userId, + 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 expectChannelEntryValidation(channelPutEntry('wrong-channel-id', encryptedEntry), aliceUserId, false) + }) + + it('accepts channel metadata deletion from a sigchain admin', async () => { + await expectChannelEntryValidation(channelDelEntry('channel-id-to-delete'), aliceUserId, true) + }) + + it('rejects channel metadata deletion from a non-admin member', async () => { + const malloryChain = createNonAdminMemberChain('mallory') + + await expectChannelEntryValidation( + channelDelEntry('channel-id-to-delete', 'mallory-channel-delete-identity'), + malloryChain.user.userId, + 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..99e8f2669b 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common' -import { IPFSAccessController, type LogEntry } from '@orbitdb/core' +import { Entry, IPFSAccessController, type LogEntry } from '@orbitdb/core' import { EventEmitter } from 'events' import { ChannelMessage, @@ -41,6 +41,7 @@ import { EncryptedAndSignedPayload, EncryptionScope, EncryptionScopeType } from import { RoleName } from '../../auth/services/roles/roles' import { DateTime } from 'luxon' import { isChannel } from '../../validation/validators' +import { isBoundChannelId, verifyChannelIdOwner } from '@quiet/common' import { NotAMemberError } from './channels.errors' import { SigchainEvents } from '../../auth/types' @@ -246,6 +247,325 @@ export class ChannelsService extends EventEmitter { } } + private async validateChannelEntryMetadata( + entry: LogEntry, + encPayload: EncryptedAndSignedPayload, + decEntry: PublicChannel + ): Promise { + 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 + } + + const writerIdentity = await this.getVerifiedChannelEntryWriter(entry, 'PUT') + if (writerIdentity == null) { + return false + } + + if (writerIdentity.teamId !== chain.team!.id) { + this.logger.error('Failed to validate channel entry: entry identity team must match active chain:', entry.hash, { + entryTeamId: writerIdentity.teamId, + activeTeamId: chain.team!.id, + }) + return false + } + + if (decEntry.owner !== sigAuthor || decEntry.owner !== writerIdentity.id) { + this.logger.error( + 'Failed to validate channel entry: owner must match encrypted payload signature author and entry signature author:', + entry.hash, + { + owner: decEntry.owner, + encryptedSignatureAuthor: sigAuthor, + entrySignatureAuthor: writerIdentity.id, + } + ) + 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 (!(await this.validateChannelOwnership(entry, decEntry, writerIdentity.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 + } + + private async getVerifiedChannelEntryWriter( + entry: LogEntry, + operation: 'PUT' | 'DEL' + ): Promise<{ id: string; teamId: string } | undefined> { + if (!entry.identity) { + this.logger.error(`Failed to validate channel ${operation} entry: entry identity is missing:`, entry.hash) + return undefined + } + + const identities = this.orbitDbService.identities + if (identities == null) { + this.logger.error( + `Failed to validate channel ${operation} entry: OrbitDB identities are not initialized:`, + entry.hash + ) + return undefined + } + + const writerIdentity = await identities.getIdentity(entry.identity) + const identityVerified = await identities.verifyIdentity(writerIdentity) + if (!identityVerified) { + this.logger.error( + `Failed to validate channel ${operation} entry: entry identity verification failed:`, + entry.hash + ) + return undefined + } + + const entryVerified = await Entry.verify(identities as any, entry) + if (!entryVerified) { + this.logger.error( + `Failed to validate channel ${operation} entry: entry signature verification failed:`, + entry.hash + ) + return undefined + } + + return { + id: writerIdentity.id, + teamId: writerIdentity.teamId, + } + } + + /** + * Enforce that a channel's metadata can only be written by its owner. + * + * For bound channel ids (`${name}_${nonce}_${commitment}`) the owner is committed into the id, so + * we can verify ownership statelessly. This holds regardless of replication/index-rebuild order, + * which the stateful fallback below cannot guarantee. + * + * For legacy ids (created before owner binding) there is no commitment to check, so we fall back + * to comparing against the stored entry. That fallback only reliably protects the live case (it + * depends on the existing entry already being indexed), but it is the best we can do for channels + * that predate the migration. + * + * @param entry The log entry being validated + * @param decEntry The decrypted channel metadata from the new entry + * @param writerId The verified id of the entry writer (already pinned to the encrypted signature author and owner) + * @returns True if the writer is authorized to write this channel's metadata + */ + private async validateChannelOwnership( + entry: LogEntry, + decEntry: PublicChannel, + writerId: string + ): Promise { + if (isBoundChannelId(decEntry.id)) { + if (!verifyChannelIdOwner(decEntry.id, writerId)) { + this.logger.error('Failed to validate channel entry: channel id is not bound to the writer:', entry.hash, { + channelId: decEntry.id, + writerId, + }) + return false + } + return true + } + + return this.validateLegacyChannelMetadataUpdate(entry, decEntry, writerId) + } + + /** + * Legacy (pre-owner-binding) fallback. Channel metadata is write-once: the only legitimate PUT for + * a given channel id is its creation. If an entry already exists for this key, ensure the writer is + * the original owner and that the security-relevant fields (owner, public flag, role) are + * unchanged. NOTE: this depends on the existing entry already being indexed, so it does not protect + * against a takeover observed during an initial full replication/index rebuild. + * + * @param entry The log entry being validated + * @param decEntry The decrypted channel metadata from the new entry + * @param writerId The verified id of the entry writer (already pinned to the encrypted signature author and owner) + * @returns True if this is a fresh channel or a valid update by the original owner + */ + private async validateLegacyChannelMetadataUpdate( + entry: LogEntry, + decEntry: PublicChannel, + writerId: string + ): Promise { + const stored = await this.channels!.get(entry.payload.key!) + if (stored == null) { + return true + } + + // A stored entry that we cannot decrypt (e.g. a private channel we don't belong to) must not be + // overwritten. decryptChannelEntry throws here and validateEntry fails closed. + const storedChannel = this.decryptChannelEntry(stored as EncryptedAndSignedPayload, entry.payload.key!) + + if (storedChannel.owner !== writerId) { + this.logger.error( + 'Failed to validate channel entry: only the original owner may modify channel metadata:', + entry.hash, + { storedOwner: storedChannel.owner, writerId } + ) + return false + } + + if ((storedChannel.public ?? true) !== (decEntry.public ?? true)) { + this.logger.error('Failed to validate channel entry: channel public flag is immutable:', entry.hash, { + storedPublic: storedChannel.public, + newPublic: decEntry.public, + }) + return false + } + + if ((storedChannel.roleName ?? null) !== (decEntry.roleName ?? null)) { + this.logger.error('Failed to validate channel entry: channel role is immutable:', entry.hash, { + storedRoleName: storedChannel.roleName, + newRoleName: decEntry.roleName, + }) + return false + } + + return true + } + + private async validateChannelDeleteEntry(entry: LogEntry): Promise { + const key = entry.payload.key + if (!key) { + this.logger.error('Delete channel entry is missing key:', entry.hash) + return false + } + + const chain = this.sigchainService.getActiveChain(false) + if (chain == null) { + this.logger.error('Cannot validate delete channel entry without an active chain:', entry.hash) + return false + } + + const writerIdentity = await this.getVerifiedChannelEntryWriter(entry, 'DEL') + if (writerIdentity == null) { + return false + } + + if (writerIdentity.teamId !== chain.team!.id) { + this.logger.error( + 'Failed to validate delete channel entry: entry identity team must match active chain:', + entry.hash, + { + entryTeamId: writerIdentity.teamId, + activeTeamId: chain.team!.id, + } + ) + return false + } + + if (!chain.roles.memberIsAdmin(writerIdentity.id)) { + this.logger.error('Failed to validate delete channel entry: writer must be a sigchain admin:', entry.hash, { + writerId: writerIdentity.id, + }) + return false + } + + return true + } + /** * Validates a log entry in the OrbitDB store. * @param entry The log entry to validate. @@ -261,10 +581,12 @@ export class ChannelsService extends EventEmitter { this.logger.error('Decrypted channel entry is not a valid channel:', entry.hash, decEntry) return false } + if (!(await this.validateChannelEntryMetadata(entry, encPayload, decEntry))) { + return false + } } if (entry.payload.op === 'DEL') { - if (!entry.payload.key) { - this.logger.error('Delete channel entry is missing key:', entry.hash) + if (!(await this.validateChannelDeleteEntry(entry))) { return false } } @@ -465,6 +787,12 @@ export class ChannelsService extends EventEmitter { public: payload.public ?? true, teamId: payload.teamId, } + // Self-defense: the channel id must commit to us (the owner) or our own validateEntry would + // reject the resulting entry network-wide. Fail fast before creating a role/store. + if (!verifyChannelIdOwner(channelData.id, channelData.owner)) { + this.logger.error('Refusing to create channel: id is not bound to the owner:', channelData.id) + return { status: ChannelOperationStatus.FAILED } + } let roleName: string | undefined = undefined if (!(channelData.public ?? true)) { roleName = this.sigchainService.getActiveChain().channels.create(channelData.id) diff --git a/packages/common/src/channelAddress.test.ts b/packages/common/src/channelAddress.test.ts index be47efdeb6..492358db21 100644 --- a/packages/common/src/channelAddress.test.ts +++ b/packages/common/src/channelAddress.test.ts @@ -1,24 +1,62 @@ -import { generateChannelId, getChannelNameFromChannelId } from './channelAddress' +import { + generateChannelId, + getChannelNameFromChannelId, + isBoundChannelId, + parseBoundChannelId, + verifyChannelIdOwner, +} from './channelAddress' + +const ownerId = 'owner-user-id' describe('Generate Channel Id', () => { it('name "rockets" is the channel name', () => { - expect(generateChannelId('rockets')).toContain('rockets') + expect(generateChannelId('rockets', ownerId)).toContain('rockets') }) - it('Should include hexadecimals characters in a determined structure (name + _ + 16 hex)', () => { - const channelName = 'rockets' - const randomBytesLength = 32 // 16 chars in hex - const underscoreLength = 1 - const expectedLength = channelName.length + underscoreLength + randomBytesLength - expect(generateChannelId('rockets')).toHaveLength(expectedLength) + it('binds the owner into the id and verifies', () => { + const channelId = generateChannelId('rockets', ownerId) + expect(isBoundChannelId(channelId)).toBe(true) + expect(verifyChannelIdOwner(channelId, ownerId)).toBe(true) + }) + + it('does not verify against a different owner', () => { + const channelId = generateChannelId('rockets', ownerId) + expect(verifyChannelIdOwner(channelId, 'someone-else')).toBe(false) + }) + + it('produces the expected `${name}_${nonce}_${commitment}` structure', () => { + const channelId = generateChannelId('rockets', ownerId) + const parsed = parseBoundChannelId(channelId) + expect(parsed).not.toBeNull() + expect(parsed!.name).toEqual('rockets') + expect(parsed!.nonce).toMatch(/^[0-9a-f]{32}$/) + expect(parsed!.commitment).toMatch(/^[0-9a-f]{64}$/) + }) + + it('supports channel names containing underscores', () => { + const channelId = generateChannelId('my_cool_channel', ownerId) + expect(parseBoundChannelId(channelId)!.name).toEqual('my_cool_channel') + expect(verifyChannelIdOwner(channelId, ownerId)).toBe(true) + }) +}) + +describe('Bound vs legacy channel ids', () => { + it('treats legacy `name_` ids as unbound', () => { + const legacyId = 'rockets_1faff74afc8daff3256275ce89d30528' + expect(isBoundChannelId(legacyId)).toBe(false) + expect(verifyChannelIdOwner(legacyId, ownerId)).toBe(false) + expect(parseBoundChannelId(legacyId)).toBeNull() }) }) describe('Get Channel Name From Channel Id', () => { - it('Should return the channel name', () => { + it('returns the channel name for a bound id', () => { + const channelId = generateChannelId('rockets', ownerId) + expect(getChannelNameFromChannelId(channelId)).toEqual('rockets') + }) + it('returns the channel name for a legacy id', () => { const channelId = 'rockets_1faff74afc8daff3256275ce89d30528' - const channelName = 'rockets' - expect(getChannelNameFromChannelId(channelId)).toEqual(channelName) + expect(getChannelNameFromChannelId(channelId)).toEqual('rockets') }) it('Should return the channel id if does not match the structure', () => { const channelName = 'rockets' diff --git a/packages/common/src/channelAddress.ts b/packages/common/src/channelAddress.ts index cc67c5a893..0188e88188 100644 --- a/packages/common/src/channelAddress.ts +++ b/packages/common/src/channelAddress.ts @@ -1,8 +1,72 @@ import crypto from 'crypto' -export const generateChannelId = (channelName: string) => `${channelName}_${crypto.randomBytes(16).toString('hex')}` +// A channel id binds its creator's owner id into the id itself via a commitment, so that ownership +// can be validated statelessly (without consulting prior store state). The id has the shape: +// +// `${name}_${nonce}_${commitment}` where commitment = sha256(`${ownerId}:${nonce}`) +// +// `nonce` is 16 random bytes (32 hex chars) and `commitment` is a sha256 digest (64 hex chars). +// Because both trailing segments are fixed-length lowercase hex, they are unambiguous even when the +// channel name itself contains underscores. Hijacking an existing id with a different owner would +// require a sha256 second-preimage, so it is infeasible. +const NONCE_HEX_LENGTH = 32 +const COMMITMENT_HEX_LENGTH = 64 +const BOUND_CHANNEL_ID_REGEX = new RegExp(`^(.*)_([0-9a-f]{${NONCE_HEX_LENGTH}})_([0-9a-f]{${COMMITMENT_HEX_LENGTH}})$`) + +const channelOwnerCommitment = (ownerId: string, nonce: string): string => + crypto.createHash('sha256').update(`${ownerId}:${nonce}`).digest('hex') + +/** + * Generate a channel id. When `ownerId` is provided the id is bound to that owner so ownership can + * be validated statelessly (see `verifyChannelIdOwner`). When omitted, a legacy/unbound id is + * produced for backwards compatibility (and test fixtures). Production callers must always pass + * `ownerId`; the backend additionally rejects unbound ids at channel-creation time. + */ +export const generateChannelId = (channelName: string, ownerId?: string): string => { + const nonce = crypto.randomBytes(NONCE_HEX_LENGTH / 2).toString('hex') + if (ownerId == null) { + return `${channelName}_${nonce}` + } + const commitment = channelOwnerCommitment(ownerId, nonce) + return `${channelName}_${nonce}_${commitment}` +} + +export interface ParsedBoundChannelId { + name: string + nonce: string + commitment: string +} + +export const parseBoundChannelId = (channelId: string): ParsedBoundChannelId | null => { + const match = BOUND_CHANNEL_ID_REGEX.exec(channelId) + if (match == null) { + return null + } + return { name: match[1], nonce: match[2], commitment: match[3] } +} + +/** + * True for ids generated with owner binding (post-migration). Legacy ids (`name_`) return + * false and must be validated via the stateful fallback. + */ +export const isBoundChannelId = (channelId: string): boolean => BOUND_CHANNEL_ID_REGEX.test(channelId) + +/** + * Verify that a bound channel id commits to the given owner. Returns false for legacy/unbound ids. + */ +export const verifyChannelIdOwner = (channelId: string, ownerId: string): boolean => { + const parsed = parseBoundChannelId(channelId) + if (parsed == null) { + return false + } + return parsed.commitment === channelOwnerCommitment(ownerId, parsed.nonce) +} export const getChannelNameFromChannelId = (channelId: string) => { + const bound = parseBoundChannelId(channelId) + if (bound != null) { + return bound.name + } const index = channelId.indexOf('_') if (index === -1) { return channelId diff --git a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx index e92e2fb5bd..41329f8ff4 100644 --- a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx +++ b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx @@ -86,7 +86,7 @@ export const CreateChannel = () => { return } const payload = { - id: generateChannelId(name), + id: generateChannelId(name, user.userId), name: name, description: `Welcome to #${name}`, public: isPublic, diff --git a/packages/mobile/src/screens/CreateChannel/CreateChannel.screen.tsx b/packages/mobile/src/screens/CreateChannel/CreateChannel.screen.tsx index 38075d0c2a..748c8186be 100644 --- a/packages/mobile/src/screens/CreateChannel/CreateChannel.screen.tsx +++ b/packages/mobile/src/screens/CreateChannel/CreateChannel.screen.tsx @@ -85,7 +85,7 @@ export const CreateChannelScreen: FC = () => { ) return } - const id = generateChannelId(name) + const id = generateChannelId(name, user.userId) setChannel({ channelId: id, channelName: name }) diff --git a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.ts b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.ts index 7661d8ecc7..fa948521dc 100644 --- a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.ts +++ b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.ts @@ -4,18 +4,26 @@ import { generateChannelId } from '@quiet/common' import { createLogger } from '../../../utils/logger' import { CreateChannelPayload } from '@quiet/types' import { communities } from '../../..' +import { identitySelectors } from '../../identity/identity.selectors' const logger = createLogger('createGeneralChannelSaga') export function* createGeneralChannelSaga(): Generator { - const id = yield* call(generateChannelId, 'general') const community = yield* select(communities.selectors.currentCommunity) + const identity = yield* select(identitySelectors.currentIdentity) if (community == null || community.teamId == null) { logger.error('Community must be initialized before creating general channel') return } + if (identity == null || identity.userId == null) { + logger.error('Identity must be initialized before creating general channel') + return + } + + const id = yield* call(generateChannelId, 'general', identity.userId) + yield* put( publicChannelsActions.createChannel({ id: id,