Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* Self-assign the member role when joining with QSS [#3058](https://github.com/TryQuiet/quiet/issues/3058)
* Use LFA-based identity in OrbitDB
* Requests iOS notification permissions when app launches [#3079](https://github.com/TryQuiet/quiet/issues/3079)
* Store LFA keys in IOS keychain for notifications [#3091](https://github.com/TryQuiet/quiet/issues/3091)

### Fixes

Expand Down
19 changes: 18 additions & 1 deletion packages/backend/src/nest/auth/services/crypto/crypto.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import {
import { ChainServiceBase } from '../chainServiceBase'
import { SigChain } from '../../sigchain'
import { asymmetric, Keyset, Member, SignedEnvelope, EncryptStreamTeamPayload } from '@localfirst/auth'
import { KeyMap } from '@localfirst/auth/team/selectors'
import { DEFAULT_SEARCH_OPTIONS, MemberSearchOptions } from '../members/types'
import { createLogger } from '../../../common/logger'
import { KeyMetadata } from '3rd-party/auth/packages/crdx/dist'
import { KeyMetadata } from '@localfirst/crdx'

const logger = createLogger('auth:cryptoService')

Expand All @@ -36,6 +37,22 @@ class CryptoService extends ChainServiceBase {
})
}

public getPublicKeysForAllMembers(includeSelf: boolean = false): Keyset[] {
const members = this.sigChain.users.getAllUsers()
const keysByMember = []
for (const member of members) {
if (member.userId === this.sigChain.context.user.userId && !includeSelf) {
continue
}
keysByMember.push(member.keys)
}
return keysByMember
}

public getAllKeys(): KeyMap {
return this.sigChain.team!.allKeys()
}

public sign(message: any): SignedEnvelope {
return this.sigChain.team!.sign(message)
}
Expand Down
135 changes: 118 additions & 17 deletions packages/backend/src/nest/auth/sigchain.service.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common'
import { Inject, Injectable } from '@nestjs/common'
import { SigChain } from './sigchain'
import { Connection, InviteeMemberContext, Keyring, LocalUserContext, MemberContext, Team } from '@localfirst/auth'
import {
Connection,
Hash,
InviteeMemberContext,
Keyring,
LocalUserContext,
MemberContext,
Team,
UserWithSecrets,
DeviceWithSecrets,
} from '@localfirst/auth'
import { KeyMetadata } from '@localfirst/crdx'
import { LocalDbService } from '../local-db/local-db.service'
import { createLogger } from '../common/logger'
import { SocketService } from '../socket/socket.service'
import { SocketEvents, User } from '@quiet/types'
import { SocketEvents, StorableKey, User } from '@quiet/types'
import { type RoleService } from './services/roles/role.service'
import { type DeviceService } from './services/members/device.service'
import { type InviteService } from './services/invites/invite.service'
import { type UserService } from './services/members/user.service'
import { type CryptoService } from './services/crypto/crypto.service'
import { type UserWithSecrets } from '@localfirst/auth'
import { type DeviceWithSecrets } from '@localfirst/auth'
import { SERVER_IO_PROVIDER } from '../const'
import { ServerIoProviderTypes } from '../types'
import EventEmitter from 'events'
import { GetChainFilter } from './types'
import { GetChainFilter, StoredKeyType } from './types'
import { KeysUpdatedEvent } from '@quiet/types'

@Injectable()
export class SigChainService extends EventEmitter {
Expand All @@ -26,8 +35,7 @@ export class SigChainService extends EventEmitter {

constructor(
@Inject(SERVER_IO_PROVIDER) public readonly serverIoProvider: ServerIoProviderTypes,
private readonly localDbService: LocalDbService,
private readonly socketService: SocketService
private readonly localDbService: LocalDbService
) {
super()
}
Expand Down Expand Up @@ -132,29 +140,121 @@ export class SigChainService extends EventEmitter {
this.attachSocketListeners(this.getChain({ teamName }))
}

private handleChainUpdate = () => {
const users = this.getActiveChain()
private handleChainUpdate = (teamName: string) => {
this._updateUsersOnChainUpdate(teamName)
this._updateKeysOnChainUpdate(teamName)
this.emit('updated', teamName)
this.saveChain(teamName)
this.logger.info('Chain updated, emitted updated event')
}

/**
* Send updated list of users to the state manager on chain update
*/
private _updateUsersOnChainUpdate(teamName: string) {
const users = this.getChain({ teamName })
.team?.members()
.map(user => ({
userId: user.userId,
roles: user.roles,
isRegistered: true,
isDuplicated: false,
})) as User[]
this.socketService.emit(SocketEvents.USERS_UPDATED, { users })
this.emit('updated')
this.saveChain(this.activeChainTeamName!)
this.logger.info('Chain updated, emitted updated event')
this.serverIoProvider.io.emit(SocketEvents.USERS_UPDATED, { users })
}

/**
* Update the IOS keychain with any new keys on chain update
*/
private async _updateKeysOnChainUpdate(teamName: string): Promise<void> {
if ((process.platform as string) !== 'ios') {
this.logger.trace('Skipping key update because we are not on ios, current platform =', process.platform)
return
}

const generateKeyName = (teamId: string, keyType: string, scope: KeyMetadata): string => {
return `quiet_${teamId}_${scope.type}_${scope.name}_${scope.generation}_${keyType}`
}

const sigchain = this.getChain({ teamName })
if (sigchain == null) {
this.logger.error('No chain for name found', teamName)
return
}

const teamId = sigchain.team!.id
const alreadySentKeys: Set<string> = new Set(await this.localDbService.getKeysStoredInKeychain(teamId))
const keysToSend: StorableKey[] = []
const keyNamesSent: string[] = []
// get all secret keys that this user has that haven't been added to the keychain
const allKeys = sigchain.crypto.getAllKeys()
for (const keyData of Object.values(allKeys)) {
for (const keyTypeData of Object.values(keyData)) {
for (const keyTypeGenData of Object.values(keyTypeData)) {
const keyName = generateKeyName(teamId, StoredKeyType.SECRET, {
name: keyTypeGenData.name,
type: keyTypeGenData.type,
generation: keyTypeGenData.generation,
})
if (!alreadySentKeys.has(keyName)) {
keysToSend.push({ key: keyTypeGenData.secretKey, keyName })
keyNamesSent.push(keyName)
}
}
}
}
// TODO: update to pull all generations of user public/sig keys
// get all user public keys that haven't been added to the keychain
const allUserPublicKeys = sigchain.crypto.getPublicKeysForAllMembers(true)
for (const keySet of allUserPublicKeys) {
const publicKeyName = generateKeyName(teamId, StoredKeyType.USER_PUBLIC, {
name: keySet.name,
type: keySet.type,
generation: keySet.generation,
})
if (!alreadySentKeys.has(publicKeyName)) {
keysToSend.push({ key: keySet.encryption, keyName: publicKeyName })
keyNamesSent.push(publicKeyName)
}

const sigKeyName = generateKeyName(teamId, StoredKeyType.USER_SIG, {
name: keySet.name,
type: keySet.type,
generation: keySet.generation,
})
if (!alreadySentKeys.has(sigKeyName)) {
keysToSend.push({ key: keySet.signature, keyName: sigKeyName })
keyNamesSent.push(sigKeyName)
}
}

if (keysToSend.length === 0) {
this.logger.trace('Skipping IOS keychain update, no new keys')
return
}

// send new keys to the state manager to add to the keychain and update list of key names in
const keyUpdateEvent: KeysUpdatedEvent = {
keys: keysToSend,
}
await this.localDbService.updateKeysStoredInKeychain(teamId, keyNamesSent)
this.serverIoProvider.io.emit(SocketEvents.KEYS_UPDATED, keyUpdateEvent)
}

private attachSocketListeners(chain: SigChain): void {
this.logger.info('Attaching socket listeners')
chain.on('updated', this.handleChainUpdate)
const _onTeamUpdate = (): void => {
this.handleChainUpdate(chain.team!.teamName)
}
chain.on('updated', _onTeamUpdate)
}

private detachSocketListeners(chain: SigChain): void {
this.logger.info('Detaching socket listeners')
chain.removeListener('updated', this.handleChainUpdate)
const _onTeamUpdate = (): void => {
this.handleChainUpdate(chain.team!.teamName)
}
chain.removeListener('updated', _onTeamUpdate)
}

/**
Expand Down Expand Up @@ -208,6 +308,7 @@ export class SigChainService extends EventEmitter {
const sigChain = SigChain.create(teamName, username)
this.addChain(sigChain, setActive, teamName)
await this.saveChain(teamName)
this.handleChainUpdate(teamName)
return sigChain
}

Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/nest/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ export type GetChainFilter = {
teamId?: string
teamName?: string
}

export enum StoredKeyType {
SECRET = 'secret',
USER_PUBLIC = 'userPublic',
USER_SIG = 'userSig',
}
25 changes: 25 additions & 0 deletions packages/backend/src/nest/local-db/local-db.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,4 +605,29 @@ export class LocalDbService extends EventEmitter {
}
return count
}

/**
* Update list of kys for a given team ID that were stored in the IOS keychain
*
* @param teamId LFA team ID
* @param keyNames Names of keys that were added to IOS keychain
*/
public async updateKeysStoredInKeychain(teamId: string, keyNames: string[]): Promise<void> {
const key = `${LocalDBKeys.KEYS_STORED_KEYCHAIN}:${teamId}`
const arr: string[] = (await this.get(key)) || []
arr.push(...keyNames)
await this.put(key, arr)
}

/**
* Get the list of key names for a given team ID that have been stored in the IOS keychain
*
* @param teamId LFA team ID
* @returns List of key names
*/
public async getKeysStoredInKeychain(teamId: string): Promise<string[]> {
const key = `${LocalDBKeys.KEYS_STORED_KEYCHAIN}:${teamId}`
const arr: string[] = (await this.get(key)) || []
return arr
}
}
3 changes: 3 additions & 0 deletions packages/backend/src/nest/local-db/local-db.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export enum LocalDBKeys {
// exists in the Community object.
OWNER_ORBIT_DB_IDENTITY = 'ownerOrbitDbIdentity',

// Keys from sigchain that have been stored in keychain
KEYS_STORED_KEYCHAIN = 'keysStoredInKeychain',

SIGCHAINS = 'sigchains:',
USER_CONTEXTS = 'userContexts',
KEYRINGS = 'keyrings',
Expand Down
8 changes: 4 additions & 4 deletions packages/backend/src/nest/qss/qss.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,7 @@ describe('QSSService', () => {
const ingestSpy = jest.spyOn(orbitDbService, 'ingestEntries').mockResolvedValue()

// Trigger sigchain update which should process DLQ
sigchainService.emit('updated')
sigchainService.emit('updated', sigchainService.activeChainTeamName)

// Wait for async processing
await waitForExpect(async () => {
Expand Down Expand Up @@ -1071,10 +1071,10 @@ describe('QSSService', () => {
const processSpy = jest.spyOn(qssService, 'processDLQDecrypt')

// Trigger first update
sigchainService.emit('updated')
sigchainService.emit('updated', sigchainService.activeChainTeamName)

// Immediately trigger second update while first is processing
sigchainService.emit('updated')
sigchainService.emit('updated', sigchainService.activeChainTeamName)

await waitForExpect(async () => {
const remainingCount = await localDbService.getDLQDecryptCount(teamId)
Expand All @@ -1097,7 +1097,7 @@ describe('QSSService', () => {
const ingestSpy = jest.spyOn(orbitDbService, 'ingestEntries')

// Trigger sigchain update
sigchainService.emit('updated')
sigchainService.emit('updated', sigchainService.activeChainTeamName)

// Give it time to process
await new Promise(resolve => setTimeout(resolve, 100))
Expand Down
8 changes: 4 additions & 4 deletions packages/backend/src/nest/qss/qss.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul
this._deadLetterQueueProcessor = setInterval(this.processDeadLetterQueue, 30_000)
this.connect = this.connect.bind(this)
this._configureEventHandlers()
this.sigChainService.on('updated', () => void this.processDLQDecrypt())
this.sigChainService.on('updated', (teamName: string) => void this.processDLQDecrypt(teamName))
}

public onModuleDestroy() {
Expand Down Expand Up @@ -907,14 +907,14 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul
/**
* Process the decryption dead letter queue when sigchain updates (new keys arrive)
*/
private async processDLQDecrypt(): Promise<void> {
private async processDLQDecrypt(teamName: string): Promise<void> {
if (this._dlqDecryptInFlight) {
this.logger.debug('DLQ decrypt already in progress, requesting retry')
this._dlqDecryptRetryRequested = true
return
}

const activeChain = this.sigChainService.getActiveChain(false)
const activeChain = this.sigChainService.getChain({ teamName })
if (!activeChain?.team) {
return
}
Expand Down Expand Up @@ -981,7 +981,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul
// If a sigchain update occurred while processing, retry with new keys
if (this._dlqDecryptRetryRequested) {
this.logger.debug('Retrying DLQ decrypt after sigchain update during processing')
await this.processDLQDecrypt()
await this.processDLQDecrypt(teamName)
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/mobile/ios/CommunicationBridge.m
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ @interface RCT_EXTERN_MODULE(CommunicationModule, RCTEventEmitter)
RCT_EXTERN_METHOD(handleIncomingEvents:(NSString *)event payload:(NSString *)payload extra:(NSString *)extra)
RCT_EXTERN_METHOD(requestNotificationPermission)
RCT_EXTERN_METHOD(checkNotificationPermission)
RCT_EXTERN_METHOD(saveKeysInKeychain:(NSArray *)newKeys)
@end
22 changes: 22 additions & 0 deletions packages/mobile/ios/CommunicationModule.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import UserNotifications
import OSLog

@objc(CommunicationModule)
class CommunicationModule: RCTEventEmitter {
Expand All @@ -12,6 +13,9 @@ class CommunicationModule: RCTEventEmitter {
static let DEVICE_TOKEN_RECEIVED = "deviceTokenReceived"

static let WEBSOCKET_CONNECTION_CHANNEL = "_WEBSOCKET_CONNECTION_"
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "CommunicationModule")

let keychainHandler = KeychainHandler()

@objc
func sendDataPort(port: UInt16, socketIOSecret: String) {
Expand Down Expand Up @@ -55,6 +59,24 @@ class CommunicationModule: RCTEventEmitter {
}
}
}

@objc
func saveKeysInKeychain(_ newKeys: NSArray) {
let decoder = JSONDecoder()
for keyAsAny in newKeys {
do {
let keyAsString: String = keyAsAny as! String
let data = Data(keyAsString.utf8)
let decodedNamedKey = try decoder.decode(NamedKey.self, from: data)
try self.keychainHandler.addLfaKey(namedKey: decodedNamedKey)
let stored = try self.keychainHandler.getLfaKeyString(keyName: decodedNamedKey.keyName)
CommunicationModule.logger.info("Stored key matches? \(stored == decodedNamedKey.key) \(decodedNamedKey.keyName)")
} catch {
// TODO: send a message to the backend with any keys that weren't stored
CommunicationModule.logger.error("Error while saving key in keychain: \(error)")
}
}
}

@objc
func checkNotificationPermission() {
Expand Down
Loading
Loading