Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .github/secrets/decrypt_secrets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ gpg --quiet --batch --yes --decrypt --passphrase="$IOS_PROFILE_KEY" --output ./.
gpg --quiet --batch --yes --decrypt --passphrase="$IOS_NSE_PROFILE_KEY" --output ./.github/secrets/match_AppStore_comquietmobile_QuietNotificationServiceExtension.mobileprovision ./.github/secrets/match_AppStore_comquietmobile_QuietNotificationServiceExtension.mobileprovision.gpg
gpg --quiet --batch --yes --decrypt --passphrase="$IOS_CERTIFICATE_KEY" --output ./.github/secrets/Certificates.p12 ./.github/secrets/Certificates.p12.gpg
gpg --quiet --batch --yes --decrypt --passphrase="$IOS_FIREBASE_KEY" --output ./.github/secrets/GoogleService-Info.plist ./.github/secrets/GoogleService-Info.plist.gpg
gpg --quiet --batch --yes --decrypt --passphrase="$IOS_FIREBASE_KEY" --output ./.github/secrets/google-services.json ./.github/secrets/google-services.json.gpg

mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles

cp ./.github/secrets/match_AppStore_comquietmobile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/762df280-302c-4336-a56d-c74914169337.mobileprovision
cp ./.github/secrets/match_AppStore_comquietmobile_QuietNotificationServiceExtension.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/247b3945-4f28-4ef1-b722-e98fb3fb59f7.mobileprovision
cp ./.github/secrets/GoogleService-Info.plist ./packages/mobile/ios/GoogleService-Info.plist
cp ./.github/secrets/google-services.json ./packages/mobile/android/app/google-services.json

