Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(2631): Add e2e encryption/decryption to messages #2733

Merged
merged 21 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
43 changes: 33 additions & 10 deletions packages/backend/src/nest/auth/services/crypto/crypto.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
*/
import * as bs58 from 'bs58'

import { EncryptedAndSignedPayload, EncryptedPayload, EncryptionScope, EncryptionScopeType } from './types'
import {
DecryptedPayload,
EncryptedAndSignedPayload,
EncryptedPayload,
EncryptionScope,
EncryptionScopeType,
} from './types'
import { ChainServiceBase } from '../chainServiceBase'
import { SigChain } from '../../sigchain'
import { asymmetric, Base58, Keyset, LocalUserContext, Member, SignedEnvelope } from '@localfirst/auth'
Expand Down Expand Up @@ -96,28 +102,45 @@ class CryptoService extends ChainServiceBase {
}
}

public decryptAndVerify(encrypted: EncryptedPayload, signature: SignedEnvelope, context: LocalUserContext): any {
const isValid = this.sigChain.team!.verify(signature)
if (!isValid) {
public decryptAndVerify<T>(
encrypted: EncryptedPayload,
signature: SignedEnvelope,
context: LocalUserContext,
failOnInvalid = true
): DecryptedPayload<T> {
const isValid = this.verifyMessage(signature)
if (!isValid && failOnInvalid) {
throw new Error(`Couldn't verify signature on message`)
}

let contents: T
switch (encrypted.scope.type) {
// Symmetrical Encryption Types
case EncryptionScopeType.CHANNEL:
case EncryptionScopeType.ROLE:
case EncryptionScopeType.TEAM:
return this.symDecrypt(encrypted)
contents = this.symDecrypt<T>(encrypted)
break
// Asymmetrical Encryption Types
case EncryptionScopeType.USER:
return this.asymUserDecrypt(encrypted, signature, context)
contents = this.asymUserDecrypt<T>(encrypted, signature, context)
break
// Unknown Type
default:
throw new Error(`Unknown encryption scope type ${encrypted.scope.type}`)
}

return {
contents,
isValid,
}
}

private symDecrypt(encrypted: EncryptedPayload): any {
public verifyMessage(signature: SignedEnvelope): boolean {
return this.sigChain.team!.verify(signature)
}

private symDecrypt<T>(encrypted: EncryptedPayload): T {
if (encrypted.scope.type !== EncryptionScopeType.TEAM && encrypted.scope.name == null) {
throw new Error(`Must provide a scope name when encryption scope is set to ${encrypted.scope.type}`)
}
Expand All @@ -129,10 +152,10 @@ class CryptoService extends ChainServiceBase {
// you don't need a name on the scope when encrypting but you need one for decrypting because of how LFA searches for keys in lockboxes
name: encrypted.scope.type === EncryptionScopeType.TEAM ? EncryptionScopeType.TEAM : encrypted.scope.name!,
},
})
}) as T
}

private asymUserDecrypt(encrypted: EncryptedPayload, signature: SignedEnvelope, context: LocalUserContext): any {
private asymUserDecrypt<T>(encrypted: EncryptedPayload, signature: SignedEnvelope, context: LocalUserContext): T {
if (encrypted.scope.name == null) {
throw new Error(`Must provide a user ID when encryption scope is set to ${encrypted.scope.type}`)
}
Expand All @@ -145,7 +168,7 @@ class CryptoService extends ChainServiceBase {
cipher: encrypted.contents,
senderPublicKey: senderKey,
recipientSecretKey: recipientKey,
})
}) as T
}
}

Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/nest/auth/services/crypto/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,8 @@ export type EncryptedAndSignedPayload = {
ts: number
username: string
}

export type DecryptedPayload<T> = {
contents: T
isValid: boolean
}
1 change: 1 addition & 0 deletions packages/backend/src/nest/auth/sigchain.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class SigChainService implements OnModuleInit {
this.setActiveChain(teamName)
return true
}

return false
}

Expand Down
6 changes: 3 additions & 3 deletions packages/backend/src/nest/storage/base.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ export abstract class KeyValueStoreBase<V> extends StoreBase<V, KeyValueType<V>>
abstract getEntry(key?: string): Promise<V | null>
}

