Files
libsignal/node/ts/MessageBackup.ts

363 lines
12 KiB
TypeScript

//
// Copyright 2024 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
//
/**
* Message backup validation routines.
*
* @module MessageBackup
*/
import * as Native from './Native.js';
import { ErrorCode, LibSignalErrorBase } from './Errors.js';
import { BackupForwardSecrecyToken, BackupKey } from './AccountKeys.js';
import { Aci } from './Address.js';
import { InputStream } from './io.js';
export type InputStreamFactory = () => InputStream;
/**
* Result of validating a message backup bundle.
*/
export class ValidationOutcome {
/**
* A developer-facing message about the error encountered during validation,
* if any.
*/
public errorMessage: string | null;
/**
* Information about unknown fields encountered during validation.
*/
public unknownFieldMessages: string[];
/**
* `true` if the backup is valid, `false` otherwise.
*
* If this is `true`, there might still be messages about unknown fields.
*/
public get ok(): boolean {
return this.errorMessage == null;
}
constructor(outcome: Native.MessageBackupValidationOutcome) {
const { errorMessage, unknownFieldMessages } = outcome;
this.errorMessage = errorMessage;
this.unknownFieldMessages = unknownFieldMessages;
}
}
export type MessageBackupKeyInput = Readonly<
| {
accountEntropy: string;
aci: Aci;
forwardSecrecyToken?: BackupForwardSecrecyToken;
}
| {
backupKey: BackupKey | Uint8Array;
backupId: Uint8Array;
forwardSecrecyToken?: BackupForwardSecrecyToken;
}
>;
/**
* Key used to encrypt and decrypt a message backup bundle.
*
* @see {@link BackupKey}
*/
export class MessageBackupKey {
readonly _nativeHandle: Native.MessageBackupKey;
/**
* Create a backup bundle key from an account entropy pool and ACI.
*
* ...or from a backup key and ID, used when reading from a local backup, which may have been
* created with a different ACI.
*
* The account entropy pool must be **validated**; passing an arbitrary string here is considered
* a programmer error. Similarly, passing a backup key or ID of the wrong length is also an error.
*/
public constructor(input: MessageBackupKeyInput) {
if ('accountEntropy' in input) {
const { accountEntropy, aci, forwardSecrecyToken } = input;
this._nativeHandle = Native.MessageBackupKey_FromAccountEntropyPool(
accountEntropy,
aci.getServiceIdFixedWidthBinary(),
forwardSecrecyToken?.contents ?? null
);
} else {
const { backupId, forwardSecrecyToken } = input;
let { backupKey } = input;
if (backupKey instanceof BackupKey) {
backupKey = backupKey.contents;
}
this._nativeHandle = Native.MessageBackupKey_FromBackupKeyAndBackupId(
backupKey,
backupId,
forwardSecrecyToken?.contents ?? null
);
}
}
/** An HMAC key used to sign a backup file. */
public get hmacKey(): Uint8Array {
return Native.MessageBackupKey_GetHmacKey(this);
}
/** An AES-256-CBC key used to encrypt a backup file. */
public get aesKey(): Uint8Array {
return Native.MessageBackupKey_GetAesKey(this);
}
}
// This must match the Rust version of the enum.
export enum Purpose {
DeviceTransfer = 0,
RemoteBackup = 1,
TakeoutExport = 2,
}
/**
* Validate a backup file
*
* @param backupKey The key to use to decrypt the backup contents.
* @param purpose Whether the backup is intended for device-to-device transfer or remote storage.
* @param inputFactory A function that returns new input streams that read the backup contents.
* @param length The exact length of the input stream.
* @returns The outcome of validation, including any errors and warnings.
* @throws IoError If an IO error on the input occurs.
*
* @see OnlineBackupValidator
*/
export async function validate(
backupKey: MessageBackupKey,
purpose: Purpose,
inputFactory: InputStreamFactory,
length: bigint
): Promise<ValidationOutcome> {
let firstStream: InputStream | undefined;
let secondStream: InputStream | undefined;
try {
firstStream = inputFactory();
secondStream = inputFactory();
return new ValidationOutcome(
await Native.MessageBackupValidator_Validate(
backupKey,
firstStream,
secondStream,
length,
purpose
)
);
} finally {
await firstStream?.close();
await secondStream?.close();
}
}
/**
* An alternative to {@link validate()} that validates a backup frame-by-frame.
*
* This is much faster than using `validate()` because it bypasses the decryption and decompression
* steps, but that also means it's validating less. Don't forget to call `finalize()`!
*
* Unlike `validate()`, unknown fields are treated as "soft" errors and logged, rather than
* collected and returned to the app for processing.
*
* # Example
*
* ```
* const validator = new OnlineBackupValidator(
* backupInfoProto.serialize(),
* Purpose.deviceTransfer)
* repeat {
* // ...generate Frames...
* validator.addFrame(frameProto.serialize())
* }
* validator.finalize() // don't forget this!
* ```
*/
export class OnlineBackupValidator {
readonly _nativeHandle: Native.OnlineBackupValidator;
/**
* Initializes an OnlineBackupValidator from the given BackupInfo protobuf message.
*
* "Soft" errors will be logged, including unrecognized fields in the protobuf.
*
* @throws BackupValidationError on error
*/
constructor(backupInfo: Uint8Array, purpose: Purpose) {
this._nativeHandle = Native.OnlineBackupValidator_New(backupInfo, purpose);
}
/**
* Processes a single Frame protobuf message.
*
* "Soft" errors will be logged, including unrecognized fields in the protobuf.
*
* @throws BackupValidationError on error
*/
addFrame(frame: Uint8Array): void {
Native.OnlineBackupValidator_AddFrame(this, frame);
}
/**
* Marks that a backup is complete, and does any final checks that require whole-file knowledge.
*
* "Soft" errors will be logged.
*
* @throws BackupValidationError on error
*/
finalize(): void {
Native.OnlineBackupValidator_Finalize(this);
}
}
/**
* An in-memory representation of a backup file used to compare contents.
*
* When comparing the contents of two backups:
* 1. Create a `ComparableBackup` instance for each of the inputs.
* 2. Check the `unknownFields()` value; if it's not empty, some parts of the
* backup weren't parsed and won't be compared.
* 3. Produce a canonical string for each backup with `comparableString()`.
* 4. Compare the canonical string representations.
*
* The diff of the canonical strings (which may be rather large) will show the
* differences between the logical content of the input backup files.
*/
export class ComparableBackup {
readonly _nativeHandle: Native.ComparableBackup;
constructor(handle: Native.ComparableBackup) {
this._nativeHandle = handle;
}
/**
* Read an unencrypted backup file into memory for comparison.
*
* @param purpose Whether the backup is intended for device-to-device transfer or remote storage.
* @param input An input stream that reads the backup contents.
* @param length The exact length of the input stream.
* @returns The in-memory representation.
* @throws BackupValidationError If an IO error occurs or the input is invalid.
*/
public static async fromUnencrypted(
purpose: Purpose,
input: InputStream,
length: bigint
): Promise<ComparableBackup> {
const handle = await Native.ComparableBackup_ReadUnencrypted(
input,
length,
purpose
);
return new ComparableBackup(handle);
}
/**
* Produces a string representation of the contents.
*
* The returned strings for two backups will be equal if the backups contain
* the same logical content. If two backups' strings are not equal, the diff
* will show what is different between them.
*
* @returns a canonical string representation of the backup
*/
public comparableString(): string {
return Native.ComparableBackup_GetComparableString(this);
}
/**
* Unrecognized protobuf fields present in the backup.
*
* If this is not empty, some parts of the backup were not recognized and
* won't be present in the string representation.
*/
public get unknownFields(): Array<string> {
return Native.ComparableBackup_GetUnknownFields(this);
}
}
/**
* The output from processing a single frame for JSON export.
*
* There are four possibilities:
* - `line` present, `errorMessage` absent - the common case, a frame converted (and possibly sanitized)
* with no problems.
* - `line` present, `errorMessage` present - the frame has been converted, but would have failed
* validation.
* - `line` absent, `errorMessage` absent - the frame has been filtered out wholesale.
* - `line` absent, `errorMessage` present - the frame has been filtered out wholesale, but would have
* failed validation had it not been filtered out.
*/
export type BackupJsonFrameResult = {
line?: string;
errorMessage?: string;
};
export type BackupJsonFinishResult = { errorMessage?: string };
/**
* Streaming exporter that produces a human-readable JSON representation of a backup.
*
* Validation feedback returned by this exporter is best-effort and intended for logging or
* diagnostics. Even when a frame reports a validation error, the serialized line is still
* produced so consumers can continue streaming the export.
*/
export class BackupJsonExporter {
private constructor(readonly _nativeHandle: Native.BackupJsonExporter) {}
/**
* Initializes the streaming exporter and returns the first set of output lines.
* @param backupInfo The serialized BackupInfo protobuf without a varint header.
* @param [options] Additional configuration for the exporter.
* @param [options.validate=true] Whether to run semantic validation on the backup.
* @returns An object containing the exporter and the first chunk of output, containing the backup info.
* @throws Error if the input is invalid.
*/
public static start(
backupInfo: Uint8Array,
options?: { validate?: boolean }
): { exporter: BackupJsonExporter; chunk: string } {
const shouldValidate = options?.validate ?? true;
const handle = Native.BackupJsonExporter_New(backupInfo, shouldValidate);
const exporter = new BackupJsonExporter(handle);
const chunk = Native.BackupJsonExporter_GetInitialChunk(exporter);
return { exporter, chunk };
}
/**
* Validates and exports a human-readable JSON representation of backup frames.
* @param frames One or more varint delimited Frame serialized protobuf messages.
* @returns An array containing the line and any validation error for each frame.
* Frames that report validation errors still include their serialized `line`, so consumers
* should continue processing the export and surface the errors for observability rather than
* aborting.
* @throws Error if the input data cannot be parsed.
*/
public exportFrames(frames: Uint8Array): BackupJsonFrameResult[] {
return Native.BackupJsonExporter_ExportFrames(this, frames);
}
/**
* Completes the validation and export of the previously exported frames.
*
* Per-frame validation errors are reported via `exportFrames`, so callers
* should inspect earlier results even if this returns with no error.
* @returns The outcome of the final validation stage.
*/
public finish(): BackupJsonFinishResult {
try {
Native.BackupJsonExporter_Finish(this);
return {};
} catch (error: unknown) {
if (LibSignalErrorBase.is(error, ErrorCode.BackupValidation)) {
return { errorMessage: error.message };
}
throw error;
}
}
}