security create-keychain -p "" build.keychain
security import ./.github/secrets/Certificates.p12 -t agg -k ~/Library/Keychains/build.keychain -P "$IOS_CERTIFICATE_KEY" -A
Expand Down
4 changes: 4 additions & 0 deletions .github/secrets/google-services.json.gpg
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Œ *ìDá³ üÒÀéõ
‚¿sðpR.jßÿѤ$&p*&Å—
n'½Ú»£˜w0Ñ/R•é"bBi‘6ÐÖ‡=À?‹¹voc2Ün òT§c2Ô¤†tà‚Ÿ;fÙ S?»¯kü4Ž>hŠ/µþf°ÞMœ;HéœM@¸–Q_„¤9ã/Õ\ûgÞmmRi˜¨QÉvaVrCµÚÖ-2AÑÄ)x¾Õè›ù3è±¼>ßÇÁ ÚÈRï^ta]<b@×$ÑÊIÀy¹ªlâ¨/IV˜ c¡0àt’-ÿÏ7Ž~¢|&{Ý2;Íxˆ-õ¶Á¶(£!ÈÔ…H Ú…9Ùíãñ›mòoOHbáçç¦SÆQRر´ìÆ`QDZ/Y¨RVEò½ BIͱçV
{í¢Äz‡¢bçC:þòFåI ûA®çȽlœNwOvð±Oß̺ éÌÙ¼õj³™Üy \@&kÏk³ŽÈÊzPÔ6ã÷O»~;Óbµf¨€ªg‰±j”³1°:?%/|>$˜u,#*ÔgÉéŠîÜ 0Ú»
Expand Down
31 changes: 31 additions & 0 deletions packages/backend/src/backendManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ function isCaptchaTokenMessage(msg: any): msg is CaptchaTokenMessage {
function isCaptchaErrorMessage(msg: any): msg is CaptchaErrorMessage {
return msg && typeof msg === 'object' && msg.type === 'hcaptcha-error' && typeof msg.message === 'string'
}

function setupGracefulShutdown(app: INestApplicationContext, getConnectionsManager: () => ConnectionsManagerService) {
let shuttingDown = false
let termSignalCount = 0
Expand Down Expand Up @@ -241,10 +242,31 @@ export const runBackendMobile = async (rn_bridge: any, secret: string) => {
{ logger: ['warn', 'error', 'log', 'debug', 'verbose'] }
)
let proxyAgent: HttpsProxyAgent<string> | undefined
let shutdownRequestedFromBridge = false
rn_bridge.channel.on('close', () => {
const connectionsManager = app.get<ConnectionsManagerService>(ConnectionsManagerService)
connectionsManager.pause()
})
rn_bridge.channel.on('hibernate', async () => {
logger.info('Received hibernate message from RN bridge')
const connectionsManager = app.get<ConnectionsManagerService>(ConnectionsManagerService)
try {
await connectionsManager.hibernate()
rn_bridge.channel.send('hibernated')
} catch (e) {
logger.error('Error occurred while hibernating backend', e)
}
})
rn_bridge.channel.on('wake', async () => {
logger.info('Received wake message from RN bridge')
const connectionsManager = app.get<ConnectionsManagerService>(ConnectionsManagerService)
try {
await connectionsManager.wake()
rn_bridge.channel.send('woke')
} catch (e) {
logger.error('Error occurred while waking backend', e)
}
})
rn_bridge.channel.on('open', (msg: OpenServices) => {
const connectionsManager = app.get<ConnectionsManagerService>(ConnectionsManagerService)
const torControl = app.get<TorControl>(TorControl)
Expand All @@ -256,6 +278,15 @@ export const runBackendMobile = async (rn_bridge: any, secret: string) => {
connectionsManager.resume()
})
const shutdown = setupGracefulShutdown(app, () => app.get<ConnectionsManagerService>(ConnectionsManagerService))
rn_bridge.channel.on('shutdown', async () => {
logger.info('Received shutdown message from RN bridge')
if (shutdownRequestedFromBridge) {
return
}
shutdownRequestedFromBridge = true
await shutdown.gracefulCloseServices()
rn_bridge.channel.send('backendClosed')
})
rn_bridge.channel.send('backendReady')
}

Expand Down
52 changes: 52 additions & 0 deletions packages/backend/src/nest/auth/sigchain.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,36 @@ describe('SigChainService - listener lifecycle', () => {
}
})

it('emits new keys to Android once and does not resend already-stored keys', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'android' })

try {
const emitSpy = jest.spyOn(sigChainService.serverIoProvider.io, 'emit')
const chain = await sigChainService.createChain('androidKeys', 'alice', true)
const teamId = chain.team!.id

await waitForExpect(async () => {
const keyCalls = emitSpy.mock.calls.filter(([event]) => event === SocketEvents.KEYS_UPDATED)
expect(keyCalls).toHaveLength(1)
expect((keyCalls[0][1] as { keys: unknown[] }).keys.length).toBeGreaterThan(0)
const storedKeys = await localDbService.getKeysStoredInKeychain(teamId)
expect(storedKeys).toHaveLength((keyCalls[0][1] as { keys: unknown[] }).keys.length)
})

const storedKeysAfterFirstUpdate = await localDbService.getKeysStoredInKeychain(teamId)

chain.emit('updated')

await new Promise(resolve => setTimeout(resolve, 25))

expect(emitSpy.mock.calls.filter(([event]) => event === SocketEvents.KEYS_UPDATED)).toHaveLength(1)
expect(await localDbService.getKeysStoredInKeychain(teamId)).toEqual(storedKeysAfterFirstUpdate)
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform })
}
})

it('emits device credentials for the NSE on ios', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'ios' })
Expand All @@ -191,4 +221,26 @@ describe('SigChainService - listener lifecycle', () => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
}
})

it('emits device credentials for the NSE on android', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'android' })

try {
const emitSpy = jest.spyOn(sigChainService.serverIoProvider.io, 'emit')
const chain = await sigChainService.createChain('androidDeviceCredentials', 'alice', true)

await waitForExpect(() => {
const deviceCalls = emitSpy.mock.calls.filter(([event]) => event === SocketEvents.DEVICE_CREDENTIALS_UPDATED)
expect(deviceCalls).toHaveLength(1)
expect(deviceCalls[0][1]).toEqual({
deviceId: chain.device.deviceId,
teamId: chain.team!.id,
signingPrivateKey: chain.device.keys.signature.secretKey,
})
})
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform })
}
})
})
16 changes: 9 additions & 7 deletions packages/backend/src/nest/auth/sigchain.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,12 @@ export class SigChainService extends EventEmitter {
}

