Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* 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)
* Store user metadata in IOS native storage 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',
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
SetUserProfilePayload,
InvitationData,
SetUserProfileResponse,
UserProfilesUpdatedPayload,
} from '@quiet/types'
import { CONFIG_OPTIONS, QSS_ALLOWED, QSS_ENDPOINT, SERVER_IO_PROVIDER, SOCKS_PROXY_AGENT } from '../const'
import { Libp2pService, Libp2pState } from '../libp2p/libp2p.service'
Expand Down Expand Up @@ -809,6 +810,11 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
}
)

this.socketService.on(SocketActions.USER_PROFILES_UPDATED, (payload: UserProfilesUpdatedPayload) => {
this.logger.info(`Forwarding ${SocketActions.USER_PROFILES_UPDATED} back to state manager`)
this.serverIoProvider.io.emit(SocketEvents.USER_PROFILES_UPDATED, payload)
})

this.socketService.on(SocketActions.TOGGLE_P2P, async (payload: boolean, callback: (response: boolean) => void) => {
try {
if (payload) {
Expand Down
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
6 changes: 6 additions & 0 deletions packages/backend/src/nest/socket/socket.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
SetUserProfilePayload,
type HCaptchaFormResponse,
InviteResultWithSalt,
UserProfilesUpdatedPayload,
} from '@quiet/types'
import EventEmitter from 'events'
import { CONFIG_OPTIONS, SERVER_IO_PROVIDER } from '../const'
Expand Down Expand Up @@ -199,6 +200,11 @@ export class SocketService extends EventEmitter implements OnModuleInit {
}
)

socket.on(SocketActions.USER_PROFILES_UPDATED, (payload: UserProfilesUpdatedPayload) => {
this.logger.info(`Emitting ${SocketActions.USER_PROFILES_UPDATED}`)
this.emit(SocketActions.USER_PROFILES_UPDATED, payload)
})

// ====== Local First Auth ======

socket.on(
Expand Down
2 changes: 2 additions & 0 deletions packages/mobile/ios/CommunicationBridge.m
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ @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)
RCT_EXTERN_METHOD(saveUserMetadata:(NSArray *)updatedMetadata)
@end
Loading
Loading