Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
54df79c
add NSE target and add keychain capability to NSE and Quiet
islathehut Feb 18, 2026
97f1731
Pass keys from backend to state manager and write to keychain
islathehut Feb 25, 2026
7700b4f
Properly store keys in ios keychain
islathehut Mar 4, 2026
76565d7
Don't store in state manager, simplify model that is sent to frontend…
islathehut Mar 4, 2026
12d20d2
Add comments
islathehut Mar 5, 2026
c4a2a23
Update CommunicationModule.swift
islathehut Mar 5, 2026
bb1e67e
Merge branch 'develop' into feat/3091-store-sigchain-data-on-keychain
islathehut Mar 5, 2026
62978af
Update KeychainHandler.swift
islathehut Mar 5, 2026
10f5549
Update CHANGELOG.md
islathehut Mar 5, 2026
dcc4bfc
Pass actual team name since updates can happen when creating a chain …
islathehut Mar 5, 2026
9689afe
Merge branch 'develop' into feat/3091-store-sigchain-data-on-keychain
islathehut Mar 10, 2026
406ef6b
Move update user profiles to a separate saga
islathehut Mar 11, 2026
f9391fc
Pass updated user profiles to native ios code
islathehut Mar 11, 2026
1497029
Persist user metadata in native storage
islathehut Mar 13, 2026
e68a238
Merge branch 'develop' into feat/3091-store-user-metadata-in-native
islathehut Mar 13, 2026
35cb3c1
Clean up logs and remove testing code
islathehut Mar 13, 2026
7189680
Fix tests
islathehut Mar 13, 2026
7476ffc
Update CHANGELOG.md
islathehut Mar 13, 2026
edc9d92
Update and move updateUserProfiles tests
islathehut Mar 13, 2026
06bb47d
Fix factories test
islathehut Mar 13, 2026
caa4a33
Merge branch 'develop' into feat/3091-store-user-metadata-in-native
islathehut Mar 16, 2026
461342d
Fix state manager stuff
islathehut Mar 16, 2026
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