import { SyncDevice, SyncDeviceEnrolmentRoute, PublicSyncDevice, DeviceType, DeviceId, DeviceInfo } from '../interfaces/SyncTypes.mjs'; import type { ServerSecret } from '../interfaces/BrandedTypes.mjs'; import type { DevicePublicKeys, DeviceSecretKeys } from '../interfaces/CryptoLib.mjs'; import type Command from '../Command/BaseCommand.mjs'; import type FavaLibMediator from '../FavaLibMediator.mjs'; import { VaultSyncState, VaultSyncStateWithServerUrl } from '../interfaces/Vault.mjs'; import type { FavaMeta } from '../interfaces/FavaMeta.mjs'; import type { SyncCommandFromServer } from '../interfaces/protocol/ServerMessage.mjs'; import type { SyncCommandFromClient } from '../interfaces/protocol/ClientMessage.mjs'; export declare enum ConnectionStatus { CONNECTING = 0, CONNECTED = 1, NOT_CONNECTED = 2, FAILED = 3 } /** What `addSyncDevice` needs to know beyond the record itself. */ export interface AddSyncDeviceOptions { /** * How this device came to be here. Required rather than defaulted, for the * reason `getEncryptedVaultState` requires its `aad`: a default would make * the wrong one the easy one to reach for. */ via: SyncDeviceEnrolmentRoute; /** The verified peer that introduced it, when `via` is 'peer'. */ by?: DeviceId; /** Whether to save after adding (false when adding several). */ saveAfter?: boolean; /** * Whether a peer introduction is news. * * Defaults to true, unlike `via`, because silence is the dangerous value * here: a route added later that forgets to say anything gets the noisy * behaviour rather than the quiet one. * * `importVaultState` passes false for the INITIAL pairing import only. Every * device in that list is the baseline of a vault the user deliberately chose * to join, so warning about each one is asking them to vet a decision they * had just finished making -- and N notices they cannot act on is how the one * that matters goes unread. Provenance is untouched: the record is still * `via: 'peer'`, still stamped with the initiator as `by`, so `getSyncDevices` * and any later audit see exactly what they saw before. */ announce?: boolean; } /** * Manages synchronization of 2FA devices and communication with the server. */ declare class SyncManager { private readonly mediator; private readonly publicKeys; private readonly secretKeys; private readonly favaMeta; private readonly deviceType; private connectionEnabled; private ws?; private activeAddDeviceFlow?; private readonly reconnectInterval; readonly serverUrl: string; readonly serverSecret: ServerSecret; private syncDevices; /** * How far this socket has got through the server's connection gate. * * Reset on every `initServerConnection`, so a reconnect proves itself again * rather than inheriting the last socket's standing. Nothing but `authProof` * is sent while this is not `authenticated`. */ private authState; private readyEventEmitted; private commandSendQueue; private commandSendQueueDrainedResolvers; private reconnectTimeout?; private terminateTimeout?; private connectionFailedTimeout?; private shouldReconnect; /** * What the last socket attempt was last seen doing, for the failure message. * * The WebSocket api gives a client almost nothing about a handshake that did * not happen -- deliberately, so a page cannot use it to probe the network -- * so this is assembled from what we do see: the close code and reason, if any * arrived, and how far through the server's gate the socket got. */ private lastSocketFailure?; private requestedResilver; private requestedResilverTimeout?; /** * Remote commands this device has applied, as persisted in the vault. * * The in-memory set CommandManager keeps still gates `execute`, but it * empties on every restart, and the server re-sends everything it has not * been told was executed. This is the half that survives. */ private processedCommands; /** * Per peer, the newest timestamp whose command id has been pruned from * `processedCommands`. Anything at or below it is refused. */ private replayFloors; /** * Device ids this vault has removed, against when. * * What makes `removeSyncDevice` converge. See VaultSyncState.removedDevices. */ private removedDevices; /** Serializes incoming batches, including their replay-state saves. */ private commandReceiveQueue; /** Remains set after a failed save; no acknowledgments may bypass that save. */ private replayStateDirty; private get deviceId(); private get deviceInfo(); /** * Public getter for the command send queue. * @returns The command send queue. */ getCommandSendQueue(): SyncCommandFromClient[]; /** * Public getter for the replay-protection state, for persistence. * @returns The applied-command record and the per-peer floors. */ getProcessedCommands(): VaultSyncState['processedCommands']; /** * Public getter for the removal tombstones, for persistence. * @returns The device ids this vault has removed, against when. */ getRemovedDevices(): VaultSyncState['removedDevices']; /** * Public getter for the sync devices. * * Carries no key material, but does carry a FINGERPRINT of it: the one * property of a peer that the peer did not choose. `deviceFriendlyName` and * `deviceType` are whatever the device said about itself, so they can * describe anything; the fingerprint is derived from the keys this vault will * actually seal to and verify against, and is short enough to read aloud. * * `acknowledged` is false only for a device a peer introduced and that no * consumer has said it surfaced yet -- it gates nothing. * @returns The sync devices, without their public keys. */ getSyncDevices(): PublicSyncDevice[]; /** * Creates an instance of SyncManager. * @param mediator - The mediator for accessing other components. * @param publicKeys - This device's two public keys. * @param secretKeys - This device's two secret keys. * @param favaMeta - Meta info containing at least a unique identifier for this device. * @param syncState - The state of the sync. * @param deviceType - The identifier for this device type (e.g. 2fa-cli). * @param connectionEnabled - Whether to connect to the sync server during initialization. * @throws {InitializationError} If initialization fails (e.g., if the server URL is invalid). */ constructor(mediator: FavaLibMediator, publicKeys: DevicePublicKeys, secretKeys: DeviceSecretKeys, favaMeta: FavaMeta, syncState: VaultSyncStateWithServerUrl, deviceType: DeviceType, connectionEnabled?: boolean); private get libraryLoader(); private get cryptoLib(); private get persistentStorageManager(); private get commandManager(); private get dispatchLibEvent(); private get log(); /** * @returns Whether an add device flow is currently active. */ get inAddDeviceFlow(): boolean; /** * @returns Whether the socket is open, whatever it is allowed to say on it. */ private get socketOpen(); /** * @returns Whether there is a usable connection to the sync server. * * Open is not enough any more: the server refuses every message from a socket * that has not proved the shared secret, so a pairing flow started between * `open` and `authAccepted` would have its connection closed under it rather * than get an error it could act on. Every caller gates on this, and it means * BOTH. */ get webSocketConnected(): boolean; /** * @returns How far the current socket got through the server's gate, as a * clause that reads after "while". */ private describeAuthStage; /** * Describes, in one line and without going near the network, why this device * is not talking to the sync server: which server, and what the last socket * attempt was seen doing. * * For the HTTP half -- what that address actually answers -- see * {@link diagnoseConnectionFailure}. This one is what the log event says, * so it has to be available the moment the failure is noticed. * @returns The one-line failure description. */ describeConnectionFailure(): string; /** * Describes why this device is not talking to the sync server, including what * a plain HTTP request to the same address answers. * * The WebSocket api is why the second half has to be asked for separately. A * handshake that fails arrives as an `error` event with no error in it and a * 1006 close with no reason -- specified that way so a page cannot use a * socket to probe the network -- so "it did not connect" is genuinely all the * socket knows. A fetch of the same address is not bound by that, and * separates the cases a user has to tell apart: nothing listening, no such * host, a certificate that is not trusted, or something listening that is not * a sync server (and then its status and the start of its body). * @returns A promise for the full failure description, on one line. */ diagnoseConnectionFailure(): Promise; /** * Asks the sync server's address for an ordinary HTTP response. * * A sync server answers a plain GET with 426 Upgrade Required, so a 426 means * the address is right and the problem is the handshake or the secret; * anything else names what is there instead. * @returns A promise for a description of the response, or of the failure to * get one. */ private probeServerUrlOverHttp; private sendToServer; /** * Initializes the WebSocket connection to the server. */ initServerConnection(): void; private handleWebSocketClose; private handleServerMessage; private attemptReconnect; /** * Initiates the process to add a new device. * @param returnAs - An object specifying what should be returned: * - `qr: boolean` - If `true`, the result will include a QR code string in the `qr` property. * - `text: boolean` - If `true`, the result will include initiation data in the `text` property. * @returns A promise that resolves to an object containing: * - `qr`: If `returnAs.qr` is `true`, this will be a `string` containing the QR code; otherwise, `null`. * - `text`: If `returnAs.text` is `true`, this will be an `InitiateAddDeviceFlowResult` object; otherwise, `null`. * @throws {SyncAddDeviceFlowConflictError} If an add device flow is already active. * @throws {SyncNoServerConnectionError} If there is no server connection. */ initiateAddDeviceFlow(returnAs: { qr: true; text: true; }): Promise<{ qr: string; text: string; }>; /** * @inheritdoc */ initiateAddDeviceFlow(returnAs: { qr: true; text: false; }): Promise<{ qr: string; text: null; }>; /** * @inheritdoc */ initiateAddDeviceFlow(returnAs: { qr: false; text: true; }): Promise<{ qr: null; text: string; }>; /** * @inheritdoc */ initiateAddDeviceFlow(returnAs: { qr: false; text: false; }): Promise<{ qr: null; text: null; }>; /** * Responds to an add device flow initiated by another device. * @param initiatorData The data received from the initiating device. * @param initiatorDataType The type of the initiatorData, determines how it should be decoded * @throws {SyncNoServerConnectionError} If there is no server connection. * @throws {SyncAddDeviceFlowConflictError} If an add device flow is already active. * @throws {SyncPairingVersionError} If the initiator speaks a different JPAKE wire version. * @throws {SyncError} If the initiator data is invalid. */ respondToAddDeviceFlow(initiatorData: string | Uint8Array | File, initiatorDataType: 'text' | 'qr'): Promise; private finishAddDeviceFlowKeyExchangeInitiator; private finishAddDeviceFlowKeyExchangeResponder; private sendFullVaultDataAndSetDeviceInfo; /** * Settles one sender flow, including a pending registration promise. * @param result - Its terminal outcome. */ private finishInitiatorFlow; /** * Handles an asynchronous sender failure without ending a newer flow. * @param err - The rejected operation's error. * @param flow - The flow the operation belonged to. */ private failInitiatorFlow; private importInitialVault; /** * Reports a vault import that was refused, without letting it escape as an * unhandled rejection. * @param what - Which import failed, for the message. * @param err - The thrown value. */ private reportFailedVaultImport; /** * Decrypts a peer's whole vault state and merges it into this one. * @param encryptedVaultState - The sealed vault state. * @param symmetricKey - The key it is sealed under. * @param expectedDeviceId - The sender, as the caller established it. * @param isPairing - True when this is the initial vault of a JPAKE flow this * device just completed. It changes what the SENDER's own record counts as: * a pairing, since the user was standing in front of both devices. Every * other device in the list is a peer introduction either way -- the sender * vouching for devices this vault has never met is delegation, not pairing, * however the sender itself arrived. * * It also silences the announcement for the rest of the list, which is a * separate question from what the record says. A resilver announcing a device * is a peer adding one to a vault that already existed; the initial import * announcing one is a vault being described for the first time, which is not * an event -- the user chose to join it, list and all. The records are * identical either way, `via: 'peer'` and `by: `, so a later audit * still sees delegation. */ private importVaultState; /** * Cancels the active add sync device flow. * @throws {SyncNoServerConnectionError} If there is no server connection. * @throws {SyncInWrongStateError} If there is no active add device flow. */ cancelAddSyncDevice(): void; /** * Sends a command to the server to synchronize with other devices. * @param command - The command to be sent. * @throws {SyncNoServerConnectionError} If there is no server connection. */ sendCommand(command: Command): Promise; /** * Waits until the server has acknowledged every queued outgoing command. * * Sending is otherwise fire-and-forget: `sendCommand` hands the commands to * the socket and returns, and the acknowledgement arrives later as a * `syncCommandsReceived` message. That is fine for a long-lived app, but a * process that exits right after a mutation - the cli - would take the * queue down with it. * * This only waits, it never re-sends: the queue is sent again in full on * the next connection anyway. A command the server already has is no * longer a problem for it - it recognises the repeat by * (commandId, deviceId) and acknowledges it again. * @param timeoutMs - How long to wait for the acknowledgement. * @returns True when the queue is empty, false when it could not be * flushed. In the false case the queue has been persisted, so the commands * go out the next time this device connects. */ flushCommandSendQueue(timeoutMs?: number): Promise; private processCommandSendQueue; /** * Handles the confirmation that the sever succesfully received (some) send commands * @param commandIds - The ids of the received commands */ private commandsSuccesfullyReceived; /** * Wakes everyone waiting in `flushCommandSendQueue`. */ private resolveCommandSendQueueDrained; /** * Classifies an authenticated command against the persisted replay record. * * Two checks, because the record of what has been applied is deliberately * bounded (see `recordProcessedCommands`): * * - an id still in `processedCommands` is a duplicate outright; * - a command at or below its sender's floor is one whose id may have been * pruned, so it is refused rather than guessed at. * * The floor is per peer and only ever rises when that peer's OWN traffic is * pruned, which is what keeps this from punishing a device that has been * offline for a long time: a quiet peer's floor stays where it was, and its * queued commands still apply when it comes back. * * Note what this is NOT for. A malicious server cannot re-deliver a stored * blob under a fresh id -- the command id is in the AAD and inside the signed * payload, so a changed id fails to decrypt and then fails to verify. This is * about the same id arriving twice, which the server does routinely and * legitimately on every reconnect, and which used to be caught only by an * in-memory set that a restart emptied. * @param from - The peer that signed the command. * @param command - The verified command. * @returns Whether to execute, acknowledge a duplicate, or discard old data. */ private getReplayStatus; /** * Records the commands that were just applied, and prunes the record. * * Bounded two ways, because "remember every command id forever" is a vault * that grows without limit: anything older than REPLAY_RETENTION_MS goes, and * so does anything beyond MAX_PROCESSED_COMMANDS, oldest first. Pruning an * entry raises its sender's floor to that entry's timestamp, so forgetting an * id never makes it acceptable again -- the set shrinks without the * protection weakening. * @param applied - Every command successfully applied in this batch. */ private recordProcessedCommands; /** * Checks that a full vault state was signed by the peer it claims to be from. * * A resilver is the one message that carries the whole vault, and it was as * unauthenticated as commands were: it is sealed to this device's public key, * and sealing is a public operation. The `fromDeviceId` on it is stamped by * the server. This is what makes it mean something. * * The initial vault of a pairing flow does NOT come through here -- see * importInitialVault for why the JPAKE key already settles that one. * @param signingPublicKey - The key this vault holds for the claimed sender. * @param fromDeviceId - The device the vault data claims to be from. * @param encryptedVaultData - The sealed vault state, as it arrived. * @param signature - The signature that travelled with it. * @throws {SyncError} If the signature is absent or does not verify. */ private assertVaultDataSignature; /** * Checks that a decrypted command really came from the peer it names. * * Everything this does is a refusal, and the order is deliberate: shape, then * sender, then signature, then the id. Each step is what makes the next one * meaningful, and no step reports which one failed -- `receiveCommands` turns * every throw here into the same warning, because telling a prober whether a * device id is known is already telling them something. * * The signature is the whole point. Sealing is a public operation, so before * this check anyone holding a device's public key could mint commands for it. * Now a command is acted on only if a device CURRENTLY in this vault's peer * list signed it, for this recipient, under this command id -- which is also * what makes `removeSyncDevice` a revocation rather than bookkeeping. * @param commandId - The id the server delivered the command under. * @param envelope - The decrypted envelope, which may be anything at all. * @returns The command and the peer identity used for verification. * @throws {SyncError} If the envelope is not a command from a known peer. */ private verifyCommandEnvelope; /** * Receives commands in batch arrival order, without overlapping execution. * @param encryptedCommands - The commands. * @returns A promise resolving after the batch is processed and saved. * @throws {Error} If the replay state cannot be saved. */ receiveCommands(encryptedCommands: SyncCommandFromServer[]): Promise; /** * Logs the same refusal for all malformed or unauthenticated commands. * @param commandId - The id supplied by the server. */ private reportRejectedCommand; /** * Processes one batch, authenticating against the peer list at execution time. * @param encryptedCommands - The commands in this batch. */ private processReceivedCommands; /** * Sends vault data to the server for each sync device */ private resilver; /** * Reports a device that describes itself with text that must not be printed. * * Every field here is chosen by the device being described and is only * length-checked, so this is the one place that can say a peer sent an * escape sequence, a carriage return or a bidirectional override -- the * three things that rewrite or reorder what a user is reading, including the * fingerprint they are being asked to compare it against. `deviceLabel` * removes them from anything shown, which is exactly why the removal has to * be said out loud somewhere: otherwise the tidied-up version is all anyone * ever sees. * * Named by fingerprint rather than by anything it claims, for the obvious * reason. * @param device - The device, already past `validateSyncDevice`. */ private reportUnsafeDeviceText; /** * Adds a device to this vault's peer list. * * The single chokepoint for every route a peer device can arrive by: * `importVaultState`, `AddSyncDeviceCommand`, and this device's own * registration from the constructor. The load path is the one exception -- * it assigns `syncDevices` directly, so `creationUtils` runs the same checks * itself. * * Four gates, in order, and the order is the point: a record has to be * well formed before its id means anything, its id has to be one this vault * has not revoked before its keys are worth comparing, and the keys have to * match any it already holds before it is worth counting against the cap. * * 1. **Shape.** Unchanged, and still only a shape gate: a well formed record * carrying an attacker's keys passes it. * 2. **Tombstone.** A device this vault removed cannot be introduced back by * a peer -- that is what makes a removal stick, given that a peer offline * at the time still lists it and will resilver it back. Pairing clears the * tombstone instead, because that is the user saying so at both ends. * 3. **Key pinning.** Keys are fixed on first receipt. A second record for a * known id carrying different keys is refused loudly rather than dropped: * it is the only refusal here that is evidence of something rather than of * a peer on a different build. * 4. **Cap.** As before. * * What it deliberately does NOT do is refuse a device merely because a peer * rather than the user introduced it. A peer holds every seed already, so * peer trust is flat by design; instead this records WHO introduced it and * announces it, so delegated trust is at least visible -- except for the one * list a user cannot sensibly be asked about device by device, the vault they * just paired into. See `announce`. * @param device - The device to add. Only its four wire fields are read; any * `enrolment` or `acknowledgedAt` on it is ignored, since those are this * device's opinion and a peer does not get to write them. * @param options - How the device arrived and what to do about it. * @throws {SyncError} If the record is unusable or the vault is full. * @throws {SyncDeviceRemovedError} If a peer is reintroducing a removed device. * @throws {SyncDeviceKeyConflictError} If it contradicts keys already held. */ addSyncDevice(device: SyncDevice, options: AddSyncDeviceOptions): Promise; /** * Records that a consumer has surfaced a peer-introduced device to the user. * * Local and terminal: no command is sent, no peer is told, and nothing about * the device changes. It exists so a consumer that was not running when * `SyncDeviceAdded` fired can still find what it has not shown yet, by * filtering `getSyncDevices()` on `acknowledged`. * @param deviceId - The device that has been surfaced. * @param saveAfter - Whether to save afterwards. */ acknowledgeSyncDevice(deviceId: DeviceId, saveAfter?: boolean): Promise; /** * Records what a device now says about itself. * * The write half of a rename; who may rename whom is decided in * `ChangeDeviceInfoCommand.validate`, both for the local route and for a * peer's. It lives here rather than in that command so that every mutation * of the device list is in one place with its `Changed` dispatch -- a rename * applied straight to `syncDevices` left every list built on that event * showing the old name. * * Does not save: the command that calls it does, together with the rest of * what it changed. * @param deviceId - The device being renamed. * @param deviceInfo - What it now says about itself. * @returns Whether a device with that id was in the list. */ setDeviceInfo(deviceId: DeviceId, deviceInfo: DeviceInfo): boolean; /** * Removes a device from this vault's peer list, and remembers that it did. * * The tombstone is the half that makes this a revocation rather than a * deletion. Without it, a peer that was offline when the removal happened * still lists the device, and its next resilver puts it straight back through * `importVaultState` -- at which point its commands verify again and the one * lever the user has has quietly done nothing. * @param deviceId - The id of the device to remove. * @param saveAfter - Whether to save the vault after removing the device. * @returns The removed device, or undefined if it was not present. * @throws {SyncError} If asked to remove this device from its own vault. */ removeSyncDevice(deviceId: DeviceId, saveAfter?: boolean): Promise; /** * Keeps the tombstone record inside its bound, oldest removals first. * * Pruning here WEAKENS the record -- a forgotten tombstone is a device a peer * may introduce again -- which is why this is loud and why the bound is loose. * There is no equivalent of `15`'s replay floors available: device ids are not * ordered, so a dropped entry leaves nothing behind that still refuses. */ private pruneRemovedDevices; /** * Requests a resilver of the vault */ requestResilver(): void; /** * Function to call when the server connection should be closed */ closeServerConnection(): void; } export default SyncManager;