157 lines
9.2 KiB
Python
157 lines
9.2 KiB
Python
"""Export local WhatsApp Desktop history through its own database decryption.
|
|
|
|
Requires an already-exposed Desktop WebView2 CDP endpoint. Does not enable CDP,
|
|
restart Desktop, use a separate browser, change messages, or request history sync.
|
|
"""
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import urllib.parse
|
|
import urllib.request
|
|
from .analyzer import endpoint
|
|
|
|
PROBE = r'''(async () => {
|
|
const options = __OPTIONS__;
|
|
if (location.origin !== 'https://web.whatsapp.com') throw Error('Unexpected origin');
|
|
const table = require('WAWebSchemaMessage').getMessageTable();
|
|
const serializer = require('WAWebDBMessageSerialization');
|
|
const names = await indexedDB.databases();
|
|
if (!names.some(d => d.name === 'model-storage')) throw Error('Database absent');
|
|
const db = await new Promise((resolve,reject) => {
|
|
const r=indexedDB.open('model-storage');
|
|
r.onupgradeneeded=()=>{r.transaction.abort();reject(Error('Refusing database creation or upgrade'));};
|
|
r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(Error('Database open failed'));
|
|
r.onblocked=()=>reject(Error('Database blocked'));
|
|
});
|
|
const request=r=>new Promise((resolve,reject)=>{r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(Error('Database read failed'));});
|
|
try {
|
|
if (!db.objectStoreNames.contains('message')) throw Error('Message store absent');
|
|
if (options.mode==='bounds') {
|
|
const store=db.transaction('message','readonly').objectStore('message');
|
|
const [count,last]=await Promise.all([request(store.count()),request(store.openKeyCursor(null,'prev'))]);
|
|
const upper=last?.primaryKey??null;
|
|
if(upper!==null&&typeof upper!=='string') throw Error('Unsupported primary key type');
|
|
return {count,upper};
|
|
}
|
|
const keys=await new Promise((resolve,reject)=>{
|
|
const tx=db.transaction('message','readonly'),store=tx.objectStore('message'),values=[];
|
|
const range=options.after===null?IDBKeyRange.upperBound(options.upper):IDBKeyRange.bound(options.after,options.upper,true,false);
|
|
const r=store.openKeyCursor(range);
|
|
r.onerror=()=>reject(Error('Cursor read failed'));tx.onabort=()=>reject(Error('Cursor transaction aborted'));
|
|
r.onsuccess=()=>{const c=r.result;if(!c||values.length===options.batch){resolve(values);return;}
|
|
if(typeof c.primaryKey!=='string'){reject(Error('Unsupported primary key type'));return;}
|
|
values.push(c.primaryKey);c.continue();};
|
|
});
|
|
const messages=[];
|
|
const asString=v=>v==null?null:typeof v==='string'?v:v.toString();
|
|
for(const key of keys){
|
|
try {
|
|
// Default get(..., true) follows WAWebIdb.asyncView -> decryptDbMaterial.
|
|
const row=await table.get(key);
|
|
if(!row){messages.push({id:key,error:'row_missing_during_live_scan'});continue;}
|
|
if(row.msgRowOpaqueData&&typeof row.msgRowOpaqueData==='object'&&'_data' in row.msgRowOpaqueData)
|
|
throw Error('Opaque payload still encrypted');
|
|
const m=serializer.messageFromDbRow(row),id=m.id;
|
|
const out={id:key,message_id:id?.id??null,chat_id:asString(id?.remote),from_me:id?.fromMe??null,
|
|
type:m.type??null,timestamp:m.t??null,body_length:typeof m.body==='string'?m.body.length:null,
|
|
caption_length:typeof m.caption==='string'?m.caption.length:null};
|
|
if(!options.redact){out.body=typeof m.body==='string'?m.body:null;out.caption=typeof m.caption==='string'?m.caption:null;}
|
|
messages.push(out);
|
|
}catch(e){messages.push({id:key,error:'row_decryption_or_decoding_failed',error_type:e?.name??'Error'});}
|
|
}
|
|
return {keys,messages,last:keys.length?keys[keys.length-1]:options.after};
|
|
}finally{db.close();}
|
|
})()'''
|
|
|
|
def desktop_ports():
|
|
# Read process metadata only; never start a process with debugging flags.
|
|
script = r'''$p=@(Get-CimInstance Win32_Process -Filter "Name='WhatsApp.Root.exe' OR Name='msedgewebview2.exe'");$ids=@($p | Where-Object Name -eq 'WhatsApp.Root.exe' | ForEach-Object ProcessId);for($i=0;$i -lt 8;$i++){$ids+=@($p | Where-Object {$_.ParentProcessId -in $ids} | ForEach-Object ProcessId);$ids=@($ids | Select-Object -Unique)};$r=@($p | Where-Object {$_.ProcessId -in $ids -and $_.CommandLine -match '--remote-debugging-port=(\d+)'} | ForEach-Object {[int]$Matches[1]});ConvertTo-Json -InputObject $r -Compress'''
|
|
result=subprocess.run(['powershell.exe','-NoProfile','-NonInteractive','-Command',script],capture_output=True,text=True,check=True)
|
|
return set(json.loads(result.stdout or '[]'))
|
|
|
|
def evaluate(ws, expression, ident):
|
|
ws.send(json.dumps({'id':ident,'method':'Runtime.evaluate','params':{'expression':expression,'returnByValue':True,'awaitPromise':True}}))
|
|
while True:
|
|
response=json.loads(ws.recv(timeout=120))
|
|
if response.get('id')!=ident:continue
|
|
if response.get('error') or response.get('result',{}).get('exceptionDetails'):
|
|
raise RuntimeError('WhatsApp database query failed; session may be locked or modules unavailable')
|
|
return response['result']['result']['value']
|
|
|
|
def expression(options):
|
|
return PROBE.replace('__OPTIONS__',json.dumps(options))
|
|
|
|
def export(cdp, output, batch=100, redact=False):
|
|
output=Path(output)
|
|
if output.exists() or output.with_suffix(output.suffix+'.partial').exists():
|
|
raise RuntimeError('Output already exists; choose a new --output path')
|
|
return _export(cdp,output,batch,redact)
|
|
|
|
def _export(cdp, output, batch=100, redact=False):
|
|
from websockets.sync.client import connect
|
|
ports=desktop_ports()
|
|
if not ports:
|
|
raise RuntimeError('Running WhatsApp Desktop exposes no CDP interface. ReadProcessMemory cannot execute its database decryption methods; Desktop was not restarted or modified.')
|
|
if cdp is None:
|
|
if len(ports)!=1:raise RuntimeError('Multiple Desktop CDP ports; supply --cdp')
|
|
cdp='http://127.0.0.1:'+str(next(iter(ports)))
|
|
parsed=urllib.parse.urlsplit(cdp)
|
|
if (parsed.port or (443 if parsed.scheme=='https' else 80)) not in ports:
|
|
raise RuntimeError('Endpoint does not belong to a detected WhatsApp Desktop debugging port')
|
|
with urllib.request.urlopen(cdp+'/json/list',timeout=10) as response:targets=json.load(response)
|
|
pages=[t for t in targets if t.get('type')=='page' and urllib.parse.urlsplit(t.get('url','')).hostname=='web.whatsapp.com']
|
|
if len(pages)!=1:raise RuntimeError('Expected one WhatsApp Desktop page target')
|
|
url=pages[0]['webSocketDebuggerUrl']
|
|
if urllib.parse.urlsplit(url).hostname not in ('127.0.0.1','::1'):raise RuntimeError('Non-loopback WebSocket refused')
|
|
output=Path(output)
|
|
partial=output.with_suffix(output.suffix+'.partial')
|
|
report_path=output.with_suffix(output.suffix+'.report.json')
|
|
if output.exists() or partial.exists():raise RuntimeError('Output already exists; choose a new --output path')
|
|
output.parent.mkdir(parents=True,exist_ok=True)
|
|
report={'status':'running','rows':0,'row_errors':0,'snapshot_atomic':False,
|
|
'scope':'All encountered local message rows up to initial upper primary key; no phone sync',
|
|
'rea_evidence':'ev_8be2c4d68d5e268d7782a8098715202eaeda83625aa5b7f25277b09929f6995e'}
|
|
try:
|
|
with connect(url,max_size=16_000_000,open_timeout=10) as ws:
|
|
bounds=evaluate(ws,expression({'mode':'bounds'}),1)
|
|
report['initial_row_count']=bounds['count']
|
|
upper,after,ident=bounds['upper'],None,2
|
|
with partial.open('x',encoding='utf-8') as stream:
|
|
while upper is not None and after!=upper:
|
|
result=evaluate(ws,expression({'mode':'batch','upper':upper,'after':after,'batch':batch,'redact':redact}),ident)
|
|
ident+=1
|
|
if not result['keys']:break
|
|
if result['last']==after:raise RuntimeError('Cursor made no progress')
|
|
if len(result['messages'])!=len(result['keys']):raise RuntimeError('Incomplete batch response')
|
|
for message in result['messages']:
|
|
stream.write(json.dumps(message,ensure_ascii=False)+'\n')
|
|
report['rows']+=1
|
|
report['row_errors']+=int('error' in message)
|
|
stream.flush();after=result['last']
|
|
print(json.dumps({'exported_rows':report['rows'],'row_errors':report['row_errors']}),flush=True)
|
|
report['final_row_count']=evaluate(ws,expression({'mode':'bounds'}),ident)['count']
|
|
report['status']='completed_with_row_errors' if report['row_errors'] else 'completed_live_local_scan'
|
|
partial.rename(output)
|
|
report['output']=str(output.resolve())
|
|
except Exception:
|
|
report['status']='failed_partial_export'
|
|
raise
|
|
finally:
|
|
report_path.write_text(json.dumps(report,indent=2),encoding='utf-8')
|
|
return report
|
|
|
|
def main(argv=None):
|
|
p=argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('--cdp',type=endpoint)
|
|
p.add_argument('--batch-size',type=int,default=100)
|
|
p.add_argument('--redact-text',action='store_true')
|
|
p.add_argument('--output',type=Path,default=Path('whatsapp-local-history.jsonl'))
|
|
args=p.parse_args(argv)
|
|
if not 1<=args.batch_size<=500:p.error('--batch-size must be 1..500')
|
|
try:report=export(args.cdp,args.output,args.batch_size,args.redact_text)
|
|
except (RuntimeError,OSError) as error:print(str(error));return 2
|
|
print(json.dumps(report));return 0 if not report['row_errors'] else 1
|
|
|
|
if __name__=='__main__':raise SystemExit(main())
|