| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  | // Copyright 2024 Signal Messenger, LLC
 | 
					
						
							|  |  |  | // SPDX-License-Identifier: AGPL-3.0-only
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | import { | 
					
						
							|  |  |  |   type ExplodePromiseResultType, | 
					
						
							|  |  |  |   explodePromise, | 
					
						
							|  |  |  | } from '../util/explodePromise'; | 
					
						
							|  |  |  | import { linkDeviceRoute } from '../util/signalRoutes'; | 
					
						
							|  |  |  | import { strictAssert } from '../util/assert'; | 
					
						
							|  |  |  | import { normalizeAci } from '../util/normalizeAci'; | 
					
						
							| 
									
										
										
										
											2024-09-03 19:56:13 -07:00
										 |  |  | import { normalizeDeviceName } from '../util/normalizeDeviceName'; | 
					
						
							| 
									
										
										
										
											2024-10-18 10:15:03 -07:00
										 |  |  | import { isLinkAndSyncEnabled } from '../util/isLinkAndSyncEnabled'; | 
					
						
							| 
									
										
										
										
											2024-11-05 15:51:25 -08:00
										 |  |  | import { MINUTE } from '../util/durations'; | 
					
						
							| 
									
										
										
										
											2024-09-03 19:56:13 -07:00
										 |  |  | import { MAX_DEVICE_NAME_LENGTH } from '../types/InstallScreen'; | 
					
						
							|  |  |  | import * as Errors from '../types/errors'; | 
					
						
							| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  | import { | 
					
						
							|  |  |  |   isUntaggedPniString, | 
					
						
							|  |  |  |   normalizePni, | 
					
						
							|  |  |  |   toTaggedPni, | 
					
						
							|  |  |  | } from '../types/ServiceId'; | 
					
						
							|  |  |  | import { SignalService as Proto } from '../protobuf'; | 
					
						
							|  |  |  | import * as Bytes from '../Bytes'; | 
					
						
							|  |  |  | import * as log from '../logging/log'; | 
					
						
							|  |  |  | import { type WebAPIType } from './WebAPI'; | 
					
						
							|  |  |  | import ProvisioningCipher, { | 
					
						
							|  |  |  |   type ProvisionDecryptResult, | 
					
						
							|  |  |  | } from './ProvisioningCipher'; | 
					
						
							|  |  |  | import { | 
					
						
							|  |  |  |   type CreateLinkedDeviceOptionsType, | 
					
						
							|  |  |  |   AccountType, | 
					
						
							|  |  |  | } from './AccountManager'; | 
					
						
							|  |  |  | import { | 
					
						
							|  |  |  |   type IWebSocketResource, | 
					
						
							|  |  |  |   type IncomingWebSocketRequest, | 
					
						
							|  |  |  |   ServerRequestType, | 
					
						
							|  |  |  | } from './WebsocketResources'; | 
					
						
							| 
									
										
										
										
											2024-11-05 15:51:25 -08:00
										 |  |  | import { InactiveTimeoutError } from './Errors'; | 
					
						
							| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  | 
 | 
					
						
							|  |  |  | enum Step { | 
					
						
							|  |  |  |   Idle = 'Idle', | 
					
						
							|  |  |  |   Connecting = 'Connecting', | 
					
						
							|  |  |  |   WaitingForURL = 'WaitingForURL', | 
					
						
							|  |  |  |   WaitingForEnvelope = 'WaitingForEnvelope', | 
					
						
							|  |  |  |   ReadyToLink = 'ReadyToLink', | 
					
						
							|  |  |  |   Done = 'Done', | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | type StateType = Readonly< | 
					
						
							|  |  |  |   | { | 
					
						
							|  |  |  |       step: Step.Idle; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   | { | 
					
						
							|  |  |  |       step: Step.Connecting; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   | { | 
					
						
							|  |  |  |       step: Step.WaitingForURL; | 
					
						
							|  |  |  |       url: ExplodePromiseResultType<string>; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   | { | 
					
						
							|  |  |  |       step: Step.WaitingForEnvelope; | 
					
						
							|  |  |  |       done: ExplodePromiseResultType<void>; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   | { | 
					
						
							|  |  |  |       step: Step.ReadyToLink; | 
					
						
							|  |  |  |       envelope: ProvisionDecryptResult; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   | { | 
					
						
							|  |  |  |       step: Step.Done; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | >; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export type PrepareLinkDataOptionsType = Readonly<{ | 
					
						
							|  |  |  |   deviceName: string; | 
					
						
							|  |  |  |   backupFile?: Uint8Array; | 
					
						
							|  |  |  | }>; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-11-05 15:51:25 -08:00
										 |  |  | export type ProvisionerOptionsType = Readonly<{ | 
					
						
							|  |  |  |   server: WebAPIType; | 
					
						
							|  |  |  |   appVersion: string; | 
					
						
							|  |  |  | }>; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | const INACTIVE_SOCKET_TIMEOUT = 30 * MINUTE; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  | export class Provisioner { | 
					
						
							|  |  |  |   private readonly cipher = new ProvisioningCipher(); | 
					
						
							| 
									
										
										
										
											2024-11-05 15:51:25 -08:00
										 |  |  |   private readonly server: WebAPIType; | 
					
						
							|  |  |  |   private readonly appVersion: string; | 
					
						
							| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  | 
 | 
					
						
							|  |  |  |   private state: StateType = { step: Step.Idle }; | 
					
						
							|  |  |  |   private wsr: IWebSocketResource | undefined; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-11-05 15:51:25 -08:00
										 |  |  |   constructor(options: ProvisionerOptionsType) { | 
					
						
							|  |  |  |     this.server = options.server; | 
					
						
							|  |  |  |     this.appVersion = options.appVersion; | 
					
						
							|  |  |  |   } | 
					
						
							| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  | 
 | 
					
						
							|  |  |  |   public close(error = new Error('Provisioner closed')): void { | 
					
						
							|  |  |  |     try { | 
					
						
							|  |  |  |       this.wsr?.close(); | 
					
						
							|  |  |  |     } catch { | 
					
						
							|  |  |  |       // Best effort
 | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     const prevState = this.state; | 
					
						
							|  |  |  |     this.state = { step: Step.Done }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (prevState.step === Step.WaitingForURL) { | 
					
						
							|  |  |  |       prevState.url.reject(error); | 
					
						
							|  |  |  |     } else if (prevState.step === Step.WaitingForEnvelope) { | 
					
						
							|  |  |  |       prevState.done.reject(error); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public async getURL(): Promise<string> { | 
					
						
							|  |  |  |     strictAssert( | 
					
						
							|  |  |  |       this.state.step === Step.Idle, | 
					
						
							|  |  |  |       `Invalid state for getURL: ${this.state.step}` | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  |     this.state = { step: Step.Connecting }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     const wsr = await this.server.getProvisioningResource({ | 
					
						
							|  |  |  |       handleRequest: (request: IncomingWebSocketRequest) => { | 
					
						
							|  |  |  |         try { | 
					
						
							|  |  |  |           this.handleRequest(request); | 
					
						
							|  |  |  |         } catch (error) { | 
					
						
							|  |  |  |           log.error( | 
					
						
							|  |  |  |             'Provisioner.handleRequest: failure', | 
					
						
							|  |  |  |             Errors.toLogFormat(error) | 
					
						
							|  |  |  |           ); | 
					
						
							|  |  |  |           this.close(); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |       }, | 
					
						
							|  |  |  |     }); | 
					
						
							|  |  |  |     this.wsr = wsr; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-11-05 15:51:25 -08:00
										 |  |  |     let inactiveTimer: NodeJS.Timeout | undefined; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     const onVisibilityChange = (): void => { | 
					
						
							|  |  |  |       // Visible
 | 
					
						
							|  |  |  |       if (!document.hidden) { | 
					
						
							|  |  |  |         if (inactiveTimer != null) { | 
					
						
							|  |  |  |           clearTimeout(inactiveTimer); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |         inactiveTimer = undefined; | 
					
						
							|  |  |  |         return; | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       // Invisible, but already has a timer
 | 
					
						
							|  |  |  |       if (inactiveTimer != null) { | 
					
						
							|  |  |  |         return; | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       inactiveTimer = setTimeout(() => { | 
					
						
							|  |  |  |         inactiveTimer = undefined; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         this.close(new InactiveTimeoutError()); | 
					
						
							|  |  |  |       }, INACTIVE_SOCKET_TIMEOUT); | 
					
						
							|  |  |  |     }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     document.addEventListener('visibilitychange', onVisibilityChange); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  |     if (this.state.step !== Step.Connecting) { | 
					
						
							|  |  |  |       this.close(); | 
					
						
							|  |  |  |       throw new Error('Provisioner closed early'); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     this.state = { | 
					
						
							|  |  |  |       step: Step.WaitingForURL, | 
					
						
							|  |  |  |       url: explodePromise(), | 
					
						
							|  |  |  |     }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     wsr.addEventListener('close', ({ code, reason }) => { | 
					
						
							| 
									
										
										
										
											2024-11-05 15:51:25 -08:00
										 |  |  |       // Unsubscribe from visibility changes
 | 
					
						
							|  |  |  |       document.removeEventListener('visibilitychange', onVisibilityChange); | 
					
						
							|  |  |  |       if (inactiveTimer != null) { | 
					
						
							|  |  |  |         clearTimeout(inactiveTimer); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |       inactiveTimer = undefined; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  |       if (this.state.step === Step.ReadyToLink) { | 
					
						
							|  |  |  |         // WebSocket close is not an issue since we no longer need it
 | 
					
						
							|  |  |  |         return; | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       log.info(`provisioning socket closed. Code: ${code} Reason: ${reason}`); | 
					
						
							|  |  |  |       this.close(new Error('websocket closed')); | 
					
						
							|  |  |  |     }); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     return this.state.url.promise; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public async waitForEnvelope(): Promise<void> { | 
					
						
							|  |  |  |     strictAssert( | 
					
						
							|  |  |  |       this.state.step === Step.WaitingForEnvelope, | 
					
						
							|  |  |  |       `Invalid state for waitForEnvelope: ${this.state.step}` | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  |     await this.state.done.promise; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public prepareLinkData({ | 
					
						
							|  |  |  |     deviceName, | 
					
						
							|  |  |  |     backupFile, | 
					
						
							|  |  |  |   }: PrepareLinkDataOptionsType): CreateLinkedDeviceOptionsType { | 
					
						
							|  |  |  |     strictAssert( | 
					
						
							|  |  |  |       this.state.step === Step.ReadyToLink, | 
					
						
							|  |  |  |       `Invalid state for prepareLinkData: ${this.state.step}` | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  |     const { envelope } = this.state; | 
					
						
							|  |  |  |     this.state = { step: Step.Done }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     const { | 
					
						
							|  |  |  |       number, | 
					
						
							|  |  |  |       provisioningCode, | 
					
						
							|  |  |  |       aciKeyPair, | 
					
						
							|  |  |  |       pniKeyPair, | 
					
						
							|  |  |  |       aci, | 
					
						
							|  |  |  |       profileKey, | 
					
						
							|  |  |  |       masterKey, | 
					
						
							|  |  |  |       untaggedPni, | 
					
						
							|  |  |  |       userAgent, | 
					
						
							|  |  |  |       readReceipts, | 
					
						
							| 
									
										
										
										
											2024-10-18 10:15:03 -07:00
										 |  |  |       ephemeralBackupKey, | 
					
						
							| 
									
										
										
										
											2024-10-31 10:01:03 -07:00
										 |  |  |       accountEntropyPool, | 
					
						
							|  |  |  |       mediaRootBackupKey, | 
					
						
							| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  |     } = envelope; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     strictAssert(number, 'prepareLinkData: missing number'); | 
					
						
							|  |  |  |     strictAssert(provisioningCode, 'prepareLinkData: missing provisioningCode'); | 
					
						
							|  |  |  |     strictAssert(aciKeyPair, 'prepareLinkData: missing aciKeyPair'); | 
					
						
							|  |  |  |     strictAssert(pniKeyPair, 'prepareLinkData: missing pniKeyPair'); | 
					
						
							|  |  |  |     strictAssert(aci, 'prepareLinkData: missing aci'); | 
					
						
							|  |  |  |     strictAssert( | 
					
						
							|  |  |  |       Bytes.isNotEmpty(profileKey), | 
					
						
							|  |  |  |       'prepareLinkData: missing profileKey' | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  |     strictAssert( | 
					
						
							| 
									
										
										
										
											2024-10-31 10:01:03 -07:00
										 |  |  |       Bytes.isNotEmpty(masterKey) || accountEntropyPool, | 
					
						
							|  |  |  |       'prepareLinkData: missing masterKey or accountEntropyPool' | 
					
						
							| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  |     ); | 
					
						
							|  |  |  |     strictAssert( | 
					
						
							|  |  |  |       isUntaggedPniString(untaggedPni), | 
					
						
							|  |  |  |       'prepareLinkData: invalid untaggedPni' | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     const ourAci = normalizeAci(aci, 'provisionMessage.aci'); | 
					
						
							|  |  |  |     const ourPni = normalizePni( | 
					
						
							|  |  |  |       toTaggedPni(untaggedPni), | 
					
						
							|  |  |  |       'provisionMessage.pni' | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     return { | 
					
						
							|  |  |  |       type: AccountType.Linked, | 
					
						
							|  |  |  |       number, | 
					
						
							|  |  |  |       verificationCode: provisioningCode, | 
					
						
							|  |  |  |       aciKeyPair, | 
					
						
							|  |  |  |       pniKeyPair, | 
					
						
							|  |  |  |       profileKey, | 
					
						
							| 
									
										
										
										
											2024-09-03 19:56:13 -07:00
										 |  |  |       deviceName: normalizeDeviceName(deviceName).slice( | 
					
						
							|  |  |  |         0, | 
					
						
							|  |  |  |         MAX_DEVICE_NAME_LENGTH | 
					
						
							|  |  |  |       ), | 
					
						
							| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  |       backupFile, | 
					
						
							|  |  |  |       userAgent, | 
					
						
							|  |  |  |       ourAci, | 
					
						
							|  |  |  |       ourPni, | 
					
						
							|  |  |  |       readReceipts: Boolean(readReceipts), | 
					
						
							|  |  |  |       masterKey, | 
					
						
							| 
									
										
										
										
											2024-10-18 10:15:03 -07:00
										 |  |  |       ephemeralBackupKey, | 
					
						
							| 
									
										
										
										
											2024-10-31 10:01:03 -07:00
										 |  |  |       accountEntropyPool, | 
					
						
							|  |  |  |       mediaRootBackupKey, | 
					
						
							| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  |     }; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-11-04 15:04:11 -08:00
										 |  |  |   public isLinkAndSync(): boolean { | 
					
						
							|  |  |  |     strictAssert( | 
					
						
							|  |  |  |       this.state.step === Step.ReadyToLink, | 
					
						
							|  |  |  |       `Invalid state for prepareLinkData: ${this.state.step}` | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     const { envelope } = this.state; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     return ( | 
					
						
							|  |  |  |       isLinkAndSyncEnabled(this.appVersion) && | 
					
						
							|  |  |  |       Bytes.isNotEmpty(envelope.ephemeralBackupKey) | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  |   private handleRequest(request: IncomingWebSocketRequest): void { | 
					
						
							|  |  |  |     const pubKey = this.cipher.getPublicKey(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if ( | 
					
						
							|  |  |  |       request.requestType === ServerRequestType.ProvisioningAddress && | 
					
						
							|  |  |  |       request.body | 
					
						
							|  |  |  |     ) { | 
					
						
							|  |  |  |       strictAssert( | 
					
						
							|  |  |  |         this.state.step === Step.WaitingForURL, | 
					
						
							|  |  |  |         `Unexpected provisioning address, state: ${this.state}` | 
					
						
							|  |  |  |       ); | 
					
						
							|  |  |  |       const prevState = this.state; | 
					
						
							|  |  |  |       this.state = { step: Step.WaitingForEnvelope, done: explodePromise() }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       const proto = Proto.ProvisioningUuid.decode(request.body); | 
					
						
							|  |  |  |       const { uuid } = proto; | 
					
						
							|  |  |  |       strictAssert(uuid, 'Provisioner.getURL: expected a UUID'); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       const url = linkDeviceRoute | 
					
						
							|  |  |  |         .toAppUrl({ | 
					
						
							|  |  |  |           uuid, | 
					
						
							|  |  |  |           pubKey: Bytes.toBase64(pubKey), | 
					
						
							| 
									
										
										
										
											2024-10-22 11:49:44 -07:00
										 |  |  |           capabilities: isLinkAndSyncEnabled(this.appVersion) ? ['backup'] : [], | 
					
						
							| 
									
										
										
										
											2024-08-29 17:02:48 -07:00
										 |  |  |         }) | 
					
						
							|  |  |  |         .toString(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       window.SignalCI?.setProvisioningURL(url); | 
					
						
							|  |  |  |       prevState.url.resolve(url); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       request.respond(200, 'OK'); | 
					
						
							|  |  |  |     } else if ( | 
					
						
							|  |  |  |       request.requestType === ServerRequestType.ProvisioningMessage && | 
					
						
							|  |  |  |       request.body | 
					
						
							|  |  |  |     ) { | 
					
						
							|  |  |  |       strictAssert( | 
					
						
							|  |  |  |         this.state.step === Step.WaitingForEnvelope, | 
					
						
							|  |  |  |         `Unexpected provisioning address, state: ${this.state}` | 
					
						
							|  |  |  |       ); | 
					
						
							|  |  |  |       const prevState = this.state; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       const ciphertext = Proto.ProvisionEnvelope.decode(request.body); | 
					
						
							|  |  |  |       const message = this.cipher.decrypt(ciphertext); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       this.state = { step: Step.ReadyToLink, envelope: message }; | 
					
						
							|  |  |  |       request.respond(200, 'OK'); | 
					
						
							|  |  |  |       this.wsr?.close(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       prevState.done.resolve(); | 
					
						
							|  |  |  |     } else { | 
					
						
							|  |  |  |       log.error('Unknown websocket message', request.requestType); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | } |