Skip to content

Commit

Permalink
feat(2631): Add e2e encryption/decryption to messages (#2733)
Browse files Browse the repository at this point in the history
* Publish

 - @quiet/[email protected]
 - @quiet/[email protected]

* Update packages CHANGELOG.md

* Update CHANGELOG.md

* Add encryption and decryption of messages

* Allow messages with unknown signatures or undecryptable contents to be stored in orbitdb

* Fixes for compatibility with 2629

* chore(2722): Slack notifier on release workflows (#2724)

* Add slack notifications on release outcome

* Update CHANGELOG.md

* fix(2726): Use base64url package for encoding/decoding auth data since the native decoder breaks on mobile (#2728)

* Fix/non-admin-invite-links (#2730)

* handle permissions errors during invite creation and display appropriate screens when non-admins try to admit users

* handle slow generating links and admin only features in mobile

* update changelog

* make import relative

* Publish

 - @quiet/[email protected]
 - @quiet/[email protected]

* Update packages CHANGELOG.md

* Fix/minor fixes (#2735)

* User correct output variable for version on linux build

* Update language on non-admin invite pages

* Fix version outputs on everything else

* Publish

 - @quiet/[email protected]
 - @quiet/[email protected]

* Update packages CHANGELOG.md

* Revert "Merge branch '4.0.0' into feat/2631-message-encrypt-decrypt"

This reverts commit b56651f, reversing
changes made to e26c287.

* Fix test

* Use member key and not team key for messages

* Fix encryption test

* Add getEncryptedEntries method to channel store

---------

Co-authored-by: Taea <[email protected]>
  • Loading branch information
islathehut and adrastaea committed Feb 19, 2025
1 parent a877627 commit 365076d
Show file tree
Hide file tree
Showing 13 changed files with 267 additions and 115 deletions.
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[]>
}
73 changes: 51 additions & 22 deletions packages/backend/src/nest/storage/channels/channel.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,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 @@ -61,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(true),
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 @@ -96,7 +100,7 @@ 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)
let message: ChannelMessage | undefined = undefined
if (entry.payload.value == null) {
Expand Down Expand Up @@ -166,25 +170,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 @@ -238,16 +245,16 @@ 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 (x.value == null) {
Expand All @@ -256,10 +263,32 @@ export class ChannelStore extends EventStoreBase<ChannelMessage> {
}

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)
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)) {
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

0 comments on commit 365076d

Please sign in to comment.