export abstract class EventStoreBase<V> extends StoreBase<V, EventsType<V>> {
export abstract class EventStoreBase<V, U = V> extends StoreBase<V, EventsType<V>> {
protected store: EventsType<V> | undefined
abstract addEntry(value: V): Promise<string>
abstract getEntries(): Promise<V[]>
abstract addEntry(value: U): Promise<string>
abstract getEntries(): Promise<U[]>
}
78 changes: 56 additions & 22 deletions packages/backend/src/nest/storage/channels/channel.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { QuietLogger } from '@quiet/logger'
import {
ChannelMessage,
CompoundError,
ConsumedChannelMessage,
MessagesLoadedPayload,
PublicChannel,
PushNotificationPayload,
Expand All @@ -14,19 +15,20 @@ import {
import { createLogger } from '../../common/logger'
import { EventStoreBase } from '../base.store'
import { EventsWithStorage } from '../orbitDb/eventsWithStorage'
import { MessagesAccessController } from '../orbitDb/MessagesAccessController'
import { MessagesAccessController } from './messages/orbitdb/MessagesAccessController'
import { OrbitDbService } from '../orbitDb/orbitDb.service'
import validate from '../../validation/validators'
import { MessagesService } from './messages/messages.service'
import { DBOptions, StorageEvents } from '../storage.types'
import { LocalDbService } from '../../local-db/local-db.service'
import { CertificatesStore } from '../certificates/certificates.store'
import { EncryptedMessage } from './messages/messages.types'

/**
* Manages storage-level logic for a given channel in Quiet
*/
@Injectable()
export class ChannelStore extends EventStoreBase<ChannelMessage> {
export class ChannelStore extends EventStoreBase<EncryptedMessage, ConsumedChannelMessage> {
private channelData: PublicChannel
private _subscribing: boolean = false

Expand Down Expand Up @@ -60,12 +62,15 @@ export class ChannelStore extends EventStoreBase<ChannelMessage> {
this.logger = createLogger(`storage:channels:channelStore:${this.channelData.name}`)
this.logger.info(`Initializing channel store for channel ${this.channelData.name}`)

this.store = await this.orbitDbService.orbitDb.open<EventsType<ChannelMessage>>(`channels.${this.channelData.id}`, {
type: 'events',
Database: EventsWithStorage(),
AccessController: MessagesAccessController({ write: ['*'] }),
sync: options.sync,
})
this.store = await this.orbitDbService.orbitDb.open<EventsType<EncryptedMessage>>(
`channels.${this.channelData.id}`,
{
type: 'events',
Database: EventsWithStorage(),
AccessController: MessagesAccessController({ write: ['*'], messagesService: this.messagesService }),
sync: options.sync,
}
)

this.logger.info('Initialized')
return this
Expand Down Expand Up @@ -95,10 +100,14 @@ export class ChannelStore extends EventStoreBase<ChannelMessage> {
this.logger.info('Subscribing to channel ', this.channelData.id)
this._subscribing = true

this.getStore().events.on('update', async (entry: LogEntry<ChannelMessage>) => {
this.getStore().events.on('update', async (entry: LogEntry<EncryptedMessage>) => {
this.logger.info(`${this.channelData.id} database updated`, entry.hash, entry.payload.value?.channelId)

const message = await this.messagesService.onConsume(entry.payload.value!)
if (message == null) {
this.logger.error(`Couldn't consume message ${entry.payload.value!.id}`)
return
}

this.emit(StorageEvents.MESSAGES_STORED, {
messages: [message],
Expand Down Expand Up @@ -147,25 +156,28 @@ export class ChannelStore extends EventStoreBase<ChannelMessage> {
*
* @param message Message to add to the OrbitDB database
*/
public async sendMessage(message: ChannelMessage): Promise<void> {
public async sendMessage(message: ChannelMessage): Promise<boolean> {
this.logger.info(`Sending message with ID ${message.id} on channel ${this.channelData.id}`)
if (!validate.isMessage(message)) {
this.logger.error('Public channel message is invalid')
return
return false
}

if (message.channelId != this.channelData.id) {
this.logger.error(
`Could not send message. Message is for channel ID ${message.channelId} which does not match channel ID ${this.channelData.id}`
)
return
return false
}

try {
await this.addEntry(message)
return true
} catch (e) {
this.logger.error(`Could not append message (entry not allowed to write to the log). Details: ${e.message}`)
this.logger.error(`Error while sending message`, e)
}

return false
}

/**
Expand Down Expand Up @@ -219,23 +231,45 @@ export class ChannelStore extends EventStoreBase<ChannelMessage> {
}

/**
* Read a list of entries on the OrbitDB event store
* Read a list of entries on the OrbitDB event store and decrypt
*
* @param ids Optional list of message IDs to filter by
* @returns All matching entries on the event store
*/
public async getEntries(): Promise<ChannelMessage[]>
public async getEntries(ids: string[] | undefined): Promise<ChannelMessage[]>
public async getEntries(ids?: string[] | undefined): Promise<ChannelMessage[]> {
public async getEntries(): Promise<ConsumedChannelMessage[]>
public async getEntries(ids: string[] | undefined): Promise<ConsumedChannelMessage[]>
public async getEntries(ids?: string[] | undefined): Promise<ConsumedChannelMessage[]> {
this.logger.info(`Getting all messages for channel`, this.channelData.id, this.channelData.name)
const messages: ChannelMessage[] = []
const messages: ConsumedChannelMessage[] = []

for await (const x of this.getStore().iterator()) {
if (ids == null || ids?.includes(x.value.id)) {
const decryptedMessage = await this.messagesService.onConsume(x.value)
if (decryptedMessage == null) {
continue
}
messages.push(decryptedMessage)
}
}

return messages
}

/**
* Read a list of entries on the OrbitDB event store without decrypting
*
* @param ids Optional list of message IDs to filter by
* @returns All matching entries on the event store
*/
public async getEncryptedEntries(): Promise<EncryptedMessage[]>
public async getEncryptedEntries(ids: string[] | undefined): Promise<EncryptedMessage[]>
public async getEncryptedEntries(ids?: string[] | undefined): Promise<EncryptedMessage[]> {
this.logger.info(`Getting all encrypted messages for channel`, this.channelData.id, this.channelData.name)
const messages: EncryptedMessage[] = []

for await (const x of this.getStore().iterator()) {
if (ids == null || ids?.includes(x.value.id)) {
// NOTE: we skipped the verification process when reading many messages in the previous version
// so I'm skipping it here - is that really the correct behavior?
const processedMessage = await this.messagesService.onConsume(x.value, false)
messages.push(processedMessage)
messages.push(x.value)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { LocalDbModule } from '../../local-db/local-db.module'
import { LocalDbService } from '../../local-db/local-db.service'
import { createLogger } from '../../common/logger'
import { ChannelsService } from './channels.service'
import { SigChainService } from '../../auth/sigchain.service'

const logger = createLogger('channelsService:test')

Expand All @@ -51,6 +52,7 @@ describe('ChannelsService', () => {
let libp2pService: Libp2pService
let localDbService: LocalDbService
let channelsService: ChannelsService
let sigChainService: SigChainService
let peerId: PeerId

let store: Store
Expand Down Expand Up @@ -106,6 +108,7 @@ describe('ChannelsService', () => {
localDbService = await module.resolve(LocalDbService)
libp2pService = await module.resolve(Libp2pService)
ipfsService = await module.resolve(IpfsService)
sigChainService = await module.resolve(SigChainService)

const params = await libp2pInstanceParams()
peerId = params.peerId.peerId
Expand All @@ -119,6 +122,8 @@ describe('ChannelsService', () => {
await localDbService.setCommunity(community)
await localDbService.setCurrentCommunityId(community.id)

await sigChainService.createChain(community.name!, alice.nickname, true)

await storageService.init(peerId)
})

Expand Down Expand Up @@ -174,10 +179,23 @@ describe('ChannelsService', () => {
expect(eventSpy).toHaveBeenCalled()
const savedMessages = await channelsService.getMessages(channelio.id)
expect(savedMessages?.messages.length).toBe(1)
expect(savedMessages?.messages[0]).toEqual({ ...messageCopy, verified: true })
expect(savedMessages?.messages[0]).toEqual({
...messageCopy,
verified: true,
encSignature: expect.objectContaining({
author: {
generation: 0,
type: 'USER',
name: sigChainService.getActiveChain().localUserContext.user.userId,
},
contents: expect.any(String),
signature: expect.any(String),
}),
})
})

it('is not saved to db if did not pass signature verification', async () => {
// TODO: figure out a good way to spoof the signature
it.skip('is not saved to db if did not pass signature verification', async () => {
const aliceMessage = await factory.create<ReturnType<typeof publicChannels.actions.test_message>['payload']>(
'Message',
{
Expand Down
Loading
Loading