'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const CHROMIUM_LOCK_FILES = new Set([
'DevToolsActivePort',
'SingletonCookie',
'SingletonLock',
'SingletonSocket',
]);
const WHATSAPP_WEB_PACKAGE_V1 = 'underchat-wa-web-session-v1';
const BROWSER_PROJECTION_SCHEMA_VERSION = 2;
const MAX_BROWSER_PROJECTION_RECORDS = 20000;
const MAX_BROWSER_PROJECTION_BYTES = 64 * 1024 * 1024;
const MAX_BROWSER_VALUE_DEPTH = 20;
const isRecord = (value) =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const assertSupportedPackage = (sessionPackage) => {
if (!sessionPackage || sessionPackage.source !== 'whatsapp_web') {
throw new Error('wwebjs_import_source_unsupported');
}
const targetProvider = sessionPackage.target_provider || 'auto';
if (targetProvider !== 'auto' && targetProvider !== 'wwebjs') {
throw new Error('wwebjs_import_target_provider_mismatch');
}
};
const assertExactObjectKeys = (value, keys, code) => {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
throw new Error(code);
}
};
const normalizeBase64 = (value) => {
if (
typeof value !== 'string' ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
value,
)
) {
throw new Error('wwebjs_import_profile_binary_invalid');
}
const bytes = Buffer.from(value, 'base64');
if (bytes.toString('base64') !== value) {
throw new Error('wwebjs_import_profile_binary_invalid');
}
return value;
};
const convertBrowserValue = (value, depth = 0) => {
if (depth > MAX_BROWSER_VALUE_DEPTH) {
throw new Error('wwebjs_import_profile_value_too_deep');
}
if (
value === null ||
typeof value === 'string' ||
typeof value === 'boolean'
) {
return value;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
throw new Error('wwebjs_import_profile_value_invalid');
}
return value;
}
if (Array.isArray(value)) {
return value.map((item) => convertBrowserValue(item, depth + 1));
}
if (!isRecord(value)) {
throw new Error('wwebjs_import_profile_value_invalid');
}
const hasWwebjsTag = Object.hasOwn(value, '__wwebjs_type');
const hasUnderchatTag = Object.hasOwn(value, '__underchat_type');
if (hasWwebjsTag && hasUnderchatTag) {
throw new Error('wwebjs_import_profile_value_invalid');
}
const tagKey = hasWwebjsTag
? '__wwebjs_type'
: hasUnderchatTag
? '__underchat_type'
: undefined;
const taggedType = tagKey ? value[tagKey] : undefined;
if (taggedType !== undefined) {
if (typeof taggedType !== 'string') {
throw new Error('wwebjs_import_profile_value_invalid');
}
if (taggedType === 'undefined') {
assertExactObjectKeys(
value,
[tagKey],
'wwebjs_import_profile_value_invalid',
);
return { __wwebjs_type: 'undefined' };
}
if (taggedType === 'bytes' || taggedType === 'array_buffer') {
assertExactObjectKeys(
value,
[tagKey, 'data'],
'wwebjs_import_profile_value_invalid',
);
return {
__wwebjs_type: 'array_buffer',
data: normalizeBase64(value.data),
};
}
if (taggedType === 'bigint') {
assertExactObjectKeys(
value,
[tagKey, 'value'],
'wwebjs_import_profile_value_invalid',
);
if (
typeof value.value !== 'string' ||
!/^-?(?:0|[1-9]\d*)$/.test(value.value)
) {
throw new Error('wwebjs_import_profile_bigint_invalid');
}
return { __wwebjs_type: 'bigint', value: value.value };
}
if (taggedType === 'date') {
assertExactObjectKeys(
value,
[tagKey, 'value'],
'wwebjs_import_profile_value_invalid',
);
if (
typeof value.value !== 'string' ||
!Number.isFinite(Date.parse(value.value)) ||
new Date(value.value).toISOString() !== value.value
) {
throw new Error('wwebjs_import_profile_date_invalid');
}
return { __wwebjs_type: 'date', value: value.value };
}
if (taggedType === 'blob') {
assertExactObjectKeys(
value,
[tagKey, 'mime_type', 'data'],
'wwebjs_import_profile_value_invalid',
);
if (typeof value.mime_type !== 'string') {
throw new Error('wwebjs_import_profile_blob_invalid');
}
return {
__wwebjs_type: 'blob',
mime_type: value.mime_type,
data: normalizeBase64(value.data),
};
}
if (taggedType === 'regexp') {
assertExactObjectKeys(
value,
[tagKey, 'source', 'flags'],
'wwebjs_import_profile_value_invalid',
);
if (
typeof value.source !== 'string' ||
typeof value.flags !== 'string'
) {
throw new Error('wwebjs_import_profile_regexp_invalid');
}
let regexp;
try {
regexp = new RegExp(value.source, value.flags);
} catch {
throw new Error('wwebjs_import_profile_regexp_invalid');
}
if (regexp.flags !== value.flags) {
throw new Error('wwebjs_import_profile_regexp_invalid');
}
return {
__wwebjs_type: 'regexp',
source: value.source,
flags: value.flags,
};
}
if (taggedType === 'map') {
assertExactObjectKeys(
value,
[tagKey, 'entries'],
'wwebjs_import_profile_value_invalid',
);
if (
!Array.isArray(value.entries) ||
value.entries.some(
(entry) => !Array.isArray(entry) || entry.length !== 2,
)
) {
throw new Error('wwebjs_import_profile_map_invalid');
}
return {
__wwebjs_type: 'map',
entries: value.entries.map(([key, entry]) => [
convertBrowserValue(key, depth + 1),
convertBrowserValue(entry, depth + 1),
]),
};
}
if (taggedType === 'set') {
assertExactObjectKeys(
value,
[tagKey, 'values'],
'wwebjs_import_profile_value_invalid',
);
if (!Array.isArray(value.values)) {
throw new Error('wwebjs_import_profile_set_invalid');
}
return {
__wwebjs_type: 'set',
values: value.values.map((entry) =>
convertBrowserValue(entry, depth + 1),
),
};
}
// A package advertised as complete may not contain placeholders that
// lose identity-bearing browser values.
throw new Error('wwebjs_import_profile_value_lossy');
}
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
convertBrowserValue(item, depth + 1),
]),
);
};
const normalizeKeyPath = (keyPath) => {
if (keyPath === null || typeof keyPath === 'string') return keyPath;
if (
Array.isArray(keyPath) &&
keyPath.every((entry) => typeof entry === 'string')
) {
return [...keyPath];
}
throw new Error('wwebjs_import_profile_key_path_invalid');
};
/**
* Converts the Chrome extension's lossless WhatsApp Web profile into the
* BrowserSessionBridge ABI. Only packages produced by the loss-aware v1
* serializer are accepted. Older v1 packages omitted completeness metadata
* while silently dropping CryptoKey/cyclic values and are intentionally
* rejected rather than treated as a recoverable browser session.
*/
const browserProjectionFromWhatsAppWebProfile = (sessionPackage) => {
assertSupportedPackage(sessionPackage);
if (sessionPackage.format_version !== WHATSAPP_WEB_PACKAGE_V1) {
throw new Error('wwebjs_import_format_unsupported');
}
if (
typeof sessionPackage.web_version !== 'string' ||
!sessionPackage.web_version.trim() ||
sessionPackage.web_version.length > 100 ||
sessionPackage.web_version !== sessionPackage.web_version.trim()
) {
throw new Error('wwebjs_import_web_version_missing');
}
const profile = sessionPackage.payload?.whatsapp_web_profile;
if (!isRecord(profile)) {
throw new Error('wwebjs_import_profile_missing');
}
if (profile.complete !== true || profile.lossyRecordCount !== 0) {
throw new Error('wwebjs_import_profile_lossy');
}
if (profile.serializationFormat !== 'wwebjs-browser-value-v1') {
throw new Error('wwebjs_import_profile_serialization_unsupported');
}
if (!isRecord(profile.localStorage) || !isRecord(profile.signalStorage)) {
throw new Error('wwebjs_import_profile_storage_missing');
}
const signalStorage = profile.signalStorage;
if (
signalStorage.databaseName !== 'signal-storage' ||
!Array.isArray(signalStorage.stores)
) {
throw new Error('wwebjs_import_profile_signal_storage_invalid');
}
const records = [];
const indexeddbStores = [];
const addresses = new Set();
const storeNames = new Set();
const pushRecord = (record) => {
const address = `${record.namespace}\u0000${record.record_key}`;
if (addresses.has(address)) {
throw new Error('wwebjs_import_profile_duplicate_record');
}
addresses.add(address);
records.push(record);
if (records.length > MAX_BROWSER_PROJECTION_RECORDS) {
throw new Error('wwebjs_import_profile_limit_exceeded');
}
};
for (const [key, value] of Object.entries(profile.localStorage)) {
if (!key || typeof value !== 'string') {
throw new Error('wwebjs_import_profile_local_storage_invalid');
}
pushRecord({
namespace: 'local_storage',
record_key: key,
value,
});
}
for (const store of signalStorage.stores) {
if (
!isRecord(store) ||
typeof store.name !== 'string' ||
!store.name ||
storeNames.has(store.name) ||
typeof store.autoIncrement !== 'boolean' ||
!Array.isArray(store.records)
) {
throw new Error('wwebjs_import_profile_store_invalid');
}
storeNames.add(store.name);
const keyPath = normalizeKeyPath(store.keyPath);
indexeddbStores.push({
database_name: signalStorage.databaseName,
store_name: store.name,
key_path: keyPath,
});
const namespace = `indexeddb:${encodeURIComponent(signalStorage.databaseName)}:${encodeURIComponent(store.name)}`;
for (const entry of store.records) {
if (
!isRecord(entry) ||
!Object.hasOwn(entry, 'key') ||
!Object.hasOwn(entry, 'value')
) {
throw new Error('wwebjs_import_profile_record_invalid');
}
const convertedKey = convertBrowserValue(entry.key);
const recordKey = JSON.stringify(convertedKey);
if (recordKey === undefined) {
throw new Error('wwebjs_import_profile_record_invalid');
}
pushRecord({
namespace,
record_key: recordKey,
value: convertBrowserValue(entry.value),
key_path: keyPath,
});
}
}
const sizeBytes = records.reduce(
(total, record) => total + Buffer.byteLength(JSON.stringify(record)),
0,
);
if (sizeBytes > MAX_BROWSER_PROJECTION_BYTES) {
throw new Error('wwebjs_import_profile_limit_exceeded');
}
return {
schema_version: BROWSER_PROJECTION_SCHEMA_VERSION,
web_version: sessionPackage.web_version,
complete: true,
lossy_records: 0,
size_bytes: sizeBytes,
indexeddb_stores: indexeddbStores,
records,
};
};
const getLocalAuthPayload = (sessionPackage) => {
const payload = sessionPackage.payload;
if (!isRecord(payload)) {
throw new Error('wwebjs_import_payload_missing');
}
const localAuthPayload =
payload.wwebjs_local_auth ||
payload.local_auth ||
payload.wwebjsLocalAuth;
if (!isRecord(localAuthPayload) || !isRecord(localAuthPayload.files)) {
throw new Error('wwebjs_import_payload_unsupported');
}
return localAuthPayload.files;
};
const assertSafeRelativePath = (relativePath) => {
if (
!relativePath ||
path.isAbsolute(relativePath) ||
relativePath.split(/[\\/]/).includes('..') ||
relativePath.includes('\0')
) {
throw new Error(`wwebjs_import_invalid_profile_file:${relativePath}`);
}
};
const decodeFileValue = (value) => {
if (typeof value === 'string') {
return Buffer.from(value, 'utf8');
}
if (!isRecord(value)) {
throw new Error('wwebjs_import_invalid_file_payload');
}
const data = value.data ?? value.content;
const encoding = value.encoding || 'utf8';
if (typeof data !== 'string') {
throw new Error('wwebjs_import_invalid_file_payload');
}
if (encoding !== 'utf8' && encoding !== 'base64') {
throw new Error('wwebjs_import_invalid_file_encoding');
}
return Buffer.from(data, encoding);
};
const pathExists = async (targetPath) =>
Boolean(await fs.promises.stat(targetPath).catch(() => undefined));
const sessionDirNameForClientId = (clientId) => {
if (!clientId) {
return 'session';
}
if (!/^[-_\w]+$/i.test(clientId)) {
throw new Error('wwebjs_import_invalid_client_id');
}
return `session-${clientId}`;
};
const resolveLocalAuthSessionPath = ({ clientId, dataPath }) =>
path.join(
path.resolve(dataPath || './.wwebjs_auth/'),
sessionDirNameForClientId(clientId),
);
const writeProfileFiles = async (profilePath, files) => {
await fs.promises.mkdir(profilePath, { recursive: true });
await Promise.all(
Object.entries(files).map(async ([relativePath, value]) => {
assertSafeRelativePath(relativePath);
const outputPath = path.join(profilePath, relativePath);
await fs.promises.mkdir(path.dirname(outputPath), {
recursive: true,
});
await fs.promises.writeFile(outputPath, decodeFileValue(value));
}),
);
};
const cleanChromiumProfileLocks = async (profilePath) => {
const entries = await fs.promises
.readdir(profilePath, { withFileTypes: true })
.catch(() => []);
await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(profilePath, entry.name);
if (CHROMIUM_LOCK_FILES.has(entry.name)) {
await fs.promises.rm(entryPath, {
recursive: true,
force: true,
});
return;
}
if (entry.isDirectory()) {
await cleanChromiumProfileLocks(entryPath);
}
}),
);
};
/**
* Restores a normalized WhatsApp Web profile package into the directory used by LocalAuth.
*
* Raw browser cookies/localStorage are not enough to build a Chromium profile. The package must include
* `payload.wwebjs_local_auth.files`, where each key is a profile-relative path and each value is either
* a UTF-8 string or `{ data, encoding: "utf8" | "base64" }`.
*/
const importWhatsAppWebSessionToLocalAuth = async ({
sessionPackage,
clientId,
dataPath,
overwrite = true,
backupPath,
cleanupBackupOnSuccess = false,
}) => {
assertSupportedPackage(sessionPackage);
const files = getLocalAuthPayload(sessionPackage);
const fileNames = Object.keys(files);
if (!fileNames.length) {
throw new Error('wwebjs_import_empty_profile');
}
const sessionPath = resolveLocalAuthSessionPath({ clientId, dataPath });
const tempPath = `${sessionPath}.secure-import-${crypto.randomUUID()}`;
const resolvedBackupPath =
backupPath ||
`${sessionPath}.backup-${Date.now()}-${crypto
.randomUUID()
.slice(0, 8)}`;
let backupCreated = false;
await fs.promises.mkdir(path.dirname(sessionPath), { recursive: true });
try {
await writeProfileFiles(tempPath, files);
await cleanChromiumProfileLocks(tempPath);
if (await pathExists(sessionPath)) {
if (!overwrite) {
throw new Error('wwebjs_import_auth_folder_exists');
}
await fs.promises.rm(resolvedBackupPath, {
recursive: true,
force: true,
});
await fs.promises.rename(sessionPath, resolvedBackupPath);
backupCreated = true;
}
await fs.promises.rename(tempPath, sessionPath);
if (backupCreated && cleanupBackupOnSuccess) {
await fs.promises.rm(resolvedBackupPath, {
recursive: true,
force: true,
});
}
return {
sessionPath,
backupPath:
backupCreated && !cleanupBackupOnSuccess
? resolvedBackupPath
: undefined,
importedFiles: fileNames.sort(),
formatVersion: sessionPackage.format_version,
accountHint: sessionPackage.account_hint,
};
} catch (error) {
await fs.promises
.rm(tempPath, { recursive: true, force: true })
.catch(() => undefined);
if (backupCreated && !(await pathExists(sessionPath))) {
await fs.promises
.rename(resolvedBackupPath, sessionPath)
.catch(() => undefined);
}
throw error;
}
};
module.exports = {
browserProjectionFromWhatsAppWebProfile,
cleanChromiumProfileLocks,
importWhatsAppWebSessionToLocalAuth,
resolveLocalAuthSessionPath,
};