Files
ghostwire/whatsapp_suite/history.py
T

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())