/**
* Update the IOS keychain with any new keys on chain update
* Update mobile native storage 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)
const platform = process.platform as string
if (platform !== 'ios' && platform !== 'android') {
this.logger.trace('Skipping key update because we are not on mobile, current platform =', process.platform)
return
}

Expand Down Expand Up @@ -236,7 +237,7 @@ export class SigChainService extends EventEmitter {
}

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

Expand All @@ -249,11 +250,12 @@ export class SigChainService extends EventEmitter {
}

/**
* Emit device credentials to iOS so the NSE can authenticate with QSS.
* Only runs on iOS; no-ops on other platforms.
* Emit device credentials to mobile clients so native background handlers can
* authenticate with QSS.
*/
private _updateDeviceCredentials(teamName: string): void {
if ((process.platform as string) !== 'ios') return
const platform = process.platform as string
if (platform !== 'ios' && platform !== 'android') return
try {
const sigchain = this.getChain({ teamName })
if (sigchain?.team == null) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ import { QPSService } from '../qps/qps.service'
export class ConnectionsManagerService extends EventEmitter implements OnModuleInit {
public communityId: string
public communityState: ServiceState
private hibernating = false
private hibernateInFlight: Promise<void> | null = null
private wakeInFlight: Promise<void> | null = null
private ports: GetPorts
isTorInit: TorInitState = TorInitState.NOT_STARTED
private peerInfo: Libp2pPeerInfo | undefined = undefined
Expand Down Expand Up @@ -260,6 +263,116 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
await this.qssService.resume()
}

/**
* Hibernate: flush state to disk and pause all networking. Keeps the node
* process and Nest context alive so wake() can bring the app back without a
* cold start. Survives Android low-memory kill because sigchain is persisted.
*/
public async hibernate() {
if (this.hibernating) {
this.logger.info('hibernate: already hibernated, skipping')
return
}
if (this.hibernateInFlight) return this.hibernateInFlight
if (this.wakeInFlight) {
this.logger.info('hibernate: waiting for in-flight wake to finish before hibernating')
try {
await this.wakeInFlight
} catch (e) {
this.logger.error('hibernate: in-flight wake failed', e)
}
}

this.hibernateInFlight = (async () => {
this.logger.info('Hibernating!')
try {
await this.saveActiveChain()
} catch (e) {
this.logger.error('hibernate: saveActiveChain failed', e)
}
try {
await this.pause()
} catch (e) {
this.logger.error('hibernate: pause failed', e)
}
if (this.storageService) {
try {
this.logger.info('hibernate: stopping OrbitDB sync')
await this.storageService.stopSync()
} catch (e) {
this.logger.error('hibernate: storage.stopSync failed', e)
}
}
if (this.tor) {
try {
this.logger.info('hibernate: killing tor')
await this.tor.kill()
} catch (e) {
this.logger.error('hibernate: tor.kill failed', e)
}
}
this.hibernating = true
this.logger.info('Hibernated')
})()
try {
await this.hibernateInFlight
} finally {
this.hibernateInFlight = null
}
}

/**
* Wake from hibernate. Re-spawns Tor (if killed), re-opens onions, resumes
* libp2p + QSS + socket. Safe to call when not hibernated (no-op if tor still
* alive and services already resumed).
*/
public async wake() {
if (!this.hibernating && !this.hibernateInFlight) {
this.logger.info('wake: not hibernated, skipping')
return
}
if (this.wakeInFlight) return this.wakeInFlight
if (this.hibernateInFlight) {
this.logger.info('wake: waiting for in-flight hibernate to finish before waking')
try {
await this.hibernateInFlight
} catch (e) {
this.logger.error('wake: in-flight hibernate failed', e)
}
}

this.wakeInFlight = (async () => {
this.logger.info('Waking!')
if (this.tor) {
try {
await this.tor.init()
} catch (e) {
this.logger.error('wake: tor.init failed', e)
}
}
try {
await this.resume()
} catch (e) {
this.logger.error('wake: resume failed', e)
}
if (this.storageService) {
try {
this.logger.info('wake: restarting OrbitDB sync')
await this.storageService.startSync()
} catch (e) {
this.logger.error('wake: storage.startSync failed', e)
}
}
this.hibernating = false
this.logger.info('Woke')
})()
try {
await this.wakeInFlight
} finally {
this.wakeInFlight = null
}
}

// This method is only used on iOS through rn-bridge for reacting on lifecycle changes
public async openSocket() {
await this.socketService.init()
Expand All @@ -272,6 +385,8 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
deleteChainFromDisk: false,
}
) {
this.logger.info('Closing services', options)

if (!options.deleteChainFromDisk) {
this.logger.info('Saving active sigchain')
try {
Expand All @@ -281,8 +396,6 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
}
}

this.logger.info('Closing services', options)

await this.closeSocket()

if (this.qssService) {
Expand Down
Loading
Loading