Prepare documented WhatsApp injector with portable Python bundle
This commit is contained in:
commit
d2b621bce7
49 files changed
+5737
No files matched your search
@@ -0,0 +1,156 @@
|
||||
"""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())
|
||||
Reference in new issue
Block a user