'use strict';
const { WhatsAppSessionCompatibilityError } = require('./SessionErrors');
const CANONICAL_SESSION_CODEC_KIND = 'wwebjs-canonical-session-v1';
const CANONICAL_SESSION_CODEC_VERSION = 1;
const CANONICAL_SESSION_SCHEMA_VERSION = 17;
const CANONICAL_PRIVATE_MODULE_ABI = 'wwebjs-private-modules-v1';
const CANONICAL_FINGERPRINT_VERSION =
'underchat-whatsapp-device-fingerprint-v2';
const DEFAULT_MAX_CANONICAL_BYTES = 64 * 1024 * 1024;
const DEFAULT_MAX_CANONICAL_RECORDS = 200000;
const MAX_SIGNAL_SESSION_BYTES = 8 * 1024 * 1024;
const MAX_SENDER_KEY_BYTES = 2 * 1024 * 1024;
const MAX_ROUTING_INFO_BYTES = 65535;
const PQ_PRE_KEY_ID_UPPER_BOUND = 0xffffff;
const PQ_PUBLIC_KEY_BYTES = 1568;
const PQ_PRIVATE_KEY_BYTES = 3168;
const PQ_SIGNATURE_BYTES = 64;
const PQ_CODEC_VERSION = 1;
const PQ_ALGORITHM = 'ML-KEM-1024';
const CANONICAL_PQ_KEY_MODE_GENERATE_AFTER_RECONNECT =
'generate_after_reconnect';
const CANONICAL_SESSION_STORE_ABI = Object.freeze({
schemaVersion: CANONICAL_SESSION_SCHEMA_VERSION,
codecKind: CANONICAL_SESSION_CODEC_KIND,
codecVersion: CANONICAL_SESSION_CODEC_VERSION,
moduleAbi: CANONICAL_PRIVATE_MODULE_ABI,
fingerprintVersion: CANONICAL_FINGERPRINT_VERSION,
signalScopes: Object.freeze(['default', 'status', 'pq']),
signalDomainTypes: Object.freeze([0, 1, 128, 129]),
lifecycleCapabilities: Object.freeze([
'active-canonical-replay-v1',
'two-phase-activation-v1',
'fenced-browser-termination-v1',
'canonical-pq-ml-kem-1024-v1',
'pq-upload-rollback-fence-v1',
'profile-anchor-canonical-checkpoint-v1',
]),
});
const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/;
const HEX_32_PATTERN = /^[a-f0-9]{64}$/;
const SIGNAL_SCOPES = new Set(['default', 'status', 'pq']);
const WHATSAPP_MEOW_PQ_SAFE_STORAGE_MODES = new Set([
'legacy_tables',
'rollout_without_tables',
]);
const PQ_SERVER_COUNT_MODES = new Set(['unknown', 'observed', 'reset']);
const CANONICAL_SIGNAL_ID_PATTERN = /^\d+(?:_(?:1|128|129))?:\d+$/;
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
const POSITIVE_REVISION_PATTERN = /^[1-9]\d*$/u;
const PQ_SERVER_ROLLBACK_PROTOCOL = 'delete_pq_prekeys_server_ack_v1';
const isObjectRecord = (value) =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const hasValidPqServerRollbackProof = (capabilities) =>
isObjectRecord(capabilities) &&
capabilities.pq_server_rollback_acknowledged === true &&
capabilities.pq_server_rollback_protocol === PQ_SERVER_ROLLBACK_PROTOCOL &&
UUID_PATTERN.test(
String(capabilities.pq_server_rollback_handoff_id || ''),
) &&
UUID_PATTERN.test(
String(capabilities.pq_server_rollback_lifecycle_operation_id || ''),
) &&
POSITIVE_REVISION_PATTERN.test(
String(capabilities.pq_server_rollback_source_revision_id || ''),
) &&
UUID_PATTERN.test(String(capabilities.pq_server_rollback_owner_id || '')) &&
POSITIVE_REVISION_PATTERN.test(
String(capabilities.pq_server_rollback_fencing_token || ''),
) &&
Number.isSafeInteger(capabilities.pq_server_rollback_generation) &&
capabilities.pq_server_rollback_generation > 0 &&
UUID_PATTERN.test(String(capabilities.pq_server_rollback_epoch || '')) &&
Number.isSafeInteger(capabilities.pq_server_rollback_acknowledged_at_ms) &&
capabilities.pq_server_rollback_acknowledged_at_ms > 0 &&
capabilities.pq_server_rollback_response_validated === true &&
capabilities.pq_upload_lifecycle_fenced === true &&
capabilities.pq_upload_lifecycle_fence_version === 1 &&
typeof capabilities.pq_runtime_upload_enabled === 'boolean' &&
typeof capabilities.pq_runtime_messaging_enabled === 'boolean';
const decodeBase64Length = (value) => {
if (
typeof value !== 'string' ||
value.length % 4 === 1 ||
!BASE64_PATTERN.test(value)
) {
return -1;
}
try {
return Buffer.from(value, 'base64').length;
} catch {
return -1;
}
};
const assertBase64 = (value, code, expectedLength) => {
const length = decodeBase64Length(value);
if (
length < 0 ||
(expectedLength !== undefined && length !== expectedLength)
) {
throw new WhatsAppSessionCompatibilityError(code);
}
return length;
};
const assertSafeInteger = (value, code, maximum = Number.MAX_SAFE_INTEGER) => {
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
throw new WhatsAppSessionCompatibilityError(code);
}
};
const assertAddress = (row, code) => {
if (
!isObjectRecord(row) ||
typeof row.their_id !== 'string' ||
!CANONICAL_SIGNAL_ID_PATTERN.test(row.their_id) ||
(row.scope !== undefined && !SIGNAL_SCOPES.has(row.scope))
) {
throw new WhatsAppSessionCompatibilityError(code);
}
};
/**
* WhatsMeow currently uses go.mau.fi/libsignal's classical X3DH wire and
* storage model. It cannot retain WWeb's ML-KEM prekeys or consume a pending
* PQXDH session. A handoff is therefore portable only when the source gives
* an explicit, internally consistent proof that post-quantum state is inactive
* and no PQ-scoped Signal session exists.
*
* This check deliberately fails closed for projections written by older
* bridges that did not publish the PQ capability fields. Silently treating an
* unknown state as classical could strand messages encrypted to a server-side
* Kyber prekey after the Chromium source has already been stopped.
*/
function assertCanonicalTargetProviderCompatibility(
projection,
targetProvider,
) {
if (targetProvider !== 'whatsmeow') return projection;
const capabilities = projection?.capabilities;
const signalSessions =
projection?.tables?.whatsapp_signal_sessions ??
projection?.tables?.signal_sessions;
const pqPreKeys =
projection?.tables?.whatsapp_pq_pre_keys ??
projection?.tables?.pq_pre_keys;
const pqPreKeyState =
projection?.tables?.whatsapp_pq_pre_key_state ??
projection?.tables?.pq_pre_key_state;
const stateKnown =
isObjectRecord(capabilities) &&
typeof capabilities.pq_migrated === 'boolean' &&
typeof capabilities.pq_upload_enabled === 'boolean' &&
typeof capabilities.pq_messaging_enabled === 'boolean' &&
WHATSAPP_MEOW_PQ_SAFE_STORAGE_MODES.has(capabilities.pq_storage_mode) &&
Number.isSafeInteger(capabilities.pq_pre_key_count) &&
capabilities.pq_pre_key_count >= 0 &&
Number.isSafeInteger(capabilities.pq_last_resort_key_count) &&
capabilities.pq_last_resort_key_count >= 0 &&
hasValidPqServerRollbackProof(capabilities) &&
Array.isArray(signalSessions) &&
Array.isArray(pqPreKeys) &&
Array.isArray(pqPreKeyState) &&
pqPreKeyState.length <= 1;
if (!stateKnown) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_whatsmeow_pqxdh_state_unknown',
{ target_provider: targetProvider },
);
}
const pqSignalSessionCount = signalSessions.filter(
(row) => row?.scope === 'pq',
).length;
const canonicalPqState = pqPreKeyState[0];
const serverRollbackAcknowledged =
hasValidPqServerRollbackProof(capabilities);
if (
capabilities.pq_migrated ||
(!serverRollbackAcknowledged && capabilities.pq_upload_enabled) ||
(!serverRollbackAcknowledged && capabilities.pq_messaging_enabled) ||
capabilities.pq_pre_key_count !== 0 ||
capabilities.pq_last_resort_key_count !== 0 ||
pqSignalSessionCount !== 0 ||
pqPreKeys.length !== 0 ||
canonicalPqState?.migrated === true
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_whatsmeow_pqxdh_state_unportable',
{
target_provider: targetProvider,
pq_migrated: capabilities.pq_migrated,
pq_upload_enabled: capabilities.pq_upload_enabled,
pq_messaging_enabled: capabilities.pq_messaging_enabled,
pq_storage_mode: capabilities.pq_storage_mode,
pq_pre_key_count: capabilities.pq_pre_key_count,
pq_last_resort_key_count: capabilities.pq_last_resort_key_count,
pq_signal_session_count: pqSignalSessionCount,
pq_server_rollback_acknowledged: serverRollbackAcknowledged,
},
);
}
return projection;
}
/**
* Validate the provider-neutral envelope before it crosses a trust boundary.
* This deliberately validates shape and sizes without parsing or logging any
* cryptographic value.
*/
function normalizeCanonicalProjection(
projection,
{
requireComplete = true,
maxBytes = DEFAULT_MAX_CANONICAL_BYTES,
maxRecords = DEFAULT_MAX_CANONICAL_RECORDS,
} = {},
) {
if (
!isObjectRecord(projection) ||
projection.kind !== CANONICAL_SESSION_CODEC_KIND ||
projection.codec_version !== CANONICAL_SESSION_CODEC_VERSION ||
projection.module_abi !== CANONICAL_PRIVATE_MODULE_ABI ||
typeof projection.web_version !== 'string' ||
projection.web_version.length === 0 ||
projection.fingerprint_version !== CANONICAL_FINGERPRINT_VERSION ||
typeof projection.complete !== 'boolean' ||
!Array.isArray(projection.blockers) ||
!projection.blockers.every(
(entry) =>
typeof entry === 'string' && /^[a-z0-9_.-]{1,100}$/.test(entry),
) ||
!isObjectRecord(projection.device) ||
!isObjectRecord(projection.tables)
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_projection_invalid',
);
}
if (requireComplete && projection.complete !== true) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_projection_incomplete',
{ blockers: projection.blockers },
);
}
const device = projection.device;
if (
typeof device.jid !== 'string' ||
!device.jid ||
(device.lid !== null && typeof device.lid !== 'string') ||
typeof device.adv_secret_available !== 'boolean' ||
!HEX_32_PATTERN.test(device.device_fingerprint)
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_device_invalid',
);
}
assertSafeInteger(
device.registration_id,
'wwebjs_canonical_registration_id_invalid',
0xffffffff,
);
assertSafeInteger(
device.signed_pre_key_id,
'wwebjs_canonical_signed_pre_key_id_invalid',
0xffffff,
);
assertSafeInteger(
device.next_pre_key_id,
'wwebjs_canonical_next_pre_key_id_invalid',
0x1000000,
);
let byteCount = 0;
const addBase64 = (value, code, length) => {
const decodedLength = assertBase64(value, code, length);
byteCount += decodedLength;
if (byteCount > maxBytes) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_projection_limit_exceeded',
);
}
return decodedLength;
};
addBase64(device.noise_key, 'wwebjs_canonical_noise_key_invalid', 32);
addBase64(device.identity_key, 'wwebjs_canonical_identity_key_invalid', 32);
addBase64(
device.signed_pre_key,
'wwebjs_canonical_signed_pre_key_invalid',
32,
);
addBase64(
device.signed_pre_key_sig,
'wwebjs_canonical_signed_pre_key_signature_invalid',
64,
);
if (device.adv_secret_available) {
addBase64(device.adv_key, 'wwebjs_canonical_adv_key_invalid', 32);
} else if (device.adv_key !== null) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_adv_capability_invalid',
);
}
addBase64(device.adv_details, 'wwebjs_canonical_adv_details_invalid');
addBase64(
device.adv_account_sig,
'wwebjs_canonical_adv_account_signature_invalid',
64,
);
addBase64(
device.adv_account_sig_key,
'wwebjs_canonical_adv_account_signature_key_invalid',
32,
);
addBase64(
device.adv_device_sig,
'wwebjs_canonical_adv_device_signature_invalid',
64,
);
if (projection.transport_state != null) {
const transportState = projection.transport_state;
if (
!isObjectRecord(transportState) ||
transportState.kind !== 'whatsapp-transport-v1' ||
transportState.codec_version !== 1
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_transport_state_invalid',
);
}
const routingInfoLength = addBase64(
transportState.routing_info,
'wwebjs_canonical_routing_info_invalid',
);
if (
routingInfoLength < 1 ||
routingInfoLength > MAX_ROUTING_INFO_BYTES
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_routing_info_invalid',
);
}
}
if (projection.provider_state !== null) {
const providerState = projection.provider_state;
if (
!isObjectRecord(providerState) ||
providerState.kind !== 'wwebjs-noise-bootstrap-v1' ||
providerState.codec_version !== 1 ||
providerState.device_fingerprint !== device.device_fingerprint ||
(providerState.certificate_chain !== null &&
typeof providerState.certificate_chain !== 'string')
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_provider_state_invalid',
);
}
addBase64(
providerState.noise_public_key,
'wwebjs_canonical_provider_state_invalid',
32,
);
addBase64(
providerState.recovery_token,
'wwebjs_canonical_provider_state_invalid',
24,
);
if (providerState.certificate_chain !== null) {
addBase64(
providerState.certificate_chain,
'wwebjs_canonical_provider_state_invalid',
);
}
}
const tableNames = [
'identity_keys',
'pre_keys',
'pq_pre_keys',
'pq_pre_key_state',
'signal_sessions',
'sender_keys',
'app_state_sync_keys',
'app_state_versions',
'app_state_mutation_macs',
];
let recordCount = 0;
for (const tableName of tableNames) {
const rows = projection.tables[tableName];
if (!Array.isArray(rows)) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_projection_table_invalid',
{ table: tableName },
);
}
recordCount += rows.length;
if (recordCount > maxRecords) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_projection_limit_exceeded',
);
}
}
const unique = (rows, key, code) => {
const keys = new Set();
for (const row of rows) {
const value = key(row);
if (keys.has(value)) {
throw new WhatsAppSessionCompatibilityError(code);
}
keys.add(value);
}
};
for (const row of projection.tables.identity_keys) {
assertAddress(row, 'wwebjs_canonical_identity_row_invalid');
addBase64(row.identity, 'wwebjs_canonical_identity_row_invalid', 32);
}
unique(
projection.tables.identity_keys,
(row) => row.their_id,
'wwebjs_canonical_identity_duplicate',
);
for (const row of projection.tables.pre_keys) {
if (!isObjectRecord(row) || typeof row.uploaded !== 'boolean') {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_pre_key_row_invalid',
);
}
assertSafeInteger(
row.key_id,
'wwebjs_canonical_pre_key_row_invalid',
0xffffff,
);
addBase64(row.key, 'wwebjs_canonical_pre_key_row_invalid', 32);
}
unique(
projection.tables.pre_keys,
(row) => String(row.key_id),
'wwebjs_canonical_pre_key_duplicate',
);
let pqOneTimeKeyCount = 0;
let pqLastResortKeyCount = 0;
for (const row of projection.tables.pq_pre_keys) {
if (
!isObjectRecord(row) ||
!['one_time', 'last_resort'].includes(row.key_kind) ||
typeof row.sent_to_server !== 'boolean'
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_pq_pre_key_row_invalid',
);
}
assertSafeInteger(
row.key_id,
'wwebjs_canonical_pq_pre_key_row_invalid',
PQ_PRE_KEY_ID_UPPER_BOUND - 1,
);
assertSafeInteger(
row.timestamp_ms,
'wwebjs_canonical_pq_pre_key_row_invalid',
);
addBase64(
row.public_key,
'wwebjs_canonical_pq_pre_key_row_invalid',
PQ_PUBLIC_KEY_BYTES,
);
addBase64(
row.private_key,
'wwebjs_canonical_pq_pre_key_row_invalid',
PQ_PRIVATE_KEY_BYTES,
);
addBase64(
row.signature,
'wwebjs_canonical_pq_pre_key_row_invalid',
PQ_SIGNATURE_BYTES,
);
if (row.key_kind === 'last_resort') pqLastResortKeyCount += 1;
else pqOneTimeKeyCount += 1;
}
unique(
projection.tables.pq_pre_keys,
(row) => String(row.key_id),
'wwebjs_canonical_pq_pre_key_duplicate',
);
if (pqLastResortKeyCount > 1) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_pq_last_resort_key_duplicate',
);
}
if (projection.tables.pq_pre_key_state.length > 1) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_pq_pre_key_state_duplicate',
);
}
const pqState = projection.tables.pq_pre_key_state[0];
let pqServerCountMode = 'reset';
if (pqState) {
if (
!isObjectRecord(pqState) ||
pqState.codec_version !== PQ_CODEC_VERSION ||
pqState.algorithm !== PQ_ALGORITHM ||
typeof pqState.migrated !== 'boolean'
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_pq_pre_key_state_invalid',
);
}
assertSafeInteger(
pqState.next_pre_key_id,
'wwebjs_canonical_pq_pre_key_state_invalid',
PQ_PRE_KEY_ID_UPPER_BOUND - 1,
);
const hasServerCount = pqState.last_server_count !== null;
const hasServerCountTimestamp =
pqState.last_server_count_timestamp_ms !== null;
if (hasServerCount !== hasServerCountTimestamp) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_pq_pre_key_state_invalid',
);
}
if (hasServerCount) {
assertSafeInteger(
pqState.last_server_count,
'wwebjs_canonical_pq_pre_key_state_invalid',
);
assertSafeInteger(
pqState.last_server_count_timestamp_ms,
'wwebjs_canonical_pq_pre_key_state_invalid',
);
}
pqServerCountMode =
projection.capabilities?.pq_server_count_mode ??
(hasServerCount ? 'observed' : 'reset');
if (
!PQ_SERVER_COUNT_MODES.has(pqServerCountMode) ||
(pqServerCountMode === 'observed' && !hasServerCount) ||
(pqServerCountMode === 'reset' && hasServerCount)
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_pq_server_count_mode_invalid',
);
}
} else if (projection.tables.pq_pre_keys.length > 0) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_pq_pre_key_state_missing',
);
}
if (
pqState &&
((pqState.migrated &&
(pqLastResortKeyCount !== 1 ||
!projection.tables.pq_pre_keys.find(
(row) => row.key_kind === 'last_resort',
)?.sent_to_server)) ||
(!pqState.migrated &&
projection.tables.pq_pre_keys.some(
(row) => row.sent_to_server,
)) ||
(!pqState.migrated &&
pqOneTimeKeyCount > 0 &&
pqLastResortKeyCount !== 1))
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_pq_migration_state_diverged',
);
}
const pqCapabilities = projection.capabilities;
if (
!isObjectRecord(pqCapabilities) ||
typeof pqCapabilities.pq_migrated !== 'boolean' ||
typeof pqCapabilities.pq_upload_enabled !== 'boolean' ||
typeof pqCapabilities.pq_messaging_enabled !== 'boolean' ||
!WHATSAPP_MEOW_PQ_SAFE_STORAGE_MODES.has(
pqCapabilities.pq_storage_mode,
) ||
!Number.isSafeInteger(pqCapabilities.pq_pre_key_count) ||
pqCapabilities.pq_pre_key_count < 0 ||
!Number.isSafeInteger(pqCapabilities.pq_last_resort_key_count) ||
pqCapabilities.pq_last_resort_key_count < 0 ||
pqCapabilities.pq_pre_key_count !== pqOneTimeKeyCount ||
pqCapabilities.pq_last_resort_key_count !== pqLastResortKeyCount ||
pqCapabilities.pq_migrated !== Boolean(pqState?.migrated) ||
(pqCapabilities.pq_storage_mode === 'legacy_tables') !==
Boolean(pqState) ||
(pqCapabilities.pq_storage_mode === 'rollout_without_tables' &&
(pqState || projection.tables.pq_pre_keys.length > 0)) ||
(!pqState &&
pqCapabilities.pq_server_count_mode !== undefined &&
pqCapabilities.pq_server_count_mode !== 'reset') ||
(pqState &&
(pqCapabilities.pq_server_count_mode ?? pqServerCountMode) !==
pqServerCountMode)
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_pq_capability_invalid',
);
}
const pqRollbackProofPresent = Object.keys(pqCapabilities).some((key) =>
key.startsWith('pq_server_rollback_'),
);
if (
pqRollbackProofPresent &&
(!hasValidPqServerRollbackProof(pqCapabilities) ||
pqCapabilities.pq_migrated !== false ||
pqCapabilities.pq_pre_key_count !== 0 ||
pqCapabilities.pq_last_resort_key_count !== 0 ||
projection.tables.pq_pre_keys.length !== 0 ||
projection.tables.signal_sessions.some(
(row) => row?.scope !== 'default',
) ||
JSON.stringify(pqCapabilities.canonical_signal_session_scopes) !==
JSON.stringify(['default']) ||
pqCapabilities.canonical_signal_session_resync_required !== true ||
JSON.stringify(
pqCapabilities.canonical_signal_session_resync_scopes,
) !== JSON.stringify(['status', 'pq']))
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_pq_server_rollback_proof_invalid',
);
}
for (const row of projection.tables.signal_sessions) {
assertAddress(row, 'wwebjs_canonical_signal_session_row_invalid');
if (!SIGNAL_SCOPES.has(row.scope)) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_signal_session_row_invalid',
);
}
const sessionBytes = addBase64(
row.session,
'wwebjs_canonical_signal_session_row_invalid',
);
if (sessionBytes === 0 || sessionBytes > MAX_SIGNAL_SESSION_BYTES) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_signal_session_limit_exceeded',
);
}
}
unique(
projection.tables.signal_sessions,
(row) => `${row.their_id}\0${row.scope}`,
'wwebjs_canonical_signal_session_duplicate',
);
for (const row of projection.tables.sender_keys) {
if (
!isObjectRecord(row) ||
typeof row.chat_id !== 'string' ||
!row.chat_id ||
typeof row.sender_id !== 'string' ||
!CANONICAL_SIGNAL_ID_PATTERN.test(row.sender_id)
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_sender_key_row_invalid',
);
}
const senderKeyBytes = addBase64(
row.sender_key,
'wwebjs_canonical_sender_key_row_invalid',
);
if (senderKeyBytes === 0 || senderKeyBytes > MAX_SENDER_KEY_BYTES) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_sender_key_limit_exceeded',
);
}
}
unique(
projection.tables.sender_keys,
(row) => `${row.chat_id}\0${row.sender_id}`,
'wwebjs_canonical_sender_key_duplicate',
);
for (const row of projection.tables.app_state_sync_keys) {
if (!isObjectRecord(row)) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_key_row_invalid',
);
}
assertSafeInteger(
row.timestamp,
'wwebjs_canonical_app_state_key_row_invalid',
);
if (decodeBase64Length(row.key_id) < 2) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_key_epoch_invalid',
);
}
addBase64(row.key_id, 'wwebjs_canonical_app_state_key_row_invalid');
addBase64(
row.key_data,
'wwebjs_canonical_app_state_key_row_invalid',
32,
);
addBase64(
row.fingerprint,
'wwebjs_canonical_app_state_key_row_invalid',
);
}
unique(
projection.tables.app_state_sync_keys,
(row) => row.key_id,
'wwebjs_canonical_app_state_key_duplicate',
);
for (const row of projection.tables.app_state_versions) {
if (!isObjectRecord(row) || typeof row.name !== 'string' || !row.name) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_version_row_invalid',
);
}
assertSafeInteger(
row.version,
'wwebjs_canonical_app_state_version_row_invalid',
);
addBase64(
row.hash,
'wwebjs_canonical_app_state_version_row_invalid',
128,
);
}
unique(
projection.tables.app_state_versions,
(row) => row.name,
'wwebjs_canonical_app_state_version_duplicate',
);
for (const row of projection.tables.app_state_mutation_macs) {
if (!isObjectRecord(row) || typeof row.name !== 'string' || !row.name) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_mac_row_invalid',
);
}
assertSafeInteger(
row.version,
'wwebjs_canonical_app_state_mac_row_invalid',
);
addBase64(
row.index_mac,
'wwebjs_canonical_app_state_mac_row_invalid',
32,
);
addBase64(
row.value_mac,
'wwebjs_canonical_app_state_mac_row_invalid',
32,
);
}
unique(
projection.tables.app_state_mutation_macs,
(row) => `${row.name}\0${row.index_mac}\0${row.version}`,
'wwebjs_canonical_app_state_mac_duplicate',
);
if (
projection.record_count !== recordCount ||
projection.size_bytes !== byteCount
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_projection_accounting_invalid',
);
}
return projection;
}
const cloneBytes = (value, code, expectedLength) => {
if (!Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
throw new WhatsAppSessionCompatibilityError(code);
}
const bytes = Buffer.from(value);
if (
bytes.length === 0 ||
(expectedLength !== undefined && bytes.length !== expectedLength)
) {
throw new WhatsAppSessionCompatibilityError(code);
}
return bytes;
};
const normalizeNonImportableSignalSessions = (rows) => {
if (!Array.isArray(rows)) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_store_projection_invalid',
);
}
const seen = new Set();
return rows
.filter((row) => row?.session === null)
.map((row) => {
assertAddress(row, 'wwebjs_canonical_signal_session_row_invalid');
if (!SIGNAL_SCOPES.has(row.scope)) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_signal_session_row_invalid',
);
}
const key = `${row.their_id}\0${row.scope}`;
if (seen.has(key)) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_signal_session_duplicate',
);
}
seen.add(key);
return {
their_id: row.their_id,
scope: row.scope,
session: null,
};
});
};
/**
* Convert the page-realm envelope to the public/store ABI. The browser uses
* base64 because Puppeteer must cross a structured-clone boundary; callers and
* PostgreSQL always receive byte arrays instead.
*/
function canonicalBrowserProjectionToStore(
projection,
{ requireComplete = true } = {},
) {
const normalized = normalizeCanonicalProjection(projection, {
requireComplete,
});
const decode = (value) => Buffer.from(value, 'base64');
const providerState = normalized.provider_state
? {
kind: normalized.provider_state.kind,
codec_version: normalized.provider_state.codec_version,
device_fingerprint: Buffer.from(
normalized.provider_state.device_fingerprint,
'hex',
),
noise_public_key: decode(
normalized.provider_state.noise_public_key,
),
recovery_token: decode(normalized.provider_state.recovery_token),
certificate_chain:
normalized.provider_state.certificate_chain === null
? null
: decode(normalized.provider_state.certificate_chain),
}
: null;
const transportState = normalized.transport_state
? {
kind: normalized.transport_state.kind,
codec_version: normalized.transport_state.codec_version,
routing_info: decode(normalized.transport_state.routing_info),
}
: null;
return {
schema_version: CANONICAL_SESSION_SCHEMA_VERSION,
codec_kind: CANONICAL_SESSION_CODEC_KIND,
codec_version: CANONICAL_SESSION_CODEC_VERSION,
module_abi: CANONICAL_PRIVATE_MODULE_ABI,
web_version: normalized.web_version,
fingerprint_version: CANONICAL_FINGERPRINT_VERSION,
complete: normalized.complete,
blockers: [...normalized.blockers],
capabilities: {
...normalized.capabilities,
pq_server_count_mode:
normalized.capabilities.pq_server_count_mode ??
(normalized.tables.pq_pre_key_state[0] &&
normalized.tables.pq_pre_key_state[0].last_server_count !== null
? 'observed'
: 'reset'),
},
device: {
jid: normalized.device.jid,
lid: normalized.device.lid,
facebook_uuid: normalized.device.facebook_uuid || null,
registration_id: normalized.device.registration_id,
noise_key: decode(normalized.device.noise_key),
identity_key: decode(normalized.device.identity_key),
signed_pre_key: decode(normalized.device.signed_pre_key),
signed_pre_key_id: normalized.device.signed_pre_key_id,
signed_pre_key_sig: decode(normalized.device.signed_pre_key_sig),
adv_secret_available: normalized.device.adv_secret_available,
adv_key:
normalized.device.adv_key === null
? null
: decode(normalized.device.adv_key),
adv_details: decode(normalized.device.adv_details),
adv_account_sig: decode(normalized.device.adv_account_sig),
adv_account_sig_key: decode(normalized.device.adv_account_sig_key),
adv_device_sig: decode(normalized.device.adv_device_sig),
platform: normalized.device.platform || 'web',
business_name: normalized.device.business_name || '',
push_name: normalized.device.push_name || '',
lid_migration_ts: normalized.device.lid_migration_ts || 0,
next_pre_key_id: normalized.device.next_pre_key_id,
device_fingerprint: Buffer.from(
normalized.device.device_fingerprint,
'hex',
),
fingerprint_version: CANONICAL_FINGERPRINT_VERSION,
},
provider_state: providerState,
transport_state: transportState,
tables: {
whatsapp_identity_keys: normalized.tables.identity_keys.map(
(row) => ({
their_id: row.their_id,
identity: decode(row.identity),
}),
),
whatsapp_pre_keys: normalized.tables.pre_keys.map((row) => ({
key_id: row.key_id,
key: decode(row.key),
uploaded: row.uploaded,
})),
whatsapp_pq_pre_keys: normalized.tables.pq_pre_keys.map((row) => ({
key_id: row.key_id,
key_kind: row.key_kind,
public_key: decode(row.public_key),
private_key: decode(row.private_key),
signature: decode(row.signature),
timestamp_ms: row.timestamp_ms,
sent_to_server: row.sent_to_server,
})),
whatsapp_pq_pre_key_state: normalized.tables.pq_pre_key_state.map(
(row) => ({
codec_version: row.codec_version,
algorithm: row.algorithm,
next_pre_key_id: row.next_pre_key_id,
migrated: row.migrated,
last_server_count: row.last_server_count,
last_server_count_timestamp_ms:
row.last_server_count_timestamp_ms,
}),
),
whatsapp_signal_sessions: normalized.tables.signal_sessions.map(
(row) => ({
their_id: row.their_id,
scope: row.scope,
session: decode(row.session),
}),
),
whatsapp_sender_keys: normalized.tables.sender_keys.map((row) => ({
chat_id: row.chat_id,
sender_id: row.sender_id,
sender_key: decode(row.sender_key),
})),
whatsapp_app_state_sync_keys:
normalized.tables.app_state_sync_keys.map((row) => ({
key_id: decode(row.key_id),
key_data: decode(row.key_data),
timestamp: row.timestamp,
fingerprint: decode(row.fingerprint),
})),
whatsapp_app_state_version:
normalized.tables.app_state_versions.map((row) => ({
name: row.name,
version: row.version,
hash: decode(row.hash),
})),
whatsapp_app_state_mutation_macs:
normalized.tables.app_state_mutation_macs.map((row) => ({
name: row.name,
version: row.version,
index_mac: decode(row.index_mac),
value_mac: decode(row.value_mac),
})),
},
record_count: normalized.record_count,
size_bytes: normalized.size_bytes,
};
}
/** Convert the public/store ABI back to the page-realm envelope. */
function canonicalStoreProjectionToBrowser(projection, { webVersion } = {}) {
if (
!isObjectRecord(projection) ||
projection.schema_version !== CANONICAL_SESSION_SCHEMA_VERSION ||
projection.codec_kind !== CANONICAL_SESSION_CODEC_KIND ||
projection.codec_version !== CANONICAL_SESSION_CODEC_VERSION ||
projection.fingerprint_version !== CANONICAL_FINGERPRINT_VERSION ||
!isObjectRecord(projection.device) ||
!isObjectRecord(projection.tables)
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_store_projection_invalid',
);
}
const device = projection.device;
if (
device.fingerprint_version !== CANONICAL_FINGERPRINT_VERSION ||
typeof device.adv_secret_available !== 'boolean'
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_store_device_invalid',
);
}
const encode = (value, code, expectedLength) =>
cloneBytes(value, code, expectedLength).toString('base64');
const tables = projection.tables;
const requiredTables = [
'whatsapp_identity_keys',
'whatsapp_pre_keys',
'whatsapp_pq_pre_keys',
'whatsapp_pq_pre_key_state',
'whatsapp_signal_sessions',
'whatsapp_sender_keys',
'whatsapp_app_state_sync_keys',
'whatsapp_app_state_version',
'whatsapp_app_state_mutation_macs',
];
if (requiredTables.some((name) => !Array.isArray(tables[name]))) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_store_projection_invalid',
);
}
const nonImportableSignalSessions = normalizeNonImportableSignalSessions(
tables.whatsapp_signal_sessions,
);
const signalSessionKeys = new Set();
for (const row of tables.whatsapp_signal_sessions) {
assertAddress(row, 'wwebjs_canonical_signal_session_row_invalid');
if (!SIGNAL_SCOPES.has(row.scope)) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_signal_session_row_invalid',
);
}
const key = `${row.their_id}\0${row.scope}`;
if (signalSessionKeys.has(key)) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_signal_session_duplicate',
);
}
signalSessionKeys.add(key);
}
const providerState = projection.provider_state;
const transportState = projection.transport_state;
const pqState = tables.whatsapp_pq_pre_key_state[0];
const pqOneTimeKeyCount = tables.whatsapp_pq_pre_keys.filter(
(row) => row?.key_kind === 'one_time',
).length;
const pqLastResortKeyCount = tables.whatsapp_pq_pre_keys.filter(
(row) => row?.key_kind === 'last_resort',
).length;
const capabilities = {
...(isObjectRecord(projection.capabilities)
? projection.capabilities
: {
adv_secret_available: device.adv_secret_available,
passkey_handoff_supported: device.adv_secret_available,
signal_scopes: ['default', 'status', 'pq'],
signal_domain_types: [0, 1, 128, 129],
app_state_import_mode: 'snapshot_resync',
canonical_signal_session_scopes: ['default', 'status', 'pq'],
canonical_signal_session_resync_required: false,
canonical_signal_session_resync_scopes: [],
}),
};
capabilities.pq_migrated ??= Boolean(pqState?.migrated);
capabilities.pq_upload_enabled ??= Boolean(pqState?.migrated);
capabilities.pq_messaging_enabled ??= Boolean(pqState?.migrated);
capabilities.pq_storage_mode ??= pqState
? 'legacy_tables'
: 'rollout_without_tables';
capabilities.pq_pre_key_count ??= pqOneTimeKeyCount;
capabilities.pq_last_resort_key_count ??= pqLastResortKeyCount;
capabilities.pq_server_count_mode ??=
pqState?.last_server_count !== null &&
pqState?.last_server_count !== undefined
? 'observed'
: 'reset';
const browser = {
kind: CANONICAL_SESSION_CODEC_KIND,
codec_version: CANONICAL_SESSION_CODEC_VERSION,
module_abi: CANONICAL_PRIVATE_MODULE_ABI,
web_version: webVersion || projection.web_version || 'unknown',
fingerprint_version: CANONICAL_FINGERPRINT_VERSION,
complete: projection.complete !== false,
blockers: Array.isArray(projection.blockers)
? [...projection.blockers]
: [],
capabilities,
device: {
jid: device.jid,
lid: device.lid ?? null,
facebook_uuid: device.facebook_uuid ?? null,
registration_id: Number(device.registration_id),
noise_key: encode(
device.noise_key,
'wwebjs_canonical_noise_key_invalid',
32,
),
identity_key: encode(
device.identity_key,
'wwebjs_canonical_identity_key_invalid',
32,
),
signed_pre_key: encode(
device.signed_pre_key,
'wwebjs_canonical_signed_pre_key_invalid',
32,
),
signed_pre_key_id: Number(device.signed_pre_key_id),
signed_pre_key_sig: encode(
device.signed_pre_key_sig,
'wwebjs_canonical_signed_pre_key_signature_invalid',
64,
),
adv_secret_available: device.adv_secret_available,
adv_key: device.adv_secret_available
? encode(device.adv_key, 'wwebjs_canonical_adv_key_invalid', 32)
: null,
adv_details: encode(
device.adv_details,
'wwebjs_canonical_adv_details_invalid',
),
adv_account_sig: encode(
device.adv_account_sig,
'wwebjs_canonical_adv_account_signature_invalid',
64,
),
adv_account_sig_key: encode(
device.adv_account_sig_key,
'wwebjs_canonical_adv_account_signature_key_invalid',
32,
),
adv_device_sig: encode(
device.adv_device_sig,
'wwebjs_canonical_adv_device_signature_invalid',
64,
),
platform: device.platform || 'web',
business_name: device.business_name || '',
push_name: device.push_name || '',
lid_migration_ts: Number(device.lid_migration_ts || 0),
next_pre_key_id: Number(device.next_pre_key_id),
device_fingerprint: cloneBytes(
device.device_fingerprint,
'wwebjs_canonical_device_fingerprint_invalid',
32,
).toString('hex'),
},
provider_state:
providerState == null
? null
: {
kind: providerState.kind,
codec_version: providerState.codec_version,
device_fingerprint: cloneBytes(
providerState.device_fingerprint,
'wwebjs_canonical_provider_state_invalid',
32,
).toString('hex'),
noise_public_key: encode(
providerState.noise_public_key,
'wwebjs_canonical_provider_state_invalid',
32,
),
recovery_token: encode(
providerState.recovery_token,
'wwebjs_canonical_provider_state_invalid',
24,
),
certificate_chain:
providerState.certificate_chain == null
? null
: encode(
providerState.certificate_chain,
'wwebjs_canonical_provider_state_invalid',
),
},
transport_state:
transportState == null
? null
: {
kind: transportState.kind,
codec_version: transportState.codec_version,
routing_info: encode(
transportState.routing_info,
'wwebjs_canonical_routing_info_invalid',
),
},
tables: {
identity_keys: tables.whatsapp_identity_keys.map((row) => ({
their_id: row.their_id,
identity: encode(
row.identity,
'wwebjs_canonical_identity_row_invalid',
32,
),
})),
pre_keys: tables.whatsapp_pre_keys.map((row) => ({
key_id: Number(row.key_id),
key: encode(
row.key,
'wwebjs_canonical_pre_key_row_invalid',
32,
),
uploaded: row.uploaded,
})),
pq_pre_keys: tables.whatsapp_pq_pre_keys.map((row) => ({
key_id: Number(row.key_id),
key_kind: row.key_kind,
public_key: encode(
row.public_key,
'wwebjs_canonical_pq_pre_key_row_invalid',
PQ_PUBLIC_KEY_BYTES,
),
private_key: encode(
row.private_key,
'wwebjs_canonical_pq_pre_key_row_invalid',
PQ_PRIVATE_KEY_BYTES,
),
signature: encode(
row.signature,
'wwebjs_canonical_pq_pre_key_row_invalid',
PQ_SIGNATURE_BYTES,
),
timestamp_ms: Number(row.timestamp_ms),
sent_to_server: row.sent_to_server,
})),
pq_pre_key_state: tables.whatsapp_pq_pre_key_state.map((row) => ({
codec_version: Number(row.codec_version),
algorithm: row.algorithm,
next_pre_key_id: Number(row.next_pre_key_id),
migrated: row.migrated,
last_server_count:
row.last_server_count === null
? null
: Number(row.last_server_count),
last_server_count_timestamp_ms:
row.last_server_count_timestamp_ms === null
? null
: Number(row.last_server_count_timestamp_ms),
})),
// A NULL canonical session is meaningful to the native stores but
// has no valid WWebJS IndexedDB representation. Keep it in the
// PostgreSQL/store ABI and deliberately omit it from the browser
// import envelope. PostgresSessionStore preserves these rows on
// subsequent WWeb-only checkpoints.
signal_sessions: tables.whatsapp_signal_sessions
.filter((row) => row.session !== null)
.map((row) => ({
their_id: row.their_id,
scope: row.scope,
session: encode(
row.session,
'wwebjs_canonical_signal_session_row_invalid',
),
})),
sender_keys: tables.whatsapp_sender_keys.map((row) => ({
chat_id: row.chat_id,
sender_id: row.sender_id,
sender_key: encode(
row.sender_key,
'wwebjs_canonical_sender_key_row_invalid',
),
})),
app_state_sync_keys: tables.whatsapp_app_state_sync_keys.map(
(row) => ({
key_id: encode(
row.key_id,
'wwebjs_canonical_app_state_key_row_invalid',
),
key_data: encode(
row.key_data,
'wwebjs_canonical_app_state_key_row_invalid',
32,
),
timestamp: Number(row.timestamp),
fingerprint: encode(
row.fingerprint,
'wwebjs_canonical_app_state_key_row_invalid',
),
}),
),
app_state_versions: tables.whatsapp_app_state_version.map(
(row) => ({
name: row.name,
version: Number(row.version),
hash: encode(
row.hash,
'wwebjs_canonical_app_state_version_row_invalid',
128,
),
}),
),
app_state_mutation_macs:
tables.whatsapp_app_state_mutation_macs.map((row) => ({
name: row.name,
version: Number(row.version),
index_mac: encode(
row.index_mac,
'wwebjs_canonical_app_state_mac_row_invalid',
32,
),
value_mac: encode(
row.value_mac,
'wwebjs_canonical_app_state_mac_row_invalid',
32,
),
})),
},
};
browser.record_count = Object.values(browser.tables).reduce(
(total, rows) => total + rows.length,
0,
);
const encodedValues = [
browser.device.noise_key,
browser.device.identity_key,
browser.device.signed_pre_key,
browser.device.signed_pre_key_sig,
browser.device.adv_details,
browser.device.adv_account_sig,
browser.device.adv_account_sig_key,
browser.device.adv_device_sig,
...(browser.device.adv_key ? [browser.device.adv_key] : []),
...(browser.provider_state
? [
browser.provider_state.noise_public_key,
browser.provider_state.recovery_token,
...(browser.provider_state.certificate_chain
? [browser.provider_state.certificate_chain]
: []),
]
: []),
...(browser.transport_state
? [browser.transport_state.routing_info]
: []),
...browser.tables.identity_keys.map((row) => row.identity),
...browser.tables.pre_keys.map((row) => row.key),
...browser.tables.pq_pre_keys.flatMap((row) => [
row.public_key,
row.private_key,
row.signature,
]),
...browser.tables.signal_sessions.map((row) => row.session),
...browser.tables.sender_keys.map((row) => row.sender_key),
...browser.tables.app_state_sync_keys.flatMap((row) => [
row.key_id,
row.key_data,
row.fingerprint,
]),
...browser.tables.app_state_versions.map((row) => row.hash),
...browser.tables.app_state_mutation_macs.flatMap((row) => [
row.index_mac,
row.value_mac,
]),
];
browser.size_bytes = encodedValues.reduce(
(total, value) => total + Buffer.from(value, 'base64').length,
0,
);
const normalized = normalizeCanonicalProjection(browser);
Object.defineProperty(normalized, 'non_importable_signal_session_count', {
configurable: true,
enumerable: false,
value: nonImportableSignalSessions.length,
});
return normalized;
}
/* This function is serialized by Puppeteer and executes in the page realm. */
async function exportCanonicalSessionProjection(options) {
const blockers = new Set();
const diagnostics = {};
let recordCount = 0;
let sizeBytes = 0;
const pqRollback = options?.pqServerRollback;
const pqRollbackRequested = pqRollback !== undefined;
const pqRollbackValid =
pqRollbackRequested &&
Boolean(pqRollback) &&
pqRollback.protocol === 'delete_pq_prekeys_server_ack_v1' &&
pqRollback.server_acknowledged === true &&
pqRollback.local_cleanup_complete === true &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
String(pqRollback.handoff_id || ''),
) &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
String(pqRollback.lifecycle_operation_id || ''),
) &&
/^[1-9]\d*$/u.test(String(pqRollback.source_revision_id || '')) &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
String(pqRollback.owner_id || ''),
) &&
/^[1-9]\d*$/u.test(String(pqRollback.fencing_token || '')) &&
Number.isSafeInteger(pqRollback.generation) &&
pqRollback.generation > 0 &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
String(pqRollback.epoch || ''),
) &&
Number.isSafeInteger(pqRollback.acknowledged_at_ms) &&
pqRollback.acknowledged_at_ms > 0 &&
pqRollback.response_validated === true &&
pqRollback.upload_lifecycle_fenced === true &&
pqRollback.upload_lifecycle_fence_version === 1 &&
typeof pqRollback.runtime_upload_enabled === 'boolean' &&
typeof pqRollback.runtime_messaging_enabled === 'boolean';
if (pqRollbackRequested && !pqRollbackValid) {
blockers.add('post_quantum.server_rollback_proof_invalid');
}
const requireModule = (name) => {
try {
const moduleValue = window.require?.(name);
if (!moduleValue) throw new Error('missing');
return moduleValue.default || moduleValue;
} catch {
blockers.add(`module.${name}.unavailable`.toLowerCase());
return undefined;
}
};
const toBytes = (value) => {
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (ArrayBuffer.isView(value)) {
return new Uint8Array(
value.buffer,
value.byteOffset,
value.byteLength,
);
}
return undefined;
};
const toBase64 = (value, expectedLength) => {
const bytes = toBytes(value);
if (
!bytes ||
(expectedLength !== undefined &&
bytes.byteLength !== expectedLength)
) {
return undefined;
}
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(
...bytes.subarray(offset, offset + 0x8000),
);
}
sizeBytes += bytes.byteLength;
if (sizeBytes > options.maxBytes) {
throw new Error('wwebjs_canonical_projection_limit_exceeded');
}
return btoa(binary);
};
const fromBase64 = (value) => {
if (typeof value !== 'string') return undefined;
try {
const binary = atob(value);
return Uint8Array.from(binary, (entry) => entry.charCodeAt(0));
} catch {
return undefined;
}
};
const addRows = (rows) => {
recordCount += rows.length;
if (recordCount > options.maxRecords) {
throw new Error('wwebjs_canonical_projection_limit_exceeded');
}
return rows;
};
const all = async (table, blocker) => {
if (!table || typeof table.all !== 'function') {
blockers.add(blocker);
return [];
}
try {
const rows = await table.all();
if (!Array.isArray(rows)) throw new Error('invalid');
return rows;
} catch {
blockers.add(blocker);
return [];
}
};
const widString = (value) => {
if (typeof value === 'string') return value;
if (!value || typeof value !== 'object') return undefined;
for (const field of ['_serialized', 'serialized', 'id']) {
if (typeof value[field] === 'string') return value[field];
}
try {
const jid = value.toJid?.();
if (typeof jid === 'string') return jid;
} catch {
// Try the legacy string representation below.
}
try {
const legacy = value.toString?.({ legacy: true });
if (typeof legacy === 'string' && legacy !== '[object Object]') {
return legacy;
}
} catch {
// The caller treats an unrenderable WID as an incomplete export.
}
return undefined;
};
const parseSignalAddress = (value) => {
if (typeof value !== 'string' || !value.endsWith('.0')) return null;
let address = value.slice(0, -2);
let scope = 'default';
if (address.endsWith('_status')) {
address = address.slice(0, -'_status'.length);
scope = 'status';
} else if (address.endsWith('_pq')) {
address = address.slice(0, -'_pq'.length);
scope = 'pq';
}
const separator = address.lastIndexOf('@');
if (separator <= 0) return null;
const head = address.slice(0, separator);
const server = address.slice(separator + 1);
const match = /^(\d+)(?::(\d+))?$/.exec(head);
if (!match) return null;
const domainType =
server === 'lid'
? 1
: server === 'hosted'
? 128
: server === 'hosted.lid'
? 129
: server === 'c.us' || server === 's.whatsapp.net'
? 0
: undefined;
if (domainType === undefined) return null;
const signalUser =
domainType === 0 ? match[1] : `${match[1]}_${domainType}`;
return {
their_id: `${signalUser}:${Number(match[2] || 0)}`,
scope,
};
};
const encodeProto = (encoder, spec, value) => {
try {
const encoded = encoder.encodeProtobuf(spec, value);
return encoded.readByteArrayView();
} catch {
return undefined;
}
};
const encodeVarint = (input) => {
let value = Number(input);
const output = [];
do {
let byte = value % 128;
value = Math.floor(value / 128);
if (value > 0) byte |= 0x80;
output.push(byte);
} while (value > 0);
return output;
};
const encodeFingerprint = (fingerprint) => {
if (!fingerprint || typeof fingerprint !== 'object') return undefined;
const rawId = Number(fingerprint.rawId);
const currentIndex = Number(fingerprint.currentIndex);
const indexes = fingerprint.deviceIndexes;
if (
!Number.isSafeInteger(rawId) ||
rawId < 0 ||
!Number.isSafeInteger(currentIndex) ||
currentIndex < 0 ||
!Array.isArray(indexes) ||
!indexes.every((entry) => Number.isSafeInteger(entry) && entry >= 0)
) {
return undefined;
}
const packed = indexes.flatMap(encodeVarint);
return Uint8Array.from([
8,
...encodeVarint(rawId),
16,
...encodeVarint(currentIndex),
...(packed.length > 0
? [26, ...encodeVarint(packed.length), ...packed]
: []),
]);
};
const domainDigest = async (domainValue, fields) => {
const domain = new TextEncoder().encode(domainValue);
const normalized = fields.map((field) => {
const bytes = toBytes(field);
return bytes ? new Uint8Array(bytes) : undefined;
});
if (normalized.some((field) => !field || field.byteLength === 0)) {
return undefined;
}
const total = normalized.reduce(
(sum, field) => sum + 4 + field.byteLength,
domain.byteLength,
);
const payload = new Uint8Array(total);
payload.set(domain);
const view = new DataView(payload.buffer);
let offset = domain.byteLength;
for (const field of normalized) {
view.setUint32(offset, field.byteLength, false);
offset += 4;
payload.set(field, offset);
offset += field.byteLength;
}
try {
const digest = new Uint8Array(
await crypto.subtle.digest('SHA-256', payload),
);
return [...digest]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
} finally {
payload.fill(0);
for (const field of normalized) field.fill(0);
}
};
const fingerprintDigest = (fields) =>
domainDigest('underchat-whatsapp-device-fingerprint-v2', fields);
const signalApi = requireModule('WAWebSignalStoreApi');
const signalStorage = requireModule('WAWebSignalStorage');
const kyberStore = requireModule('WAWebKyberPreKeyStore');
const pqGating = requireModule('WAWebPQGatingUtils');
const signalConst = requireModule('WAWebSignalConst');
const signalCommon = requireModule('WAWebSignalCommonUtils');
const signalConvert = requireModule('WAWebSignalConvertApi');
const sessionsApi = requireModule('WASignalSessions');
const groupSessionsApi = requireModule('WASignalGroupSession');
const localStorageProto = requireModule('WASignalLocalStorageProtocol.pb');
const protobufEncoder = requireModule('encodeProtobuf');
const noiseStoreModule = requireModule('WAWebUserPrefsInfoStore');
const multiDevice = requireModule('WAWebUserPrefsMultiDevice');
const syncdDb = requireModule('WAWebSyncdDb');
const collectionSchema = requireModule('WAWebSchemaCollectionVersion');
const syncActionsSchema = requireModule('WAWebSchemaSyncActions');
const connectionModule = requireModule('WAWebConnModel');
const meUserPrefs = requireModule('WAWebUserPrefsMeUser');
const userPrefsGeneral = requireModule('WAWebUserPrefsGeneral');
const mobilePlatforms = requireModule('WAWebMobilePlatforms');
const lidMigration = requireModule('WAWebLid1X1MigrationGating');
const signalStore = signalApi?.waSignalStore;
const noiseStore = noiseStoreModule?.waNoiseInfo;
if (
!signalStore ||
typeof signalStore.getRegistrationInfo !== 'function' ||
typeof signalStore.getSignedPreKey !== 'function' ||
typeof signalStore.getADVSignedIdentity !== 'function' ||
typeof signalStorage?.initialize !== 'function' ||
typeof kyberStore?.isPQMigrated !== 'function' ||
typeof kyberStore?.setPQMigrated !== 'function' ||
typeof kyberStore?.loadKyberKeysForDigest !== 'function' ||
typeof pqGating?.isPqKeysUploadEnabled !== 'function' ||
typeof pqGating?.isPq1on1MessageEnabled !== 'function' ||
typeof sessionsApi?.serializeSession !== 'function' ||
typeof groupSessionsApi?.serializeSession !== 'function' ||
!localStorageProto?.RecordStructureSpec ||
!localStorageProto?.SenderKeyRecordStructureSpec ||
typeof protobufEncoder?.encodeProtobuf !== 'function' ||
typeof noiseStore?.get !== 'function' ||
typeof multiDevice?.getRoutingInfo !== 'function' ||
typeof userPrefsGeneral?.getLastMobilePlatform !== 'function' ||
typeof userPrefsGeneral?.getPushname !== 'function' ||
typeof lidMigration?.Lid1X1MigrationUtils?.isLidMigrated !== 'function'
) {
blockers.add('module_abi.incompatible');
}
try {
await signalStorage?.initialize?.();
} catch {
blockers.add('signal_storage.initialize_failed');
}
const safeRead = async (read, blocker) => {
if (typeof read !== 'function') {
blockers.add(blocker);
return undefined;
}
try {
return await read();
} catch {
blockers.add(blocker);
return undefined;
}
};
const [registration, signedPreKey, advIdentity, noiseInfo, routingInfo] =
await Promise.all([
safeRead(
() => signalStore.getRegistrationInfo(),
'device.registration_info_read_failed',
),
safeRead(
() => signalStore.getSignedPreKey(),
'device.signed_pre_key_read_failed',
),
safeRead(
() => signalStore.getADVSignedIdentity(),
'device.adv_identity_read_failed',
),
safeRead(() => noiseStore.get(), 'device.noise_info_read_failed'),
safeRead(
() => multiDevice.getRoutingInfo(),
'transport.routing_info_read_failed',
),
]);
const requiredBytes = (value, length, blocker) => {
const bytes = toBytes(value);
if (!bytes || bytes.byteLength !== length) {
blockers.add(blocker);
return undefined;
}
return new Uint8Array(bytes);
};
const identityPublic = requiredBytes(
registration?.identityKeyPair?.pubKey,
32,
'device.identity_public_missing',
);
const identityPrivate = requiredBytes(
registration?.identityKeyPair?.privKey,
32,
'device.identity_private_missing',
);
const noisePrivate = requiredBytes(
noiseInfo?.staticKeyPair?.privKey,
32,
'device.noise_private_missing',
);
const noisePublic = requiredBytes(
noiseInfo?.staticKeyPair?.pubKey,
32,
'device.noise_public_missing',
);
const noiseRecoveryToken = requiredBytes(
noiseInfo?.recoveryToken,
24,
'device.noise_recovery_token_missing',
);
const noiseCertificateChain = noiseInfo?.certificateChainBuffer
? toBytes(noiseInfo.certificateChainBuffer)
: undefined;
const routingInfoBytes = toBytes(routingInfo?.edgeRouting ?? routingInfo);
if (
!routingInfoBytes ||
routingInfoBytes.byteLength < 1 ||
routingInfoBytes.byteLength > 65535
) {
blockers.add('transport.routing_info_missing');
}
const signedPrivate = requiredBytes(
signedPreKey?.keyPair?.privKey,
32,
'device.signed_pre_key_missing',
);
const signedSignature = requiredBytes(
signedPreKey?.signature,
64,
'device.signed_pre_key_signature_missing',
);
const advDetails = toBytes(advIdentity?.details);
const advAccountSignature = requiredBytes(
advIdentity?.accountSignature,
64,
'device.adv_account_signature_missing',
);
const advAccountSignatureKey = requiredBytes(
advIdentity?.accountSignatureKey,
32,
'device.adv_account_signature_key_missing',
);
const advDeviceSignature = requiredBytes(
advIdentity?.deviceSignature,
64,
'device.adv_device_signature_missing',
);
if (!advDetails || advDetails.byteLength === 0) {
blockers.add('device.adv_details_missing');
}
let advSecret = fromBase64(options.capturedAdvSecret);
if (!advSecret || advSecret.byteLength !== 32) {
try {
const current = await multiDevice?.getADVSecretKey?.();
advSecret = fromBase64(current);
} catch {
advSecret = undefined;
}
}
const advSecretAvailable = advSecret?.byteLength === 32;
const conn = connectionModule?.Conn;
const supportedPrimaryPlatforms = new Set(
Object.values(mobilePlatforms?.PLATFORMS || {}).filter(
(value) => typeof value === 'string',
),
);
const storedPrimaryPlatform = await safeRead(
() => userPrefsGeneral?.getLastMobilePlatform?.(),
'device.platform_read_failed',
);
const primaryPlatform = [conn?.platform, storedPrimaryPlatform].find(
(value) =>
typeof value === 'string' && supportedPrimaryPlatforms.has(value),
);
if (!primaryPlatform) blockers.add('device.platform_missing');
const pushName = await safeRead(
() => userPrefsGeneral?.getPushname?.(),
'device.push_name_read_failed',
);
const lidMigrated =
(await safeRead(
() => lidMigration?.Lid1X1MigrationUtils?.isLidMigrated?.(),
'device.lid_migration_state_read_failed',
)) === true;
// The canonical device JID identifies the linked companion, not merely
// the WhatsApp account. Current Web builds expose the complete WID (with
// its `:device` component) through the device getters. Older private ABIs
// only exposed the user getters, so retain those as a compatibility
// fallback without letting them erase a device ID that is available.
const jid =
widString(meUserPrefs?.getMaybeMeDevicePn?.()) ||
widString(meUserPrefs?.getMaybeMePnUser?.()) ||
widString(conn?.wid || conn?.me || conn?.meUser);
const lid =
widString(meUserPrefs?.getMaybeMeDeviceLid?.()) ||
widString(meUserPrefs?.getMaybeMeLidUser?.()) ||
widString(conn?.lid || conn?.meLid || conn?.lidWid) ||
null;
if (!jid) blockers.add('device.jid_missing');
if (lid && !lidMigrated) blockers.add('device.lid_migration_state_missing');
if (
!Number.isSafeInteger(registration?.registrationId) ||
registration.registrationId < 0 ||
registration.registrationId > 0xffffffff
) {
blockers.add('device.registration_id_missing');
}
if (
!Number.isSafeInteger(signedPreKey?.keyId) ||
signedPreKey.keyId < 0 ||
signedPreKey.keyId > 0xffffff
) {
blockers.add('device.signed_pre_key_id_missing');
}
const fingerprint = await fingerprintDigest([
identityPublic,
advDetails,
advAccountSignatureKey,
advAccountSignature,
advDeviceSignature,
]);
if (!fingerprint) blockers.add('device.fingerprint_missing');
let firstUnuploaded = 1;
let nextPreKeyId = 1;
try {
[firstUnuploaded, nextPreKeyId] = await Promise.all([
signalStore.getMeta(signalConst.META_KEYS.FIRST_UNUPLOAD_PK_ID),
signalStore.getMeta(signalConst.META_KEYS.NEXT_PK_ID),
]);
firstUnuploaded = Number(firstUnuploaded || 1);
nextPreKeyId = Number(nextPreKeyId || firstUnuploaded || 1);
} catch {
blockers.add('device.pre_key_counters_unavailable');
}
let pqMigrated = false;
let pqUploadEnabled = false;
let pqMessagingEnabled = false;
let pqStorageMode = 'unknown';
let pqServerCountMode = 'reset';
let pqPreKeyCount = 0;
let pqLastResortKeyCount = 0;
const pqPreKeyRows = [];
const pqPreKeyStateRows = [];
const pqPrivateKeyCopies = [];
try {
[pqMigrated, pqUploadEnabled, pqMessagingEnabled] = await Promise.all([
kyberStore.isPQMigrated().then(Boolean),
Promise.resolve(Boolean(pqGating.isPqKeysUploadEnabled())),
Promise.resolve(Boolean(pqGating.isPq1on1MessageEnabled())),
]);
const pqTables = [];
let missingPqTables = 0;
for (const getterName of [
'getKyberPreKeyTable',
'getKyberLastResortKeyTable',
]) {
try {
const table = signalStorage?.[getterName]?.();
if (!table || typeof table.all !== 'function') {
throw new Error('invalid_table');
}
pqTables.push(table);
} catch (error) {
if (error?.name !== 'NoSuchTableError') throw error;
missingPqTables += 1;
}
}
if (missingPqTables === 0) {
const [preKeys, lastResortKeys] = await Promise.all(
pqTables.map((table) => table.all()),
);
if (!Array.isArray(preKeys) || !Array.isArray(lastResortKeys)) {
throw new Error('invalid_rows');
}
pqStorageMode = 'legacy_tables';
// WhatsApp Web does not expose Baileys' last observed server count.
// `unknown` tells the PostgreSQL store to retain the existing pair
// atomically instead of turning a loss of observability into NULL.
pqServerCountMode = 'unknown';
const nextPqPreKeyId = Number(
await kyberStore.getNextKyberPreKeyId?.(),
);
if (
!Number.isSafeInteger(nextPqPreKeyId) ||
nextPqPreKeyId < 0 ||
nextPqPreKeyId >= 0xffffff
) {
blockers.add('post_quantum.allocator_incompatible');
} else {
pqPreKeyStateRows.push({
codec_version: 1,
algorithm: 'ML-KEM-1024',
next_pre_key_id: nextPqPreKeyId,
migrated: pqMigrated,
last_server_count: null,
last_server_count_timestamp_ms: null,
});
}
if (lastResortKeys.length > 1) {
blockers.add('post_quantum.last_resort_key_ambiguous');
}
const seenPqKeyIds = new Set();
for (const [keyKind, rows] of [
['one_time', preKeys],
['last_resort', lastResortKeys],
]) {
for (const row of rows) {
const keyId = Number(row?.keyId);
const publicKey = toBytes(row?.keyPair?.pubKey);
const privateKey = toBytes(row?.keyPair?.privKey);
const signature = toBytes(row?.signature);
const timestampMs = Number(row?.timestamp);
const sentToServer =
keyKind === 'one_time' ? row?.sentToServer : pqMigrated;
if (
!Number.isSafeInteger(keyId) ||
keyId < 0 ||
keyId >= 0xffffff ||
seenPqKeyIds.has(keyId) ||
!publicKey ||
publicKey.byteLength !== 1568 ||
!privateKey ||
privateKey.byteLength !== 3168 ||
!signature ||
signature.byteLength !== 64 ||
!Number.isSafeInteger(timestampMs) ||
timestampMs < 0 ||
typeof sentToServer !== 'boolean'
) {
blockers.add('post_quantum.row_incompatible');
continue;
}
seenPqKeyIds.add(keyId);
const privateKeyCopy = new Uint8Array(privateKey);
pqPrivateKeyCopies.push(privateKeyCopy);
pqPreKeyRows.push({
key_id: keyId,
key_kind: keyKind,
public_key: toBase64(publicKey, 1568),
private_key: toBase64(privateKeyCopy, 3168),
signature: toBase64(signature, 64),
timestamp_ms: timestampMs,
sent_to_server: sentToServer,
});
}
}
pqPreKeyRows.sort((left, right) => left.key_id - right.key_id);
pqPreKeyCount = pqPreKeyRows.filter(
(row) => row.key_kind === 'one_time',
).length;
pqLastResortKeyCount = pqPreKeyRows.filter(
(row) => row.key_kind === 'last_resort',
).length;
if (pqMigrated && pqLastResortKeyCount === 0) {
blockers.add('post_quantum.migrated_state_incomplete');
}
if (
(!pqMigrated &&
pqPreKeyRows.some((row) => row.sent_to_server)) ||
(!pqMigrated && pqPreKeyCount > 0 && pqLastResortKeyCount !== 1)
) {
blockers.add('post_quantum.migration_state_diverged');
}
} else if (missingPqTables === 2) {
// Feature flags do not prove that a managed store is empty. A
// rollout can be disabled while dormant/rollback key material is
// still present, and silently exporting that as an empty legacy
// projection would destroy the companion's PQ state.
let digestState;
try {
digestState = await kyberStore.loadKyberKeysForDigest();
} catch (error) {
if (
error?.name === 'NoSuchTableError' &&
!pqMigrated &&
!pqUploadEnabled &&
!pqMessagingEnabled
) {
// The audited legacy implementation builds its digest by
// reopening these exact physical tables. If both getters
// and that digest path report NoSuchTable while every PQ
// rollout flag is inactive, there is no alternate managed
// store and physical absence is the empty-state proof.
digestState = {
kyberPreKeys: [],
kyberLastResortKeys: [],
};
} else {
throw error;
}
}
if (
!Array.isArray(digestState?.kyberPreKeys) ||
!Array.isArray(digestState?.kyberLastResortKeys)
) {
throw new Error('invalid_managed_store');
}
pqPreKeyCount = digestState.kyberPreKeys.length;
pqLastResortKeyCount = digestState.kyberLastResortKeys.length;
if (
!pqMigrated &&
pqPreKeyCount === 0 &&
pqLastResortKeyCount === 0 &&
((!pqUploadEnabled && !pqMessagingEnabled) || pqRollbackValid)
) {
pqStorageMode = 'rollout_without_tables';
} else {
pqStorageMode = 'managed_store';
blockers.add('post_quantum.private_material_unavailable');
if (pqMigrated && pqLastResortKeyCount === 0) {
blockers.add('post_quantum.migrated_state_incomplete');
}
if (pqUploadEnabled && !pqMigrated) {
blockers.add('post_quantum.regeneration_pending');
}
}
} else {
blockers.add('post_quantum.storage_incompatible');
}
} catch {
blockers.add('post_quantum.capability_read_failed');
}
const identityRows = [];
for (const row of await all(
signalStorage?.getIdentityTable?.(),
'identity_keys.read_failed',
)) {
const address = parseSignalAddress(row?.identifier);
const signalIdentity = signalCommon?.strToBuffer?.(row?.identityKey);
let bytes = toBytes(signalIdentity);
if (bytes?.byteLength === 33 && bytes[0] === 5) {
bytes = bytes.subarray(1);
}
if (
!address ||
address.scope !== 'default' ||
!bytes ||
bytes.byteLength !== 32
) {
blockers.add('identity_keys.row_incompatible');
continue;
}
identityRows.push({
their_id: address.their_id,
identity: toBase64(bytes, 32),
});
}
const preKeyRows = [];
for (const row of await all(
signalStorage?.getPreKeyTable?.(),
'pre_keys.read_failed',
)) {
const keyId = Number(row?.keyId);
const privateKey = toBytes(row?.keyPair?.privKey);
if (
!Number.isSafeInteger(keyId) ||
keyId < 0 ||
keyId > 0xffffff ||
!privateKey ||
privateKey.byteLength !== 32
) {
blockers.add('pre_keys.row_incompatible');
continue;
}
preKeyRows.push({
key_id: keyId,
key: toBase64(privateKey, 32),
uploaded: keyId < firstUnuploaded,
});
}
const signalSessionRows = [];
const signalAddresses = new Set();
for (const row of await all(
signalStorage?.getSessionTable?.(),
'signal_sessions.read_failed',
)) {
const address = parseSignalAddress(row?.address);
let serialized;
try {
const session = await signalConvert.maybeConvertSession(
row.session,
);
serialized = encodeProto(
protobufEncoder,
localStorageProto.RecordStructureSpec,
sessionsApi.serializeSession(session),
);
} catch {
// The row is rejected by the shared compatibility path below.
}
const key = address
? `${address.their_id}\0${address.scope}`
: undefined;
if (
!address ||
!serialized ||
serialized.byteLength === 0 ||
serialized.byteLength > 8 * 1024 * 1024 ||
signalAddresses.has(key)
) {
blockers.add('signal_sessions.row_incompatible');
continue;
}
signalAddresses.add(key);
signalSessionRows.push({
...address,
session: toBase64(serialized),
});
}
const senderKeyRows = [];
const senderAddresses = new Set();
for (const row of await all(
signalStorage?.getSenderKeyTable?.(),
'sender_keys.read_failed',
)) {
const separator =
typeof row?.senderKeyName === 'string'
? row.senderKeyName.indexOf('::')
: -1;
const chatId =
separator > 0 ? row.senderKeyName.slice(0, separator) : undefined;
const address =
separator > 0
? parseSignalAddress(row.senderKeyName.slice(separator + 2))
: undefined;
let serialized;
try {
const senderKey = signalConvert.maybeConvertSenderKey(
row.senderKey,
);
serialized = encodeProto(
protobufEncoder,
localStorageProto.SenderKeyRecordStructureSpec,
groupSessionsApi.serializeSession(senderKey),
);
} catch {
// The row is rejected by the shared compatibility path below.
}
const key =
chatId && address && address.scope === 'default'
? `${chatId}\0${address.their_id}`
: undefined;
if (
!key ||
!serialized ||
serialized.byteLength === 0 ||
serialized.byteLength > 2 * 1024 * 1024 ||
senderAddresses.has(key)
) {
blockers.add('sender_keys.row_incompatible');
continue;
}
senderAddresses.add(key);
senderKeyRows.push({
chat_id: chatId,
sender_id: address.their_id,
sender_key: toBase64(serialized),
});
}
const appStateSyncKeyRows = [];
let syncKeys;
try {
syncKeys = await syncdDb?.getAllSyncKeys?.();
if (!Array.isArray(syncKeys)) throw new Error('invalid');
} catch {
blockers.add('app_state_sync_keys.read_failed');
syncKeys = [];
}
for (const row of syncKeys) {
const keyId = toBytes(row?.keyId);
const keyData = toBytes(row?.keyData);
const encodedFingerprint = encodeFingerprint(row?.fingerprint);
if (
!keyId ||
keyId.byteLength === 0 ||
!keyData ||
keyData.byteLength !== 32 ||
!encodedFingerprint ||
!Number.isSafeInteger(row?.timestamp) ||
row.timestamp < 0
) {
blockers.add('app_state_sync_keys.row_incompatible');
continue;
}
const derivedKeyEpoch =
keyId[keyId.byteLength - 2] * 256 + keyId[keyId.byteLength - 1];
if (row.keyEpoch !== derivedKeyEpoch) {
blockers.add('app_state_sync_keys.key_epoch_incompatible');
}
appStateSyncKeyRows.push({
key_id: toBase64(keyId),
key_data: toBase64(keyData, 32),
timestamp: row.timestamp,
fingerprint: toBase64(encodedFingerprint),
});
}
const appStateVersionRows = [];
const appStateVersionSourceRows = await all(
collectionSchema?.getCollectionVersionTable?.(),
'app_state_versions.read_failed',
);
const appStateVersionIncompatibleSamples = [];
let appStateVersionIncompatibleCount = 0;
for (const row of appStateVersionSourceRows) {
const hash = toBytes(row?.ltHash);
if (
typeof row?.collection !== 'string' ||
!row.collection ||
!Number.isSafeInteger(row.version) ||
row.version < 0 ||
!hash ||
hash.byteLength !== 128
) {
blockers.add('app_state_versions.row_incompatible');
appStateVersionIncompatibleCount += 1;
if (appStateVersionIncompatibleSamples.length < 3) {
const describeValue = (value) => ({
type: typeof value,
constructor:
typeof value === 'object' && value !== null
? String(
value.constructor?.name || 'unknown',
).slice(0, 80)
: null,
});
appStateVersionIncompatibleSamples.push({
columns:
typeof row === 'object' && row !== null
? Object.keys(row)
.filter((key) =>
/^[A-Za-z0-9_$]{1,80}$/u.test(key),
)
.sort()
.slice(0, 32)
: [],
column_types:
typeof row === 'object' && row !== null
? Object.keys(row)
.filter((key) =>
/^[A-Za-z0-9_$]{1,80}$/u.test(key),
)
.sort()
.slice(0, 32)
.map((key) => ({
name: key,
...describeValue(row[key]),
}))
: [],
collection: describeValue(row?.collection),
version: describeValue(row?.version),
bytes: {
...describeValue(row?.ltHash),
length: Number.isSafeInteger(hash?.byteLength)
? hash.byteLength
: null,
},
state: {
...describeValue(row?.state),
code:
typeof row?.state === 'string' &&
/^[A-Za-z0-9_.:-]{1,80}$/u.test(row.state)
? row.state
: null,
columns:
typeof row?.state === 'object' && row.state !== null
? Object.keys(row.state)
.filter((key) =>
/^[A-Za-z0-9_$]{1,80}$/u.test(key),
)
.sort()
.slice(0, 32)
: [],
version: describeValue(row?.state?.version),
lt_hash: {
...describeValue(row?.state?.ltHash),
length: Number.isSafeInteger(
toBytes(row?.state?.ltHash)?.byteLength,
)
? toBytes(row.state.ltHash).byteLength
: null,
},
hash: {
...describeValue(row?.state?.hash),
length: Number.isSafeInteger(
toBytes(row?.state?.hash)?.byteLength,
)
? toBytes(row.state.hash).byteLength
: null,
},
finite_failure_started:
Number.isFinite(row?.finiteFailureStartTime) &&
row.finiteFailureStartTime > 0,
},
});
}
continue;
}
appStateVersionRows.push({
name: row.collection,
version: row.version,
hash: toBase64(hash, 128),
});
}
if (appStateVersionIncompatibleCount > 0) {
diagnostics.app_state_versions = {
row_count: appStateVersionSourceRows.length,
incompatible_count: appStateVersionIncompatibleCount,
samples: appStateVersionIncompatibleSamples,
};
}
const appStateMutationMacRows = [];
const mutationMacKeys = new Set();
for (const row of await all(
syncActionsSchema?.getSyncActionsTable?.(),
'app_state_mutation_macs.read_failed',
)) {
const indexMac = toBytes(row?.indexMac);
const valueMac = toBytes(row?.valueMac);
if (
typeof row?.collection !== 'string' ||
!row.collection ||
!Number.isSafeInteger(row.version) ||
row.version < 0 ||
!indexMac ||
indexMac.byteLength !== 32 ||
!valueMac ||
valueMac.byteLength !== 32
) {
blockers.add('app_state_mutation_macs.row_incompatible');
continue;
}
const indexMacEncoded = toBase64(indexMac, 32);
const key = `${row.collection}\0${indexMacEncoded}\0${row.version}`;
if (mutationMacKeys.has(key)) continue;
mutationMacKeys.add(key);
appStateMutationMacRows.push({
name: row.collection,
version: row.version,
index_mac: indexMacEncoded,
value_mac: toBase64(valueMac, 32),
});
}
if (
pqRollbackValid &&
(pqMigrated || pqPreKeyCount !== 0 || pqLastResortKeyCount !== 0)
) {
blockers.add('post_quantum.server_rollback_state_not_empty');
}
const handoffSignalSessionRows = pqRollbackValid
? signalSessionRows.filter((row) => row.scope === 'default')
: signalSessionRows;
const tables = {
identity_keys: addRows(identityRows),
pre_keys: addRows(preKeyRows),
pq_pre_keys: addRows(pqPreKeyRows),
pq_pre_key_state: addRows(pqPreKeyStateRows),
signal_sessions: addRows(handoffSignalSessionRows),
sender_keys: addRows(senderKeyRows),
app_state_sync_keys: addRows(appStateSyncKeyRows),
app_state_versions: addRows(appStateVersionRows),
app_state_mutation_macs: addRows(appStateMutationMacRows),
};
const device = {
jid: jid || '',
lid,
facebook_uuid: null,
registration_id: registration?.registrationId,
noise_key: noisePrivate ? toBase64(noisePrivate, 32) : '',
identity_key: identityPrivate ? toBase64(identityPrivate, 32) : '',
signed_pre_key: signedPrivate ? toBase64(signedPrivate, 32) : '',
signed_pre_key_id: signedPreKey?.keyId,
signed_pre_key_sig: signedSignature
? toBase64(signedSignature, 64)
: '',
adv_secret_available: advSecretAvailable,
adv_key: advSecretAvailable ? toBase64(advSecret, 32) : null,
adv_details: advDetails ? toBase64(advDetails) : '',
adv_account_sig: advAccountSignature
? toBase64(advAccountSignature, 64)
: '',
adv_account_sig_key: advAccountSignatureKey
? toBase64(advAccountSignatureKey, 32)
: '',
adv_device_sig: advDeviceSignature
? toBase64(advDeviceSignature, 64)
: '',
platform: primaryPlatform || '',
business_name: '',
push_name: typeof pushName === 'string' ? pushName : '',
// WhatsApp Web only exposes a boolean migration-completion proof. The
// shared store deliberately reconstructs that proof from durable
// device state instead of trusting provider metadata, so persist a
// stable positive marker when the private ABI confirms completion.
// A constant keeps repeated checkpoints byte-stable.
lid_migration_ts: lidMigrated ? 1 : 0,
next_pre_key_id: nextPreKeyId,
device_fingerprint: fingerprint || '',
};
const providerState =
noisePublic && noiseRecoveryToken && fingerprint
? {
kind: 'wwebjs-noise-bootstrap-v1',
codec_version: 1,
device_fingerprint: fingerprint,
noise_public_key: toBase64(noisePublic, 32),
recovery_token: toBase64(noiseRecoveryToken, 24),
certificate_chain: noiseCertificateChain
? toBase64(noiseCertificateChain)
: null,
}
: null;
const transportState = routingInfoBytes
? {
kind: 'whatsapp-transport-v1',
codec_version: 1,
routing_info: toBase64(routingInfoBytes),
}
: null;
for (const secret of [
identityPrivate,
noisePrivate,
noiseRecoveryToken,
signedPrivate,
advSecret,
...pqPrivateKeyCopies,
]) {
secret?.fill?.(0);
}
return {
kind: 'wwebjs-canonical-session-v1',
codec_version: 1,
module_abi: 'wwebjs-private-modules-v1',
web_version: window.Debug?.VERSION || 'unknown',
fingerprint_version: 'underchat-whatsapp-device-fingerprint-v2',
complete: blockers.size === 0,
blockers: [...blockers].sort(),
...(Object.keys(diagnostics).length > 0 ? { diagnostics } : {}),
capabilities: {
adv_secret_available: advSecretAvailable,
passkey_handoff_supported: advSecretAvailable,
signal_scopes: ['default', 'status', 'pq'],
signal_domain_types: [0, 1, 128, 129],
app_state_import_mode: 'snapshot_resync',
canonical_signal_session_scopes: pqRollbackValid
? ['default']
: ['default', 'status', 'pq'],
canonical_signal_session_resync_required: pqRollbackValid,
canonical_signal_session_resync_scopes: pqRollbackValid
? ['status', 'pq']
: [],
pq_migrated: pqMigrated,
pq_upload_enabled: pqUploadEnabled,
pq_messaging_enabled: pqMessagingEnabled,
pq_storage_mode: pqStorageMode,
pq_server_count_mode: pqServerCountMode,
pq_pre_key_count: pqPreKeyCount,
pq_last_resort_key_count: pqLastResortKeyCount,
...(pqRollbackValid
? {
pq_server_rollback_acknowledged: true,
pq_server_rollback_protocol:
'delete_pq_prekeys_server_ack_v1',
pq_server_rollback_handoff_id: pqRollback.handoff_id,
pq_server_rollback_lifecycle_operation_id:
pqRollback.lifecycle_operation_id,
pq_server_rollback_source_revision_id: String(
pqRollback.source_revision_id,
),
pq_server_rollback_owner_id: pqRollback.owner_id,
pq_server_rollback_fencing_token: String(
pqRollback.fencing_token,
),
pq_server_rollback_generation: pqRollback.generation,
pq_server_rollback_epoch: pqRollback.epoch,
pq_server_rollback_acknowledged_at_ms:
pqRollback.acknowledged_at_ms,
pq_server_rollback_response_validated: true,
pq_upload_lifecycle_fenced: true,
pq_upload_lifecycle_fence_version: 1,
pq_runtime_upload_enabled:
pqRollback.runtime_upload_enabled,
pq_runtime_messaging_enabled:
pqRollback.runtime_messaging_enabled,
}
: {}),
lid_migrated: lidMigrated,
},
device,
provider_state: providerState,
transport_state: transportState,
tables,
record_count: recordCount,
size_bytes: sizeBytes,
};
}
/* This function is serialized by Puppeteer and executes in the page realm. */
async function importCanonicalSessionProjection(projection, options) {
// A canonical cross-provider import can legitimately spend more than the
// Puppeteer protocol default inside IndexedDB-backed private APIs (notably
// while a fresh WWeb profile creates its signal-store tables). Do not keep
// that work attached to one long Runtime.callFunctionOn request: it hides
// the import stage and makes Chromium abort the otherwise recoverable
// hydration at Puppeteer's transport deadline.
//
// The task modes intentionally live in this serialized function instead
// of a Node closure. `page.evaluate()` serializes this function into the
// WhatsApp document, where the named-function binding lets the short
// `start` invocation launch the regular import asynchronously and return
// immediately. Node then polls only a safe, aggregate task state. No
// credential material is exposed through the task status object.
const backgroundTaskKey = '__WWebCanonicalImportTaskV1';
const backgroundTaskMode = options?.backgroundTaskMode;
const backgroundTaskId =
typeof options?.backgroundTaskId === 'string' &&
/^[a-f0-9-]{16,128}$/iu.test(options.backgroundTaskId)
? options.backgroundTaskId
: undefined;
const safeTaskErrorCode = (error) => {
const candidate = String(error?.code || error?.message || '');
const controlled = /wwebjs_canonical_[a-z0-9_]+/iu.exec(candidate)?.[0];
return controlled
? controlled.toLowerCase()
: 'wwebjs_canonical_import_task_failed';
};
const taskSnapshot = (task) => ({
task_id: task.id,
status: task.status,
stage: task.stage,
duration_ms: Math.max(0, Date.now() - task.started_at_ms),
...(Number.isSafeInteger(task.progress_completed) &&
Number.isSafeInteger(task.progress_total) &&
task.progress_completed >= 0 &&
task.progress_completed <= task.progress_total
? {
progress_completed: task.progress_completed,
progress_total: task.progress_total,
}
: {}),
...(task.error_code ? { error_code: task.error_code } : {}),
});
const activeTask = () => {
const candidate = window[backgroundTaskKey];
return candidate?.id === backgroundTaskId ? candidate : undefined;
};
if (backgroundTaskMode === 'start') {
if (!backgroundTaskId) {
throw new Error('wwebjs_canonical_import_task_id_invalid');
}
const existing = window[backgroundTaskKey];
if (existing?.status === 'running') {
throw new Error('wwebjs_canonical_import_task_already_running');
}
const task = {
id: backgroundTaskId,
status: 'running',
stage: 'projection_validation',
started_at_ms: Date.now(),
updated_at_ms: Date.now(),
cancelled: false,
};
// Keep an eventual result in this invocation's closure rather than a
// serializable global property. The next short `consume` evaluation
// can call these closures, while `observe` can never expose it.
let completionResult;
task.consumeResult = () => {
const result = completionResult;
completionResult = undefined;
return result;
};
task.clearResult = () => {
completionResult = undefined;
};
window[backgroundTaskKey] = task;
// Keep only opaque task state in the global registry. The projection
// is captured by this promise chain until the import settles and is
// never added to a serializable status object.
void new Promise((resolve) => setTimeout(resolve, 0))
.then(() =>
importCanonicalSessionProjection(projection, {
...options,
backgroundTaskMode: 'run',
backgroundTaskId,
}),
)
.then(
(result) => {
if (task.cancelled) return;
task.status = 'completed';
task.stage = 'completed';
completionResult = result;
task.updated_at_ms = Date.now();
},
(error) => {
if (task.cancelled) return;
task.status = 'failed';
task.error_code = safeTaskErrorCode(error);
task.updated_at_ms = Date.now();
},
);
return taskSnapshot(task);
}
if (backgroundTaskMode === 'observe') {
if (!backgroundTaskId) {
throw new Error('wwebjs_canonical_import_task_id_invalid');
}
const task = activeTask();
if (!task) {
throw new Error('wwebjs_canonical_import_task_missing');
}
return taskSnapshot(task);
}
if (backgroundTaskMode === 'consume') {
if (!backgroundTaskId) {
throw new Error('wwebjs_canonical_import_task_id_invalid');
}
const task = activeTask();
if (!task) {
throw new Error('wwebjs_canonical_import_task_missing');
}
if (task.status === 'running') {
throw new Error('wwebjs_canonical_import_task_still_running');
}
if (task.status !== 'completed') {
throw new Error(
task.error_code || 'wwebjs_canonical_import_task_failed',
);
}
const result = task.consumeResult?.();
task.clearResult?.();
task.consumeResult = undefined;
task.clearResult = undefined;
delete window[backgroundTaskKey];
if (!result || typeof result !== 'object') {
throw new Error('wwebjs_canonical_import_task_result_missing');
}
return result;
}
if (backgroundTaskMode === 'cancel') {
if (!backgroundTaskId) {
throw new Error('wwebjs_canonical_import_task_id_invalid');
}
const task = activeTask();
if (!task) return false;
task.cancelled = true;
task.status = 'cancelled';
task.stage = 'cancelled';
task.error_code = 'wwebjs_canonical_import_task_cancelled';
task.clearResult?.();
task.consumeResult = undefined;
task.clearResult = undefined;
task.updated_at_ms = Date.now();
delete window[backgroundTaskKey];
return true;
}
if (backgroundTaskMode !== undefined && backgroundTaskMode !== 'run') {
throw new Error('wwebjs_canonical_import_task_mode_invalid');
}
const task =
backgroundTaskMode === 'run' && backgroundTaskId
? activeTask()
: undefined;
if (backgroundTaskMode === 'run' && !task) {
throw new Error('wwebjs_canonical_import_task_missing');
}
const taskDeadlineAtMs = options?.backgroundTaskDeadlineAtMs;
if (
backgroundTaskMode === 'run' &&
(!Number.isSafeInteger(taskDeadlineAtMs) ||
taskDeadlineAtMs <= task.started_at_ms)
) {
throw new Error('wwebjs_canonical_import_task_timeout_invalid');
}
const setImportStage = (stage) => {
if (task?.cancelled) {
throw new Error('wwebjs_canonical_import_task_cancelled');
}
if (task && Date.now() >= taskDeadlineAtMs) {
throw new Error('wwebjs_canonical_import_task_timeout');
}
if (task) {
if (task.stage !== stage) {
task.progress_completed = undefined;
task.progress_total = undefined;
}
task.stage = stage;
task.updated_at_ms = Date.now();
}
return stage;
};
const setImportProgress = (completed, total) => {
if (
!Number.isSafeInteger(completed) ||
!Number.isSafeInteger(total) ||
completed < 0 ||
completed > total
) {
throw new Error('wwebjs_canonical_import_progress_invalid');
}
if (task) {
task.progress_completed = completed;
task.progress_total = total;
task.updated_at_ms = Date.now();
}
};
const fail = (code) => {
throw new Error(code);
};
// Keep this helper inside the Puppeteer-serialized function. A module-level
// closure is unavailable in the WhatsApp page realm and would turn an
// otherwise valid public sender-key state into an opaque import failure.
const normalizeCanonicalSenderKeyRecord = (record) => {
if (!record || !Array.isArray(record.senderKeyStates)) return record;
let normalized = false;
const senderKeyStates = record.senderKeyStates.map((state) => {
const signingKey = state?.senderSigningKey;
const privateKey = signingKey?.private;
if (
!(privateKey instanceof Uint8Array) ||
privateKey.byteLength !== 0
) {
return state;
}
const senderSigningKey = { ...signingKey };
delete senderSigningKey.private;
normalized = true;
return { ...state, senderSigningKey };
});
return normalized ? { ...record, senderKeyStates } : record;
};
setImportStage('projection_validation');
let currentImportStage;
const importStep = async (stage, operation) => {
currentImportStage = setImportStage(stage);
try {
const result = await operation();
// An individual IndexedDB/private-module promise can take longer
// than expected. Recheck the task fence after it settles so a
// late final operation cannot be reported as a successful import.
setImportStage(stage);
return result;
} catch (error) {
if (/^wwebjs_canonical_/u.test(String(error?.message || ''))) {
throw error;
}
fail(`wwebjs_canonical_import_${stage}_failed`);
}
};
const signalComponentNames = [
'identity_keys',
'pre_keys',
'pq_pre_keys',
'signal_sessions',
'sender_keys',
];
const requestedSignalComponents = options?.preserveExistingSignalComponents;
const requestedSignalComponentKeys =
requestedSignalComponents &&
typeof requestedSignalComponents === 'object' &&
!Array.isArray(requestedSignalComponents)
? Object.keys(requestedSignalComponents)
: [];
const requestedSignalComponentsValid =
requestedSignalComponents === undefined ||
(requestedSignalComponents &&
typeof requestedSignalComponents === 'object' &&
!Array.isArray(requestedSignalComponents) &&
Object.getPrototypeOf(requestedSignalComponents) ===
Object.prototype &&
requestedSignalComponentKeys.length ===
signalComponentNames.length &&
signalComponentNames.every(
(name) =>
Object.hasOwn(requestedSignalComponents, name) &&
typeof requestedSignalComponents[name] === 'boolean',
) &&
requestedSignalComponentKeys.every((name) =>
signalComponentNames.includes(name),
));
if (
!projection ||
projection.kind !== 'wwebjs-canonical-session-v1' ||
projection.codec_version !== 1 ||
projection.module_abi !== 'wwebjs-private-modules-v1' ||
projection.fingerprint_version !==
'underchat-whatsapp-device-fingerprint-v2' ||
options?.moduleAbi !== 'wwebjs-private-modules-v1' ||
projection.complete !== true ||
!projection.device ||
!projection.tables ||
!Array.isArray(projection.tables.pq_pre_keys) ||
!Array.isArray(projection.tables.pq_pre_key_state) ||
(options?.preserveExistingAppState !== undefined &&
typeof options.preserveExistingAppState !== 'boolean') ||
(options?.preserveExistingSignalTables !== undefined &&
typeof options.preserveExistingSignalTables !== 'boolean') ||
(options?.preserveExistingSignalTables === true &&
options?.preserveExistingAppState !== true) ||
!requestedSignalComponentsValid ||
(requestedSignalComponents !== undefined &&
(options?.preserveExistingAppState !== true ||
options?.preserveExistingSignalTables === true))
) {
fail('wwebjs_canonical_projection_incompatible');
}
const preserveExistingAppState = options?.preserveExistingAppState === true;
const preserveExistingSignalTables =
options?.preserveExistingSignalTables === true;
const preserveExistingSignalComponents = Object.freeze(
Object.fromEntries(
signalComponentNames.map((name) => [
name,
preserveExistingSignalTables ||
requestedSignalComponents?.[name] === true,
]),
),
);
const preservedSignalComponents = signalComponentNames.filter(
(name) => preserveExistingSignalComponents[name],
);
const replacedSignalComponents = signalComponentNames.filter(
(name) => !preserveExistingSignalComponents[name],
);
const pqPairValidationProof = options?.pqPairValidationProof;
if (
pqPairValidationProof !== undefined &&
(!pqPairValidationProof ||
pqPairValidationProof.version !== 1 ||
pqPairValidationProof.algorithm !== 'ML-KEM-1024' ||
!Number.isSafeInteger(pqPairValidationProof.key_count) ||
pqPairValidationProof.key_count < 1 ||
!/^[a-f0-9]{64}$/u.test(
String(pqPairValidationProof.commitment || ''),
))
) {
fail('wwebjs_canonical_pq_validation_proof_invalid');
}
const requireModule = (name) => {
try {
const moduleValue = window.require?.(name);
if (!moduleValue) fail('wwebjs_canonical_module_abi_incompatible');
return moduleValue.default || moduleValue;
} catch (error) {
if (error?.message === 'wwebjs_canonical_module_abi_incompatible') {
throw error;
}
fail('wwebjs_canonical_module_abi_incompatible');
}
};
const fromBase64 = (value, expectedLength) => {
if (
typeof value !== 'string' ||
value.length % 4 === 1 ||
!/^[A-Za-z0-9+/]*={0,2}$/.test(value)
) {
fail('wwebjs_canonical_projection_incompatible');
}
let bytes;
try {
const binary = atob(value);
bytes = Uint8Array.from(binary, (entry) => entry.charCodeAt(0));
} catch {
fail('wwebjs_canonical_projection_incompatible');
}
if (
expectedLength !== undefined &&
bytes.byteLength !== expectedLength
) {
fail('wwebjs_canonical_projection_incompatible');
}
return bytes;
};
const asArrayBuffer = (bytes) =>
bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
);
const toBase64 = (bytes) => {
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(
...bytes.subarray(offset, offset + 0x8000),
);
}
return btoa(binary);
};
const constantTimeHexEqual = (left, right) => {
if (
typeof left !== 'string' ||
typeof right !== 'string' ||
left.length !== right.length
) {
return false;
}
let difference = 0;
for (let index = 0; index < left.length; index += 1) {
difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
}
return difference === 0;
};
const domainDigest = async (domainValue, fields) => {
const domain = new TextEncoder().encode(domainValue);
const total = fields.reduce(
(sum, field) => sum + 4 + field.byteLength,
domain.byteLength,
);
const payload = new Uint8Array(total);
payload.set(domain);
const view = new DataView(payload.buffer);
let offset = domain.byteLength;
for (const field of fields) {
view.setUint32(offset, field.byteLength, false);
offset += 4;
payload.set(field, offset);
offset += field.byteLength;
}
try {
const digest = new Uint8Array(
await crypto.subtle.digest('SHA-256', payload),
);
return [...digest]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
} finally {
payload.fill(0);
}
};
const fingerprintDigest = (fields) =>
domainDigest('underchat-whatsapp-device-fingerprint-v2', fields);
const signedIdentityDigest = (fields) =>
domainDigest('underchat-wwebjs-signed-identity-tuple-v1', fields);
const bytesDigest = async (bytes) => {
const digest = new Uint8Array(
await crypto.subtle.digest('SHA-256', bytes),
);
return [...digest]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
};
const parseCanonicalSignalAddress = (value, scope = 'default') => {
const match = /^(\d+)(?:_(1|128|129))?:(\d+)$/.exec(value);
if (!match || !['default', 'status', 'pq'].includes(scope)) {
fail('wwebjs_canonical_signal_address_incompatible');
}
const device = Number(match[3]);
if (!Number.isSafeInteger(device) || device < 0 || device > 65535) {
fail('wwebjs_canonical_signal_address_incompatible');
}
const server =
match[2] === '1'
? 'lid'
: match[2] === '128'
? 'hosted'
: match[2] === '129'
? 'hosted.lid'
: 'c.us';
const devicePart = device === 0 ? '' : `:${device}`;
const scopePart =
scope === 'status' ? '_status' : scope === 'pq' ? '_pq' : '';
return `${match[1]}${devicePart}@${server}${scopePart}.0`;
};
const parseFingerprint = (bytes) => {
let offset = 0;
const readVarint = () => {
let value = 0;
let multiplier = 1;
for (let count = 0; count < 5; count += 1) {
if (offset >= bytes.length) {
fail('wwebjs_canonical_app_state_fingerprint_invalid');
}
const byte = bytes[offset++];
value += (byte & 0x7f) * multiplier;
if ((byte & 0x80) === 0) return value;
multiplier *= 128;
}
fail('wwebjs_canonical_app_state_fingerprint_invalid');
};
const fingerprint = {
rawId: 0,
currentIndex: 0,
deviceIndexes: [],
};
while (offset < bytes.length) {
const tag = readVarint();
const field = tag >>> 3;
const wireType = tag & 7;
if ((field === 1 || field === 2) && wireType === 0) {
const value = readVarint();
if (field === 1) fingerprint.rawId = value;
else fingerprint.currentIndex = value;
} else if (field === 3 && wireType === 0) {
fingerprint.deviceIndexes.push(readVarint());
} else if (field === 3 && wireType === 2) {
const length = readVarint();
const end = offset + length;
if (end > bytes.length) {
fail('wwebjs_canonical_app_state_fingerprint_invalid');
}
while (offset < end) {
fingerprint.deviceIndexes.push(readVarint());
}
if (offset !== end) {
fail('wwebjs_canonical_app_state_fingerprint_invalid');
}
} else if (wireType === 0) {
readVarint();
} else if (wireType === 2) {
const length = readVarint();
offset += length;
if (offset > bytes.length) {
fail('wwebjs_canonical_app_state_fingerprint_invalid');
}
} else {
fail('wwebjs_canonical_app_state_fingerprint_invalid');
}
}
return fingerprint;
};
const byteLengthIs = (value, length) => {
if (value instanceof ArrayBuffer) return value.byteLength === length;
return ArrayBuffer.isView(value) && value.byteLength === length;
};
const signalApi = requireModule('WAWebSignalStoreApi');
const signalStorage = requireModule('WAWebSignalStorage');
const kyberStore = requireModule('WAWebKyberPreKeyStore');
const pqGating = requireModule('WAWebPQGatingUtils');
const signalConst = requireModule('WAWebSignalConst');
const signalCommon = requireModule('WAWebSignalCommonUtils');
const sessionsApi = requireModule('WASignalSessions');
const groupSessionsApi = requireModule('WASignalGroupSession');
const localStorageProto = requireModule('WASignalLocalStorageProtocol.pb');
const protobufDecoder = requireModule('decodeProtobuf');
const curve = requireModule('WAWebCryptoCurve25519');
const signalSignatures = requireModule('WASignalSignatures');
const kem =
projection.tables.pq_pre_keys.length > 0 && !pqPairValidationProof
? requireModule('WAKemModule')
: undefined;
const noiseStoreModule = requireModule('WAWebUserPrefsInfoStore');
const multiDevice = requireModule('WAWebUserPrefsMultiDevice');
const syncdDb = requireModule('WAWebSyncdDb');
const syncKeysSchema = requireModule('WAWebSchemaSyncKeys');
const collectionSchema = requireModule('WAWebSchemaCollectionVersion');
const syncActionsSchema = requireModule('WAWebSchemaSyncActions');
const pendingMutationsSchema = requireModule('WAWebSchemaPendingMutations');
const missingKeysSchema = requireModule('WAWebSchemaMissingKeys');
const widFactory = requireModule('WAWebWidFactory');
const meUserPrefs = requireModule('WAWebUserPrefsMeUser');
const userPrefsGeneral = requireModule('WAWebUserPrefsGeneral');
const mobilePlatforms = requireModule('WAWebMobilePlatforms');
const lidMigration = requireModule('WAWebLid1X1MigrationGating');
const userPrefsTypes = requireModule('WAWebUserPrefsTypes');
const signalStore = signalApi?.waSignalStore;
const noiseStore = noiseStoreModule?.waNoiseInfo;
if (
!signalStore ||
typeof signalStorage?.initialize !== 'function' ||
typeof signalStore.setRegistrationInfo !== 'function' ||
typeof signalStore.getRegistrationInfo !== 'function' ||
typeof signalStore.putSignedPreKeys !== 'function' ||
typeof signalStore.getSignedPreKey !== 'function' ||
typeof signalStore.putADVSignedIdentity !== 'function' ||
typeof signalStore.getADVSignedIdentity !== 'function' ||
typeof signalStore.putPreKeys !== 'function' ||
typeof signalStore.bulkPutIdentity !== 'function' ||
typeof signalStore.bulkPutSession !== 'function' ||
typeof signalStore.bulkPutSenderKey !== 'function' ||
typeof signalStore.putMeta !== 'function' ||
!(signalStore.idToSignedPreKey instanceof Map) ||
typeof signalStore.idToSignedPreKey.clear !== 'function' ||
typeof kyberStore?.isPQMigrated !== 'function' ||
typeof kyberStore?.setPQMigrated !== 'function' ||
typeof pqGating?.isPqKeysUploadEnabled !== 'function' ||
typeof pqGating?.isPq1on1MessageEnabled !== 'function' ||
typeof noiseStore?.set !== 'function' ||
typeof noiseStore?.get !== 'function' ||
typeof curve?.keyPair !== 'function' ||
typeof signalSignatures?.verifyMsgSignalVariant !== 'function' ||
typeof protobufDecoder?.decodeProtobuf !== 'function' ||
typeof sessionsApi?.parseSessionFromRecord !== 'function' ||
typeof groupSessionsApi?.parseSessionFromRecord !== 'function' ||
!localStorageProto?.RecordStructureSpec ||
!localStorageProto?.SenderKeyRecordStructureSpec ||
typeof syncdDb?.createSyncKey !== 'function' ||
typeof meUserPrefs?.setMe !== 'function' ||
typeof meUserPrefs?.setMeLid !== 'function' ||
typeof userPrefsGeneral?.setLastMobilePlatform !== 'function' ||
userPrefsGeneral.setLastMobilePlatform.length !== 1 ||
typeof userPrefsGeneral?.getLastMobilePlatform !== 'function' ||
userPrefsGeneral.getLastMobilePlatform.length !== 0 ||
typeof userPrefsGeneral?.setPushname !== 'function' ||
userPrefsGeneral.setPushname.length !== 1 ||
typeof userPrefsGeneral?.getPushname !== 'function' ||
userPrefsGeneral.getPushname.length !== 0 ||
typeof lidMigration?.Lid1X1MigrationUtils?.setIsLidMigrated !==
'function' ||
typeof lidMigration?.Lid1X1MigrationUtils?.isLidMigrated !==
'function' ||
userPrefsTypes?.LidMigrationSource?.HISTORY === undefined ||
typeof multiDevice?.isRegistered !== 'function' ||
typeof multiDevice?.setADVSecretKey !== 'function' ||
typeof multiDevice?.getADVSecretKey !== 'function' ||
typeof multiDevice?.setRoutingInfo !== 'function' ||
typeof multiDevice?.getRoutingInfo !== 'function'
) {
fail('wwebjs_canonical_module_abi_incompatible');
}
const resolveSignalTables = () => [
signalStorage.getBaseKeyTable?.(),
signalStorage.getIdentityTable?.(),
signalStorage.getMetaTable?.(),
signalStorage.getPreKeyTable?.(),
signalStorage.getSessionTable?.(),
signalStorage.getSignedPreKeyTable?.(),
signalStorage.getSenderKeyTable?.(),
];
const writableSignalTables = (tables) =>
Array.isArray(tables) &&
tables.length === 7 &&
tables.every((table) => table && typeof table.clear === 'function');
let signalTables;
let bootstrapRegistration;
currentImportStage = setImportStage('signal_storage_bootstrap_probe');
try {
signalTables = resolveSignalTables();
} catch {
signalTables = undefined;
}
let projectedIdentityKeyShapeValid;
try {
const projectedIdentityKey = fromBase64(
projection.device.identity_key,
32,
);
projectedIdentityKeyShapeValid = projectedIdentityKey.byteLength === 32;
projectedIdentityKey.fill(0);
} catch {
projectedIdentityKeyShapeValid = false;
}
const reusableProfileProjectionProof =
preserveExistingAppState &&
writableSignalTables(signalTables) &&
signalTables.every((table) => typeof table.all === 'function') &&
Number.isSafeInteger(projection.device.registration_id) &&
projection.device.registration_id > 0 &&
projection.device.registration_id <= 0xffffffff &&
projectedIdentityKeyShapeValid &&
/^[a-f0-9]{64}$/u.test(
String(projection.device.device_fingerprint || ''),
);
if (!reusableProfileProjectionProof) {
try {
bootstrapRegistration = await signalStore.getRegistrationInfo();
} catch {
bootstrapRegistration = undefined;
}
}
const bootstrapSignalStorageReady =
reusableProfileProjectionProof ||
(writableSignalTables(signalTables) &&
signalTables.every((table) => typeof table.all === 'function') &&
Number.isSafeInteger(bootstrapRegistration?.registrationId) &&
bootstrapRegistration.registrationId > 0 &&
byteLengthIs(bootstrapRegistration?.identityKeyPair?.pubKey, 32) &&
byteLengthIs(bootstrapRegistration?.identityKeyPair?.privKey, 32));
const signalStorageInitializationMode = bootstrapSignalStorageReady
? 'preinitialized_bootstrap_proof'
: 'official_initialize';
if (!bootstrapSignalStorageReady) {
await importStep('signal_storage_initialize', () =>
signalStorage.initialize(),
);
signalTables = await importStep('signal_table_resolution', async () =>
resolveSignalTables(),
);
}
const copyBytes = (value, expectedLength) => {
const view =
value instanceof ArrayBuffer
? new Uint8Array(value)
: ArrayBuffer.isView(value)
? new Uint8Array(
value.buffer,
value.byteOffset,
value.byteLength,
)
: undefined;
if (!view || view.byteLength !== expectedLength) return undefined;
return new Uint8Array(view);
};
const wipeBytes = (value) => {
const view =
value instanceof ArrayBuffer
? new Uint8Array(value)
: ArrayBuffer.isView(value)
? new Uint8Array(
value.buffer,
value.byteOffset,
value.byteLength,
)
: undefined;
view?.fill(0);
};
const bytesEqual = (left, right) => {
const leftBytes =
left instanceof ArrayBuffer
? new Uint8Array(left)
: ArrayBuffer.isView(left)
? new Uint8Array(
left.buffer,
left.byteOffset,
left.byteLength,
)
: undefined;
const rightBytes =
right instanceof ArrayBuffer
? new Uint8Array(right)
: ArrayBuffer.isView(right)
? new Uint8Array(
right.buffer,
right.byteOffset,
right.byteLength,
)
: undefined;
if (
!leftBytes ||
!rightBytes ||
leftBytes.length !== rightBytes.length
) {
return false;
}
let difference = 0;
for (let index = 0; index < leftBytes.length; index += 1) {
difference |= leftBytes[index] ^ rightBytes[index];
}
return difference === 0;
};
const safeCounter = (value) => Number.isSafeInteger(value) && value >= 0;
const validateMessageKeys = (keys) => {
if (!Array.isArray(keys) || keys.length > 2000) return false;
return keys.every(
(key) =>
key &&
safeCounter(key.index) &&
byteLengthIs(key.cipherKey, 32) &&
byteLengthIs(key.macKey, 32) &&
byteLengthIs(key.iv, 16),
);
};
const validateSignalSessionState = (state) => {
if (
!state ||
state.sessionVersion !== 3 ||
!byteLengthIs(state.localIdentityPublic, 33) ||
!byteLengthIs(state.remoteIdentityPublic, 33) ||
!byteLengthIs(state.rootKey, 32) ||
!byteLengthIs(state.aliceBaseKey, 33) ||
!safeCounter(state.previousCounter) ||
!safeCounter(state.remoteRegistrationId) ||
!safeCounter(state.localRegistrationId) ||
!state.senderChain ||
!byteLengthIs(state.senderChain.senderRatchetKey, 33) ||
!byteLengthIs(state.senderChain.senderRatchetKeyPrivate, 32) ||
!state.senderChain.chainKey ||
!safeCounter(state.senderChain.chainKey.index) ||
!byteLengthIs(state.senderChain.chainKey.key, 32) ||
!validateMessageKeys(state.senderChain.messageKeys) ||
!Array.isArray(state.receiverChains) ||
state.receiverChains.length > 5
) {
return false;
}
return state.receiverChains.every(
(chain) =>
chain &&
byteLengthIs(chain.senderRatchetKey, 33) &&
chain.chainKey &&
safeCounter(chain.chainKey.index) &&
byteLengthIs(chain.chainKey.key, 32) &&
validateMessageKeys(chain.messageKeys),
);
};
const validateSignalRecord = (record) =>
record &&
validateSignalSessionState(record.currentSession) &&
Array.isArray(record.previousSessions) &&
record.previousSessions.length <= 40 &&
record.previousSessions.every(validateSignalSessionState);
const validateSenderKeyRecord = (record) =>
record &&
Array.isArray(record.senderKeyStates) &&
record.senderKeyStates.length > 0 &&
record.senderKeyStates.length <= 5 &&
record.senderKeyStates.every(
(state) =>
state &&
safeCounter(state.senderKeyId) &&
state.senderChainKey &&
safeCounter(state.senderChainKey.iteration) &&
byteLengthIs(state.senderChainKey.seed, 32) &&
state.senderSigningKey &&
byteLengthIs(state.senderSigningKey.public, 33) &&
(state.senderSigningKey.private === undefined ||
byteLengthIs(state.senderSigningKey.private, 32)) &&
Array.isArray(state.senderMessageKeys) &&
state.senderMessageKeys.length <= 2000 &&
state.senderMessageKeys.every(
(key) =>
key &&
safeCounter(key.iteration) &&
byteLengthIs(key.seed, 32),
),
);
const sensitive = [];
const secretBytes = (value, length) => {
const bytes = fromBase64(value, length);
sensitive.push(bytes);
return bytes;
};
const derivePair = async (privateBytes) => {
const privateCopy = new Uint8Array(privateBytes);
sensitive.push(privateCopy);
const pair = await curve.keyPair(asArrayBuffer(privateCopy));
const publicBytes = new Uint8Array(pair.pubKey);
const normalizedPrivate = new Uint8Array(pair.privKey);
if (
publicBytes.byteLength !== 32 ||
normalizedPrivate.byteLength !== 32
) {
fail('wwebjs_canonical_key_derivation_failed');
}
sensitive.push(normalizedPrivate);
return {
pubKey: asArrayBuffer(publicBytes),
privKey: asArrayBuffer(normalizedPrivate),
publicBytes,
};
};
try {
currentImportStage = setImportStage('device_validation');
const device = projection.device;
const identityPrivate = secretBytes(device.identity_key, 32);
const noisePrivate = secretBytes(device.noise_key, 32);
const signedPrivate = secretBytes(device.signed_pre_key, 32);
const signedSignature = fromBase64(device.signed_pre_key_sig, 64);
const advDetails = fromBase64(device.adv_details);
const advAccountSignature = fromBase64(device.adv_account_sig, 64);
const advAccountSignatureKey = fromBase64(
device.adv_account_sig_key,
32,
);
const advDeviceSignature = fromBase64(device.adv_device_sig, 64);
const advSecret = device.adv_secret_available
? secretBytes(device.adv_key, 32)
: undefined;
let routingInfo;
if (projection.transport_state != null) {
if (
projection.transport_state.kind !== 'whatsapp-transport-v1' ||
projection.transport_state.codec_version !== 1
) {
fail('wwebjs_canonical_transport_state_invalid');
}
routingInfo = secretBytes(projection.transport_state.routing_info);
if (routingInfo.byteLength < 1 || routingInfo.byteLength > 65535) {
fail('wwebjs_canonical_routing_info_invalid');
}
} else if (options?.requireRoutingInfo === true) {
fail('wwebjs_canonical_routing_info_unavailable');
}
if (advDetails.byteLength === 0) {
fail('wwebjs_canonical_projection_incompatible');
}
const [identityPair, noisePair, signedPair] = await importStep(
'device_key_derivation',
() =>
Promise.all([
derivePair(identityPrivate),
derivePair(noisePrivate),
derivePair(signedPrivate),
]),
);
const noiseBootstrap =
projection.provider_state || options?.noiseBootstrap;
const regenerateNoiseMetadata =
!noiseBootstrap && options?.allowNoiseMetadataBootstrap === true;
if (
!regenerateNoiseMetadata &&
(!noiseBootstrap ||
noiseBootstrap.kind !== 'wwebjs-noise-bootstrap-v1' ||
noiseBootstrap.codec_version !== 1 ||
noiseBootstrap.device_fingerprint !== device.device_fingerprint)
) {
fail('wwebjs_canonical_noise_metadata_unavailable');
}
const expectedNoisePublic = regenerateNoiseMetadata
? new Uint8Array(noisePair.publicBytes)
: fromBase64(noiseBootstrap.noise_public_key, 32);
let noisePublicDifference = 0;
for (let index = 0; index < 32; index += 1) {
noisePublicDifference |=
expectedNoisePublic[index] ^ noisePair.publicBytes[index];
}
if (noisePublicDifference !== 0) {
fail('wwebjs_canonical_noise_metadata_mismatch');
}
let noiseRecoveryToken;
let noiseCertificateChain;
if (regenerateNoiseMetadata) {
noiseRecoveryToken = new Uint8Array(24);
crypto.getRandomValues(noiseRecoveryToken);
sensitive.push(noiseRecoveryToken);
} else {
noiseRecoveryToken = secretBytes(noiseBootstrap.recovery_token, 24);
noiseCertificateChain =
noiseBootstrap.certificate_chain === null
? undefined
: fromBase64(noiseBootstrap.certificate_chain);
}
const serializedIdentityPublic = new Uint8Array(33);
serializedIdentityPublic[0] = 5;
serializedIdentityPublic.set(identityPair.publicBytes, 1);
const serializedSignedPublic = new Uint8Array(33);
serializedSignedPublic[0] = 5;
serializedSignedPublic.set(signedPair.publicBytes, 1);
const signedPreKeyValid = await importStep(
'signed_pre_key_validation',
async () =>
signalSignatures.verifyMsgSignalVariant(
serializedIdentityPublic,
serializedSignedPublic,
signedSignature,
),
);
if (!signedPreKeyValid) {
fail('wwebjs_canonical_signed_pre_key_invalid');
}
const fingerprint = await fingerprintDigest([
identityPair.publicBytes,
advDetails,
advAccountSignatureKey,
advAccountSignature,
advDeviceSignature,
]);
const [
noisePublicHash,
identityPublicHash,
signedIdentityHash,
routingInfoHash,
] = await Promise.all([
bytesDigest(noisePair.publicBytes),
bytesDigest(identityPair.publicBytes),
signedIdentityDigest([
advDetails,
advAccountSignatureKey,
advAccountSignature,
advDeviceSignature,
]),
routingInfo ? bytesDigest(routingInfo) : Promise.resolve(null),
]);
if (
!constantTimeHexEqual(
fingerprint,
String(device.device_fingerprint || '').toLowerCase(),
)
) {
fail('wwebjs_canonical_device_fingerprint_mismatch');
}
let me;
let meLid;
const supportedPrimaryPlatforms = new Set(
Object.values(mobilePlatforms?.PLATFORMS || {}).filter(
(value) => typeof value === 'string',
),
);
if (
typeof device.platform !== 'string' ||
!supportedPrimaryPlatforms.has(device.platform)
) {
fail('wwebjs_canonical_primary_platform_invalid');
}
if (!device.lid || projection.capabilities?.lid_migrated !== true) {
fail('wwebjs_canonical_lid_migration_state_unavailable');
}
try {
me = widFactory.createWid(device.jid);
meLid = device.lid ? widFactory.createWid(device.lid) : undefined;
} catch {
fail('wwebjs_canonical_device_jid_incompatible');
}
if (!me) fail('wwebjs_canonical_device_jid_incompatible');
currentImportStage = setImportStage('identity_validation');
const identities = projection.tables.identity_keys.map((row) => {
const identifier = parseCanonicalSignalAddress(row.their_id);
const rawIdentity = fromBase64(row.identity, 32);
const serialized = new Uint8Array(33);
serialized[0] = 5;
serialized.set(rawIdentity, 1);
return {
identifier,
identityKey: signalCommon.bufferToStr(
asArrayBuffer(serialized),
),
};
});
const preKeys = [];
// Reusable-profile equivalence is calculated from an offline native
// export and the exact target projection before the page import begins.
// When the classic pre-key component matches, re-deriving thousands of
// identical public keys is both redundant and the dominant migration
// cost. We still decode every private row and validate all boundaries,
// but retain no key pair and perform no native write for that component.
if (preserveExistingSignalComponents.pre_keys) {
currentImportStage = setImportStage('preserved_pre_key_validation');
for (const row of projection.tables.pre_keys) {
secretBytes(row.key, 32);
preKeys.push({
keyId: row.key_id,
uploaded: row.uploaded,
});
}
} else {
// The private curve module is not concurrency-safe at unbounded
// cardinality. Derive a bounded batch at a time so a normal 800-key
// replenishment cannot overload its internal worker/transaction.
for (
let start = 0;
start < projection.tables.pre_keys.length;
start += 32
) {
const batch = projection.tables.pre_keys.slice(
start,
start + 32,
);
const derived = await importStep('pre_key_derivation', () =>
Promise.all(
batch.map(async (row) => {
const privateKey = secretBytes(row.key, 32);
return {
keyId: row.key_id,
keyPair: await derivePair(privateKey),
uploaded: row.uploaded,
};
}),
),
);
preKeys.push(...derived);
}
}
preKeys.sort((left, right) => left.keyId - right.keyId);
let firstUnuploaded = device.next_pre_key_id;
const firstPending = preKeys.find((row) => !row.uploaded);
if (firstPending) firstUnuploaded = firstPending.keyId;
if (
preKeys.some(
(row) => row.uploaded !== row.keyId < firstUnuploaded,
) ||
preKeys.some((row) => row.keyId >= device.next_pre_key_id)
) {
fail('wwebjs_canonical_pre_key_boundary_invalid');
}
currentImportStage = setImportStage('pq_pre_key_validation');
if (projection.tables.pq_pre_key_state.length > 1) {
fail('wwebjs_canonical_pq_pre_key_state_duplicate');
}
const pqState = projection.tables.pq_pre_key_state[0];
if (!pqState && projection.tables.pq_pre_keys.length > 0) {
fail('wwebjs_canonical_pq_pre_key_state_missing');
}
const pqHasServerCount =
Boolean(pqState) && pqState.last_server_count !== null;
const pqServerCountMode =
projection.capabilities?.pq_server_count_mode ??
(pqState && pqHasServerCount ? 'observed' : 'reset');
if (
pqState &&
(pqState.codec_version !== 1 ||
pqState.algorithm !== 'ML-KEM-1024' ||
!safeCounter(pqState.next_pre_key_id) ||
pqState.next_pre_key_id >= 0xffffff ||
typeof pqState.migrated !== 'boolean' ||
(pqState.last_server_count === null) !==
(pqState.last_server_count_timestamp_ms === null) ||
(pqState.last_server_count !== null &&
(!safeCounter(pqState.last_server_count) ||
!safeCounter(pqState.last_server_count_timestamp_ms))))
) {
fail('wwebjs_canonical_pq_pre_key_state_invalid');
}
if (
!['unknown', 'observed', 'reset'].includes(pqServerCountMode) ||
(pqState &&
((pqServerCountMode === 'observed' && !pqHasServerCount) ||
(pqServerCountMode === 'reset' && pqHasServerCount))) ||
(!pqState && pqServerCountMode !== 'reset')
) {
fail('wwebjs_canonical_pq_server_count_mode_invalid');
}
if (
projection.tables.pq_pre_keys.length > 0 &&
!pqPairValidationProof &&
(typeof kem?.encapsulate !== 'function' ||
typeof kem?.decapsulate !== 'function')
) {
fail('wwebjs_canonical_pq_materialization_abi_incompatible');
}
const pqPreKeys = [];
let pqLastResortKey;
const pqKeyIds = new Set();
const pqKeyMaterials = [];
let pqLastResortMaterialSeen = false;
for (const row of projection.tables.pq_pre_keys) {
if (
!row ||
!['one_time', 'last_resort'].includes(row.key_kind) ||
!safeCounter(row.key_id) ||
row.key_id >= 0xffffff ||
pqKeyIds.has(row.key_id) ||
!safeCounter(row.timestamp_ms) ||
typeof row.sent_to_server !== 'boolean'
) {
fail('wwebjs_canonical_pq_pre_key_row_invalid');
}
if (row.key_kind === 'last_resort') {
if (pqLastResortMaterialSeen) {
fail('wwebjs_canonical_pq_last_resort_key_duplicate');
}
pqLastResortMaterialSeen = true;
}
pqKeyIds.add(row.key_id);
const publicKey = fromBase64(row.public_key, 1568);
const privateKey = secretBytes(row.private_key, 3168);
const signature = fromBase64(row.signature, 64);
pqKeyMaterials.push({ row, publicKey, privateKey, signature });
}
for (const material of pqKeyMaterials) {
const signatureValid = await importStep(
'pq_pre_key_signature_validation',
() =>
signalSignatures.verifyMsgSignalVariant(
serializedIdentityPublic,
material.publicKey,
material.signature,
),
);
if (!signatureValid) {
fail('wwebjs_canonical_pq_pre_key_signature_invalid');
}
}
setImportProgress(0, pqKeyMaterials.length);
if (pqPairValidationProof) {
const commitmentFields = pqKeyMaterials.flatMap(
({ row, publicKey, privateKey, signature }) => [
new TextEncoder().encode(
JSON.stringify([
row.key_id,
row.key_kind,
row.timestamp_ms,
row.sent_to_server,
]),
),
publicKey,
privateKey,
signature,
],
);
const commitment = await importStep(
'pq_pre_key_pair_validation_proof',
() =>
domainDigest(
'underchat-wwebjs-pq-pair-validation-v1',
commitmentFields,
),
);
if (
pqPairValidationProof.key_count !== pqKeyMaterials.length ||
!constantTimeHexEqual(
commitment,
pqPairValidationProof.commitment,
)
) {
fail('wwebjs_canonical_pq_validation_proof_invalid');
}
setImportProgress(pqKeyMaterials.length, pqKeyMaterials.length);
} else {
// Retain a compatibility fallback for direct library consumers
// that do not use BrowserSessionBridge's Node-side proof. The
// WWeb private KEM implementation can serialize these nominally
// asynchronous calls, so staged provider handoffs never use this
// renderer path.
const pqPairValidationBatchSize = 4;
for (
let start = 0;
start < pqKeyMaterials.length;
start += pqPairValidationBatchSize
) {
const batch = pqKeyMaterials.slice(
start,
start + pqPairValidationBatchSize,
);
const settled = await importStep(
'pq_pre_key_pair_validation',
() =>
Promise.allSettled(
batch.map(async (material) => {
let encapsulated;
let decapsulated;
try {
encapsulated = await kem.encapsulate(
material.publicKey,
);
if (
!byteLengthIs(
encapsulated?.ciphertext,
1568,
) ||
!byteLengthIs(
encapsulated?.sharedSecret,
32,
)
) {
fail(
'wwebjs_canonical_pq_pre_key_pair_invalid',
);
}
decapsulated = await kem.decapsulate(
encapsulated.ciphertext,
material.privateKey,
);
if (
!byteLengthIs(decapsulated, 32) ||
!bytesEqual(
decapsulated,
encapsulated.sharedSecret,
)
) {
fail(
'wwebjs_canonical_pq_pre_key_pair_invalid',
);
}
return material;
} finally {
wipeBytes(encapsulated?.sharedSecret);
wipeBytes(decapsulated);
}
}),
),
);
const rejected = settled.find(
(result) => result.status === 'rejected',
);
if (rejected) throw rejected.reason;
setImportProgress(
Math.min(start + batch.length, pqKeyMaterials.length),
pqKeyMaterials.length,
);
}
}
// Only copy private material into the objects destined for the native
// store after every pair has passed. A rejection in a late batch must
// leave no successful earlier-batch copy outside the sensitive wipe
// list while the import exits before its first destructive write.
for (const {
row,
publicKey,
privateKey,
signature,
} of pqKeyMaterials) {
const key = {
keyId: row.key_id,
keyPair: {
pubKey: asArrayBuffer(publicKey),
privKey: asArrayBuffer(privateKey),
},
signature: asArrayBuffer(signature),
timestamp: row.timestamp_ms,
sentToServer: row.sent_to_server,
};
if (row.key_kind === 'last_resort') {
pqLastResortKey = key;
} else {
pqPreKeys.push(key);
}
}
pqPreKeys.sort((left, right) => left.keyId - right.keyId);
if (
pqState &&
((pqState.migrated &&
(!pqLastResortKey || !pqLastResortKey.sentToServer)) ||
(!pqState.migrated &&
(pqLastResortKey?.sentToServer ||
pqPreKeys.some((key) => key.sentToServer))) ||
(!pqState.migrated && pqPreKeys.length > 0 && !pqLastResortKey))
) {
fail('wwebjs_canonical_pq_migration_state_diverged');
}
const pqCapabilities = projection.capabilities;
if (
!pqCapabilities ||
pqCapabilities.pq_migrated !== Boolean(pqState?.migrated) ||
pqCapabilities.pq_storage_mode !==
(pqState ? 'legacy_tables' : 'rollout_without_tables') ||
(pqCapabilities.pq_server_count_mode ?? pqServerCountMode) !==
pqServerCountMode ||
pqCapabilities.pq_pre_key_count !== pqPreKeys.length ||
pqCapabilities.pq_last_resort_key_count !==
(pqLastResortKey ? 1 : 0)
) {
fail('wwebjs_canonical_pq_capability_invalid');
}
currentImportStage = setImportStage('signal_session_validation');
const knownSignalScopes = ['default', 'status', 'pq'];
const sourceSignalScopes =
projection.capabilities?.canonical_signal_session_scopes;
const signalSessionResyncRequired =
projection.capabilities
?.canonical_signal_session_resync_required === true;
if (
sourceSignalScopes !== undefined &&
(!Array.isArray(sourceSignalScopes) ||
sourceSignalScopes.length === 0 ||
sourceSignalScopes.some(
(scope, index) =>
!knownSignalScopes.includes(scope) ||
sourceSignalScopes.indexOf(scope) !== index,
))
) {
fail('wwebjs_canonical_signal_scope_capability_invalid');
}
const importableSignalScopes = new Set(
sourceSignalScopes || knownSignalScopes,
);
const declaredResyncScopes =
projection.capabilities?.canonical_signal_session_resync_scopes;
if (
declaredResyncScopes !== undefined &&
(!Array.isArray(declaredResyncScopes) ||
declaredResyncScopes.some(
(scope, index) =>
!knownSignalScopes.includes(scope) ||
declaredResyncScopes.indexOf(scope) !== index,
))
) {
fail('wwebjs_canonical_signal_scope_capability_invalid');
}
const signalSessionResyncScopes = signalSessionResyncRequired
? new Set(
declaredResyncScopes ||
knownSignalScopes.filter(
(scope) => !importableSignalScopes.has(scope),
),
)
: new Set();
if (
signalSessionResyncRequired &&
[...signalSessionResyncScopes].some((scope) =>
importableSignalScopes.has(scope),
)
) {
fail('wwebjs_canonical_signal_scope_capability_invalid');
}
const importedSignalRows = signalSessionResyncRequired
? projection.tables.signal_sessions.filter((row) =>
importableSignalScopes.has(row.scope),
)
: projection.tables.signal_sessions;
const discardedSignalSessionCount =
projection.tables.signal_sessions.length -
importedSignalRows.length;
const sessions = importedSignalRows.map((row) => {
const address = parseCanonicalSignalAddress(
row.their_id,
row.scope,
);
try {
const decoded = protobufDecoder.decodeProtobuf(
localStorageProto.RecordStructureSpec,
fromBase64(row.session),
);
if (!validateSignalRecord(decoded)) {
fail('wwebjs_canonical_signal_session_limits_invalid');
}
return {
address,
session: sessionsApi.parseSessionFromRecord(decoded),
};
} catch {
fail('wwebjs_canonical_signal_session_incompatible');
}
});
currentImportStage = setImportStage('sender_key_validation');
const senderKeys = projection.tables.sender_keys.map((row) => {
const senderAddress = parseCanonicalSignalAddress(row.sender_id);
let decoded;
try {
decoded = protobufDecoder.decodeProtobuf(
localStorageProto.SenderKeyRecordStructureSpec,
fromBase64(row.sender_key),
);
} catch {
fail('wwebjs_canonical_sender_key_incompatible');
}
const normalized = normalizeCanonicalSenderKeyRecord(decoded);
if (!validateSenderKeyRecord(normalized)) {
fail('wwebjs_canonical_sender_key_limits_invalid');
}
let senderKey;
try {
senderKey = groupSessionsApi.parseSessionFromRecord(normalized);
} catch {
fail('wwebjs_canonical_sender_key_runtime_parse_incompatible');
}
return {
senderKeyName: `${row.chat_id}::${senderAddress}`,
senderId: senderAddress,
senderKey,
};
});
currentImportStage = setImportStage('app_state_validation');
const syncKeys = projection.tables.app_state_sync_keys.map((row) => {
const keyId = fromBase64(row.key_id);
if (keyId.byteLength < 2) {
fail('wwebjs_canonical_app_state_key_epoch_invalid');
}
return {
// WAWebSchemaSyncKeys.convertFromSyncKeyToRow reads
// keyId.buffer before rebuilding the Uint8Array row key. The
// read API returns an ArrayBuffer, but feeding that same shape
// back into createSyncKey loses the key because ArrayBuffer
// itself has no `.buffer` property.
keyId: new Uint8Array(keyId),
keyData: asArrayBuffer(secretBytes(row.key_data, 32)),
timestamp: row.timestamp,
fingerprint: parseFingerprint(fromBase64(row.fingerprint)),
keyEpoch:
keyId[keyId.byteLength - 2] * 256 +
keyId[keyId.byteLength - 1],
};
});
// All decoding and cryptographic validation above is side-effect free.
// Only now may the empty candidate profile be hydrated.
if (!writableSignalTables(signalTables)) {
fail('wwebjs_canonical_module_abi_incompatible');
}
// Current WhatsApp Web builds can expose the two legacy Kyber table
// getters even when the rollout deliberately omitted those tables.
// In that state both getters throw NoSuchTableError and the supported
// API is WAWebKyberPreKeyStore. Treating the getters as mandatory made
// every canonical handoff fail before the first write. Conversely, a
// migrated/enabled PQ runtime without its backing tables is unsafe and
// must fail closed instead of silently discarding post-quantum state.
const legacyKyberTables = [];
let missingLegacyKyberTables = 0;
for (const getterName of [
'getKyberPreKeyTable',
'getKyberLastResortKeyTable',
]) {
const getter = signalStorage?.[getterName];
if (typeof getter !== 'function') {
fail('wwebjs_canonical_pq_storage_abi_incompatible');
}
try {
const table = getter.call(signalStorage);
if (!table || typeof table.clear !== 'function') {
fail('wwebjs_canonical_pq_storage_abi_incompatible');
}
legacyKyberTables.push(table);
} catch (error) {
if (error?.name !== 'NoSuchTableError') {
if (
/^wwebjs_canonical_/u.test(String(error?.message || ''))
) {
throw error;
}
fail('wwebjs_canonical_pq_storage_unavailable');
}
missingLegacyKyberTables += 1;
}
}
if (missingLegacyKyberTables !== 0 && missingLegacyKyberTables !== 2) {
fail('wwebjs_canonical_pq_storage_inconsistent');
}
const pqUploadEnabled = Boolean(pqGating.isPqKeysUploadEnabled());
const pqMessagingEnabled = Boolean(pqGating.isPq1on1MessageEnabled());
// On rollout builds without either physical Kyber table,
// isPQMigrated() waits for an internal store deadline even though the
// source projection and runtime upload gate independently prove that
// no PQ material exists or can be published. Avoid that redundant
// native read only for this exact empty-rollout shape. Any table,
// source PQ state, non-zero count, enabled upload gate or capability
// disagreement still executes the official probe and therefore keeps
// the fail-closed path for migrated companions.
const declaredEmptyPqRollout =
!pqState &&
pqPreKeys.length === 0 &&
!pqLastResortKey &&
pqCapabilities?.pq_migrated === false &&
pqCapabilities?.pq_storage_mode === 'rollout_without_tables' &&
pqCapabilities?.pq_pre_key_count === 0 &&
pqCapabilities?.pq_last_resort_key_count === 0 &&
pqServerCountMode === 'reset' &&
missingLegacyKyberTables === 2 &&
pqUploadEnabled === false;
const pqFlags = await importStep(
'pq_capability_resolution',
async () => ({
migrated: declaredEmptyPqRollout
? false
: Boolean(await kyberStore.isPQMigrated()),
uploadEnabled: pqUploadEnabled,
messagingEnabled: pqMessagingEnabled,
}),
);
if (
(pqState?.migrated || pqFlags.uploadEnabled) &&
pqFlags.messagingEnabled !== true
) {
// Once a companion has published (or can automatically publish) a
// PQ bundle, the server may send PQXDH messages immediately. The
// official upload task is registered independently of our manual
// bootstrap call, so reject this rollout combination before any
// write instead of relying only on ensurePostQuantumReady().
fail('wwebjs_canonical_pq_messaging_runtime_disabled');
}
if (
pqState &&
(missingLegacyKyberTables !== 0 ||
typeof kyberStore.saveKyberPreKeys !== 'function' ||
typeof kyberStore.saveKyberLastResortKey !== 'function' ||
typeof kyberStore.loadKyberPreKey !== 'function' ||
typeof kyberStore.loadLatestKyberLastResortKey !== 'function' ||
(typeof kyberStore.getUnsentKyberPreKeys !== 'function' &&
typeof window.__WWebSessionBridge
?.readUnsentKyberPreKeys !== 'function') ||
typeof kyberStore.loadKyberKeysForDigest !== 'function' ||
typeof kyberStore.getNextKyberPreKeyId !== 'function' ||
signalConst?.META_KEYS?.NEXT_KYBER_PK_ID == null)
) {
fail('wwebjs_canonical_pq_materialization_abi_incompatible');
}
const signalTablesToClear = [
// Device/base metadata and signed credentials are always resealed;
// preservation applies only to independently checksummed bulk data.
signalTables[0],
signalTables[2],
signalTables[5],
...(!preserveExistingSignalComponents.identity_keys
? [signalTables[1]]
: []),
...(!preserveExistingSignalComponents.pre_keys
? [signalTables[3]]
: []),
...(!preserveExistingSignalComponents.signal_sessions
? [signalTables[4]]
: []),
...(!preserveExistingSignalComponents.sender_keys
? [signalTables[6]]
: []),
];
await importStep(
preservedSignalComponents.length > 0
? 'clear_signal_credential_tables'
: 'clear_signal_tables',
() =>
Promise.all(signalTablesToClear.map((table) => table.clear())),
);
// getSignedPreKey() is backed by a closure-level Map that survives an
// IndexedDB table clear and even clearCredential(). Without explicitly
// invalidating this pinned private-ABI cache, a reconnect-time read can
// read back the SPK generated by the unpaired bootstrap instead of the
// row just persisted below.
signalStore.idToSignedPreKey.clear();
if (
legacyKyberTables.length > 0 &&
!preserveExistingSignalComponents.pq_pre_keys
) {
await importStep('clear_pq_tables', () =>
Promise.all(legacyKyberTables.map((table) => table.clear())),
);
}
await importStep('reset_pq_migration', () =>
kyberStore.setPQMigrated(false),
);
signalStore.registrationInfo = undefined;
await importStep('registration', () =>
signalStore.setRegistrationInfo({
registrationId: device.registration_id,
identityKeyPair: {
pubKey: identityPair.pubKey,
privKey: identityPair.privKey,
},
}),
);
await importStep('signed_pre_key', () =>
signalStore.putSignedPreKeys([
{
keyId: device.signed_pre_key_id,
keyPair: {
pubKey: signedPair.pubKey,
privKey: signedPair.privKey,
},
signature: asArrayBuffer(signedSignature),
},
]),
);
await importStep('signal_meta', () =>
signalStore.putMeta([
{
key: signalConst.META_KEYS.LAST_SPK_ID,
value: device.signed_pre_key_id,
},
{
key: signalConst.META_KEYS.FIRST_UNUPLOAD_PK_ID,
value: firstUnuploaded,
},
{
key: signalConst.META_KEYS.NEXT_PK_ID,
value: device.next_pre_key_id,
},
{
key: signalConst.META_KEYS.SERVER_HAS_KEY,
value: preKeys.some((row) => row.uploaded),
},
...(pqState
? [
{
key: signalConst.META_KEYS.NEXT_KYBER_PK_ID,
value: pqState.next_pre_key_id,
},
]
: []),
]),
);
await importStep('adv_identity', () =>
signalStore.putADVSignedIdentity({
details: asArrayBuffer(advDetails),
accountSignature: asArrayBuffer(advAccountSignature),
accountSignatureKey: asArrayBuffer(advAccountSignatureKey),
deviceSignature: asArrayBuffer(advDeviceSignature),
}),
);
if (typeof multiDevice?.setADVSecretKey !== 'function') {
fail('wwebjs_canonical_module_abi_incompatible');
}
await importStep('adv_secret', () =>
multiDevice.setADVSecretKey(
advSecret ? toBase64(advSecret) : undefined,
),
);
await importStep('noise_identity', () =>
noiseStore.set({
recoveryToken: asArrayBuffer(noiseRecoveryToken),
staticKeyPair: {
pubKey: noisePair.pubKey,
privKey: noisePair.privKey,
},
certificateChainBuffer: noiseCertificateChain
? asArrayBuffer(noiseCertificateChain)
: undefined,
}),
);
if (routingInfo) {
await importStep('routing_info', () =>
multiDevice.setRoutingInfo({
domain: 'fb',
edgeRouting: new Uint8Array(routingInfo),
}),
);
}
// Establish the registered tuple before the slower bulk imports. The
// unpaired bootstrap otherwise remains free to rotate credentials
// locally while hundreds of prekeys are being derived/written.
await importStep('me_preferences', () => meUserPrefs.setMe(me));
if (meLid) {
await importStep('me_lid_preferences', () =>
meUserPrefs.setMeLid(meLid),
);
}
await importStep('lid_migration_state', async () => {
await lidMigration.Lid1X1MigrationUtils.setIsLidMigrated(
true,
userPrefsTypes.LidMigrationSource.HISTORY,
);
if (lidMigration.Lid1X1MigrationUtils.isLidMigrated() !== true) {
fail('wwebjs_canonical_lid_migration_state_write_failed');
}
});
await importStep('primary_platform', async () => {
await userPrefsGeneral.setLastMobilePlatform(device.platform);
if (
(await userPrefsGeneral.getLastMobilePlatform()) !==
device.platform
) {
fail('wwebjs_canonical_primary_platform_write_failed');
}
});
if (
typeof device.push_name === 'string' &&
device.push_name.length > 0
) {
await importStep('push_name', async () => {
userPrefsGeneral.setPushname(device.push_name);
if (userPrefsGeneral.getPushname() !== device.push_name) {
fail('wwebjs_canonical_push_name_write_failed');
}
});
}
if (preKeys.length > 0 && !preserveExistingSignalComponents.pre_keys) {
await importStep('pre_keys', () =>
signalStore.putPreKeys(
preKeys.map((row) => ({
keyId: row.keyId,
keyPair: {
pubKey: row.keyPair.pubKey,
privKey: row.keyPair.privKey,
},
})),
),
);
}
if (
pqPreKeys.length > 0 &&
!preserveExistingSignalComponents.pq_pre_keys
) {
await importStep('pq_pre_keys', () =>
kyberStore.saveKyberPreKeys(pqPreKeys),
);
}
if (pqLastResortKey && !preserveExistingSignalComponents.pq_pre_keys) {
const lastResortKey = {
keyId: pqLastResortKey.keyId,
keyPair: pqLastResortKey.keyPair,
signature: pqLastResortKey.signature,
timestamp: pqLastResortKey.timestamp,
};
await importStep('pq_last_resort_key', () =>
kyberStore.saveKyberLastResortKey(lastResortKey),
);
}
if (pqState) {
await importStep('pq_migration_state', () =>
kyberStore.setPQMigrated(pqState.migrated),
);
const [
persistedPqPreKeys,
persistedPqLastResortKey,
unsentPqPreKeys,
pqDigest,
persistedNextPqPreKeyId,
persistedPqMigrated,
] = await importStep('pq_material_readback', () =>
Promise.all([
Promise.all(
pqPreKeys.map((key) =>
kyberStore.loadKyberPreKey(key.keyId),
),
),
kyberStore.loadLatestKyberLastResortKey(),
typeof kyberStore.getUnsentKyberPreKeys === 'function'
? kyberStore.getUnsentKyberPreKeys()
: window.__WWebSessionBridge.readUnsentKyberPreKeys(),
kyberStore.loadKyberKeysForDigest(),
kyberStore.getNextKyberPreKeyId(),
kyberStore.isPQMigrated(),
]),
);
const pqStoredRowMatches = (expected, actual) =>
actual?.keyId === expected.keyId &&
actual?.timestamp === expected.timestamp &&
actual?.sentToServer === expected.sentToServer &&
bytesEqual(actual?.keyPair?.pubKey, expected.keyPair.pubKey) &&
bytesEqual(
actual?.keyPair?.privKey,
expected.keyPair.privKey,
) &&
bytesEqual(actual?.signature, expected.signature);
const persistedOneTimeValid =
Array.isArray(persistedPqPreKeys) &&
persistedPqPreKeys.length === pqPreKeys.length &&
persistedPqPreKeys.every((row, index) =>
pqStoredRowMatches(pqPreKeys[index], row),
);
const persistedLastResortValid = pqLastResortKey
? persistedPqLastResortKey?.keyId === pqLastResortKey.keyId &&
persistedPqLastResortKey?.timestamp ===
pqLastResortKey.timestamp &&
bytesEqual(
persistedPqLastResortKey?.keyPair?.pubKey,
pqLastResortKey.keyPair.pubKey,
) &&
bytesEqual(
persistedPqLastResortKey?.keyPair?.privKey,
pqLastResortKey.keyPair.privKey,
) &&
bytesEqual(
persistedPqLastResortKey?.signature,
pqLastResortKey.signature,
)
: persistedPqLastResortKey == null;
const expectedUnsentIds = pqPreKeys
.filter((key) => !key.sentToServer)
.map((key) => key.keyId)
.sort((left, right) => left - right);
const actualUnsentIds = Array.isArray(unsentPqPreKeys)
? unsentPqPreKeys
.map((key) => key?.keyId)
.sort((left, right) => left - right)
: [];
const digestPreKeys = pqDigest?.kyberPreKeys;
const digestLastResortKeys = pqDigest?.kyberLastResortKeys;
const digestValid =
Array.isArray(digestPreKeys) &&
digestPreKeys.length === pqPreKeys.length &&
digestPreKeys.every(
(row, index) =>
row?.keyId === pqPreKeys[index].keyId &&
bytesEqual(
row?.publicKey,
pqPreKeys[index].keyPair.pubKey,
),
) &&
Array.isArray(digestLastResortKeys) &&
digestLastResortKeys.length === (pqLastResortKey ? 1 : 0) &&
(!pqLastResortKey ||
(digestLastResortKeys[0]?.keyId === pqLastResortKey.keyId &&
bytesEqual(
digestLastResortKeys[0]?.publicKey,
pqLastResortKey.keyPair.pubKey,
) &&
bytesEqual(
digestLastResortKeys[0]?.signature,
pqLastResortKey.signature,
)));
if (
!persistedOneTimeValid ||
!persistedLastResortValid ||
!Array.isArray(unsentPqPreKeys) ||
expectedUnsentIds.length !== actualUnsentIds.length ||
expectedUnsentIds.some(
(keyId, index) => keyId !== actualUnsentIds[index],
) ||
!digestValid ||
persistedNextPqPreKeyId !== pqState.next_pre_key_id ||
Boolean(persistedPqMigrated) !== pqState.migrated
) {
fail('wwebjs_canonical_pq_material_readback_invalid');
}
}
if (
identities.length > 0 &&
!preserveExistingSignalComponents.identity_keys
) {
await importStep('identities', () =>
signalStore.bulkPutIdentity(identities),
);
}
if (
sessions.length > 0 &&
!preserveExistingSignalComponents.signal_sessions
) {
await importStep('signal_sessions', () =>
signalStore.bulkPutSession(sessions),
);
}
if (
senderKeys.length > 0 &&
!preserveExistingSignalComponents.sender_keys
) {
await importStep('sender_keys', () =>
signalStore.bulkPutSenderKey(senderKeys),
);
}
if (!preserveExistingAppState) {
const appStateTables = await importStep(
'app_state_table_resolution',
async () => [
syncKeysSchema.getSyncKeysTable?.(),
collectionSchema.getCollectionVersionTable?.(),
syncActionsSchema.getSyncActionsTable?.(),
pendingMutationsSchema.getPendingMutationsTable?.(),
missingKeysSchema.getMissingKeysTable?.(),
],
);
if (
appStateTables.some(
(table) => !table || typeof table.clear !== 'function',
)
) {
fail('wwebjs_canonical_module_abi_incompatible');
}
await importStep('clear_app_state_tables', () =>
Promise.all(appStateTables.map((table) => table.clear())),
);
}
// sync-keys.keyData is encrypted by WhatsApp Web's final database
// message-encryption key. A fresh cross-provider profile deliberately
// does not have that key while the browser is offline, so attempting a
// write here fails with DbMsgEncKeyNotLoaded. Keep the validated rows
// in the Node-side handoff projection. RemoteAuth installs them only
// after the imported socket has authenticated and then drives the
// official full app-state sync before any checkpoint or promotion.
// Seal the critical tuple after all bulk work. This is intentionally
// a second write followed by an official-API readback: if an unpaired
// bootstrap writer rotated registration/SPK while the import was in
// progress, the candidate fails here while it is still offline.
const criticalSignedPreKeyTable =
signalStorage.getSignedPreKeyTable?.();
if (
!criticalSignedPreKeyTable ||
typeof criticalSignedPreKeyTable.clear !== 'function'
) {
fail('wwebjs_canonical_module_abi_incompatible');
}
await importStep('critical_seal_signed_pre_key_clear', () =>
criticalSignedPreKeyTable.clear(),
);
signalStore.idToSignedPreKey.clear();
signalStore.registrationInfo = undefined;
await importStep('critical_seal_registration', () =>
signalStore.setRegistrationInfo({
registrationId: device.registration_id,
identityKeyPair: {
pubKey: identityPair.pubKey,
privKey: identityPair.privKey,
},
}),
);
await importStep('critical_seal_signed_pre_key', () =>
signalStore.putSignedPreKeys([
{
keyId: device.signed_pre_key_id,
keyPair: {
pubKey: signedPair.pubKey,
privKey: signedPair.privKey,
},
signature: asArrayBuffer(signedSignature),
},
]),
);
await importStep('critical_seal_adv_identity', () =>
signalStore.putADVSignedIdentity({
details: asArrayBuffer(advDetails),
accountSignature: asArrayBuffer(advAccountSignature),
accountSignatureKey: asArrayBuffer(advAccountSignatureKey),
deviceSignature: asArrayBuffer(advDeviceSignature),
}),
);
await importStep('critical_seal_adv_secret', () =>
multiDevice.setADVSecretKey(
advSecret ? toBase64(advSecret) : undefined,
),
);
await importStep('critical_seal_noise_identity', () =>
noiseStore.set({
recoveryToken: asArrayBuffer(noiseRecoveryToken),
staticKeyPair: {
pubKey: noisePair.pubKey,
privKey: noisePair.privKey,
},
certificateChainBuffer: noiseCertificateChain
? asArrayBuffer(noiseCertificateChain)
: undefined,
}),
);
if (routingInfo) {
await importStep('critical_seal_routing_info', () =>
multiDevice.setRoutingInfo({
domain: 'fb',
edgeRouting: new Uint8Array(routingInfo),
}),
);
}
await importStep('critical_seal_me_preferences', () =>
meUserPrefs.setMe(me),
);
if (meLid) {
await importStep('critical_seal_me_lid_preferences', () =>
meUserPrefs.setMeLid(meLid),
);
}
const [
sealedRegistration,
sealedSignedPreKey,
sealedAdvIdentity,
sealedAdvSecret,
sealedNoiseInfo,
sealedRoutingInfo,
] = await importStep('critical_seal_readback', () =>
Promise.all([
signalStore.getRegistrationInfo(),
signalStore.getSignedPreKey(),
signalStore.getADVSignedIdentity(),
multiDevice.getADVSecretKey(),
noiseStore.get(),
multiDevice.getRoutingInfo(),
]),
);
const sealedIdentityPublic = new Uint8Array(33);
sealedIdentityPublic[0] = 5;
const sealedIdentityPublicBytes =
sealedRegistration?.identityKeyPair?.pubKey;
if (byteLengthIs(sealedIdentityPublicBytes, 32)) {
sealedIdentityPublic.set(
new Uint8Array(
sealedIdentityPublicBytes.buffer ||
sealedIdentityPublicBytes,
sealedIdentityPublicBytes.byteOffset || 0,
sealedIdentityPublicBytes.byteLength,
),
1,
);
}
const sealedSignedPublic = new Uint8Array(33);
sealedSignedPublic[0] = 5;
const sealedSignedPublicBytes = sealedSignedPreKey?.keyPair?.pubKey;
if (byteLengthIs(sealedSignedPublicBytes, 32)) {
sealedSignedPublic.set(
new Uint8Array(
sealedSignedPublicBytes.buffer || sealedSignedPublicBytes,
sealedSignedPublicBytes.byteOffset || 0,
sealedSignedPublicBytes.byteLength,
),
1,
);
}
// IndexedDB-backed signal storage currently reads signatures as an
// ArrayBuffer while verifyMsgSignalVariant expects a Uint8Array. The
// initial validation already receives a Uint8Array; normalize the
// official-store readback to the same ABI before verifying it.
const sealedSignedSignature = copyBytes(
sealedSignedPreKey?.signature,
64,
);
const sealedSignatureValid =
sealedSignedSignature !== undefined &&
(await importStep('critical_seal_signature_validation', () =>
signalSignatures.verifyMsgSignalVariant(
sealedIdentityPublic,
sealedSignedPublic,
sealedSignedSignature,
),
));
let sealedAdvSecretBytes;
if (typeof sealedAdvSecret === 'string' && sealedAdvSecret.length > 0) {
try {
const binary = atob(sealedAdvSecret);
sealedAdvSecretBytes = Uint8Array.from(binary, (entry) =>
entry.charCodeAt(0),
);
} catch {
sealedAdvSecretBytes = undefined;
}
}
const sealedAdvSecretValid = advSecret
? bytesEqual(sealedAdvSecretBytes, advSecret)
: sealedAdvSecret == null || sealedAdvSecret === '';
const criticalSealChecks = [
[
'registration_id',
sealedRegistration?.registrationId === device.registration_id,
],
[
'identity_public',
bytesEqual(
sealedRegistration?.identityKeyPair?.pubKey,
identityPair.pubKey,
),
],
[
'identity_private',
bytesEqual(
sealedRegistration?.identityKeyPair?.privKey,
identityPair.privKey,
),
],
[
'signed_pre_key_id',
sealedSignedPreKey?.keyId === device.signed_pre_key_id,
],
[
'signed_pre_key_public',
bytesEqual(
sealedSignedPreKey?.keyPair?.pubKey,
signedPair.pubKey,
),
],
[
'signed_pre_key_private',
bytesEqual(
sealedSignedPreKey?.keyPair?.privKey,
signedPair.privKey,
),
],
[
'signed_pre_key_signature_bytes',
bytesEqual(sealedSignedPreKey?.signature, signedSignature),
],
['signed_pre_key_signature_math', sealedSignatureValid === true],
['adv_details', bytesEqual(sealedAdvIdentity?.details, advDetails)],
[
'adv_account_signature',
bytesEqual(
sealedAdvIdentity?.accountSignature,
advAccountSignature,
),
],
[
'adv_account_signature_key',
bytesEqual(
sealedAdvIdentity?.accountSignatureKey,
advAccountSignatureKey,
),
],
[
'adv_device_signature',
bytesEqual(
sealedAdvIdentity?.deviceSignature,
advDeviceSignature,
),
],
['adv_secret', sealedAdvSecretValid],
[
'noise_public',
bytesEqual(
sealedNoiseInfo?.staticKeyPair?.pubKey,
noisePair.pubKey,
),
],
[
'noise_private',
bytesEqual(
sealedNoiseInfo?.staticKeyPair?.privKey,
noisePair.privKey,
),
],
[
'noise_recovery',
bytesEqual(sealedNoiseInfo?.recoveryToken, noiseRecoveryToken),
],
[
'routing_info',
routingInfo
? sealedRoutingInfo?.domain === 'fb' &&
bytesEqual(sealedRoutingInfo?.edgeRouting, routingInfo)
: sealedRoutingInfo == null,
],
['registered_flag', multiDevice.isRegistered() === true],
];
const failedCriticalSealChecks = criticalSealChecks
.filter(([, valid]) => !valid)
.map(([category]) => category);
if (failedCriticalSealChecks.length > 0) {
fail(
`wwebjs_canonical_critical_seal_invalid:${failedCriticalSealChecks.join(
',',
)}`,
);
}
window.__WWebSessionBridge?.markClean?.();
setImportStage('completion');
return {
imported_records: projection.record_count,
imported_signal_sessions: sessions.length,
imported_sender_keys: senderKeys.length,
imported_app_state_sync_keys: 0,
pending_app_state_sync_keys: preserveExistingAppState
? 0
: syncKeys.length,
adv_secret_available: Boolean(advSecret),
app_state_import_mode: preserveExistingAppState
? 'preserved_native_profile'
: 'deferred_authenticated_full_sync',
noise_metadata_mode: regenerateNoiseMetadata
? 'canonical_noise_metadata_bootstrap_canary'
: 'preserved',
signal_storage_initialization_mode: signalStorageInitializationMode,
signal_table_import_mode:
preservedSignalComponents.length === signalComponentNames.length
? 'preserved_equivalent_bulk_tables'
: preservedSignalComponents.length > 0
? 'selective_component_replace'
: 'full_replace',
preserved_signal_components: preservedSignalComponents,
replaced_signal_components: replacedSignalComponents,
pq_key_mode: pqState
? 'preserved'
: pqFlags.uploadEnabled
? CANONICAL_PQ_KEY_MODE_GENERATE_AFTER_RECONNECT
: 'disabled',
imported_pq_pre_key_count: pqPreKeys.length,
imported_pq_last_resort_key_count: pqLastResortKey ? 1 : 0,
imported_pq_migrated: Boolean(pqState?.migrated),
pq_storage_mode:
legacyKyberTables.length === 2
? 'legacy_tables'
: pqFlags.migrated ||
pqFlags.uploadEnabled ||
pqFlags.messagingEnabled
? 'managed_store'
: 'rollout_without_tables',
imported_signal_session_scopes: [
...new Set(importedSignalRows.map((row) => row.scope)),
].sort(),
discarded_signal_session_count: discardedSignalSessionCount,
signal_session_resync_required: signalSessionResyncRequired,
signal_session_resync_scopes: [...signalSessionResyncScopes].sort(),
status_session_mode: signalSessionResyncScopes.has('status')
? 'lazy_official_session_establishment'
: 'preserved',
routing_info_imported: Boolean(routingInfo),
generated_provider_state: regenerateNoiseMetadata
? {
kind: 'wwebjs-noise-bootstrap-v1',
codec_version: 1,
device_fingerprint: device.device_fingerprint,
noise_public_key: toBase64(noisePair.publicBytes),
recovery_token: toBase64(noiseRecoveryToken),
certificate_chain: null,
}
: undefined,
expected_registration_state: {
registration_id: device.registration_id,
noise_public_hash: noisePublicHash,
identity_public_hash: identityPublicHash,
signed_identity_hash: signedIdentityHash,
companion_fingerprint: fingerprint,
signed_pre_key_id: device.signed_pre_key_id,
routing_info_hash: routingInfoHash,
noise_keypair_math_valid: true,
identity_keypair_math_valid: true,
signed_pre_key_keypair_math_valid: true,
signed_pre_key_signature_valid: true,
},
reconnect_required: true,
reload_required: false,
};
} catch (error) {
if (/^wwebjs_canonical_/u.test(String(error?.message || ''))) {
throw error;
}
fail(`wwebjs_canonical_import_${currentImportStage}_failed`);
} finally {
for (const bytes of sensitive) bytes.fill(0);
}
}
module.exports = {
CANONICAL_FINGERPRINT_VERSION,
CANONICAL_PQ_KEY_MODE_GENERATE_AFTER_RECONNECT,
CANONICAL_PRIVATE_MODULE_ABI,
CANONICAL_SESSION_CODEC_KIND,
CANONICAL_SESSION_CODEC_VERSION,
CANONICAL_SESSION_SCHEMA_VERSION,
CANONICAL_SESSION_STORE_ABI,
DEFAULT_MAX_CANONICAL_BYTES,
DEFAULT_MAX_CANONICAL_RECORDS,
canonicalBrowserProjectionToStore,
canonicalStoreProjectionToBrowser,
assertCanonicalTargetProviderCompatibility,
exportCanonicalSessionProjection,
importCanonicalSessionProjection,
normalizeCanonicalProjection,
};