Open feature request

AD-core verb: elevated SYSTEM helper to decrypt browser credentials (desktop_decrypt_browser_credentials) — unblocks pup comprehensive credential import

John Lauer · 6d ago

What pup needs from AD core: an elevated SYSTEM helper to decrypt browser credentials

pup is building a comprehensive credential import (spec: dev-skills/pup-browser-import in the pup bridge repo) to seed a shared, identity-tagged "adom-you" vault from the user's real logins across ALL their Chromium profiles. The blocker is Windows App-Bound Encryption (ABE, Chrome 127+): saved passwords/cookies are v20 blobs whose master key is double-DPAPI-wrapped under the SYSTEM account. A normal-user process (the container, the pup bridge, non-elevated code) canNOT unwrap it. Only an admin→SYSTEM helper can, exactly what Codex Desktop's "Import from Chrome" does behind its "Administrator approval required" prompt.

This half is AD-core (the container can't elevate or touch Windows DPAPI). Requesting a verb, proposed name desktop_decrypt_browser_credentials.

Contract

Input: { browsers?: ["chrome","edge","brave"], profiles?: [{browser, profile}], include?: ["passwords","cookies","autofill"] } (default: all browsers, all profiles, passwords).

Behavior:

  1. UAC-elevate once (honest consent prompt, like Codex). Return errorCode:"elevation_declined" if the user cancels.
  2. Get a SYSTEM token. For EACH browser: read User Data/Local State, strip APPB from os_crypt.app_bound_encrypted_key, SYSTEM-DPAPI-unwrap the outer layer, then user-DPAPI-unwrap → the AES-256-GCM master key (one key per browser, shared across its profiles).
  3. For EACH profile: copy Login Data (it's locked while the browser runs), decrypt each v20 password_value (3-byte version prefix, 12-byte GCM nonce, ciphertext, 16-byte tag) with that browser's key.
  4. Return rows: { origin, username, password, sourceBrowser, sourceProfile, sourceAccount (from Preferences account_info[].email), lastUsed }. Do NOT write plaintext to disk; return over the local API only. Cookies best-effort (managed-profile device policy often blocks them, surface per-item, don't fail the run).

Why AD and not pup

The pup bridge runs in the cloud container over CDP; it has zero access to the Windows user's DPAPI or an elevation path. AD is the only component on the machine that can prompt UAC and run as SYSTEM. pup owns everything after the rows come back: the identity-tagged merge, the vault, and the UX.

Precedent / safety

Same operation Codex Desktop ships openly with an admin-approval prompt. It's the user's own data, own machine, own explicit elevation, same as any password-manager import. Never a path that reads another user's profile or acts without the live consenting user.

1 Reply

John Lauer · 5d ago

Resolution plus a definitive finding after building this end to end (v1.9.220 through 1.9.227).

What shipped and works

The full pipeline is built and verified live on a real machine:

  • desktop_decrypt_browser_credentials (AD-core verb), gated exactly as specced: relay/cloud-AI callers refused unconditionally (errorCode relay_forbidden, no UAC); local/bridge callers reach it.
  • A separate signed, elevated helper ad-credhelper.exe (its own crate) does all the Win32 crypto: SeDebugPrivilege plus winlogon SYSTEM-token duplication, the three-layer App-Bound key unwrap (SYSTEM-DPAPI outer, user-DPAPI inner, elevation-service AEAD), per-row v20/v10/v11 decrypt, profile and source-account enumeration. Rows return over an in-memory named pipe. Plaintext never touches disk and is carved out of every log seam.
  • Two human gates, in order: an in-AD consent dialog naming the caller, plus a paired Windows toast (Continue/Cancel), the two synced so acting on either dismisses both; then the UAC on the signed helper.
  • The direct API listen address is pinned and validated to loopback since plaintext now flows over it.

Two real bugs were found and fixed along the way:

  1. Pipe handoff. AD creates the return pipe PIPE_ACCESS_INBOUND (helper writes, AD reads), but the helper opened it read plus write, which an inbound pipe denies the client, so every decrypt hung until timeout with the rows already decrypted in memory. Fixed: the client opens write-only.
  2. CoInitializeSecurity was missing, so the elevation service could not impersonate us for the user-context DPAPI unwrap. Added it (PKT_PRIVACY, IMPERSONATE, DYNAMIC_CLOAKING).

The definitive finding: the rotation-proof path is walled by design

The plan flagged that the hardcoded elevation-service AEAD keys go stale when Google rotates them per Chrome release. That is real: the test machine's Chrome 150 uses an era-0x03 ChaCha key that the shipped constant no longer matches, so all 979 saved passwords fail the master-key unwrap.

The rotation-proof answer is to let Chrome's own elevation service unwrap the key via IElevator::DecryptData. I built it fully, and it does NOT work for a standalone helper, for a reason worth recording so nobody re-explores it:

  • CoCreateInstance first failed TYPE_E_CANTLOADLIBRARY because Chrome's registered IElevator typelib path goes stale after a browser update (it kept pointing at the deleted 143.x version dir while Chrome was on 150.x). Fixed by locating the current elevation_service.exe and re-registering the typelib there.
  • With CoInitializeSecurity added, both the SYSTEM- and USER-context DPAPI unwraps inside the service then succeed.
  • DecryptData still dies at the FINAL step with hr 0x8004A007. Per Chrome source (elevation_service/elevator.cc DecryptData plus caller_validation.cc ValidateData to ValidatePath), after the DPAPI unwraps the service hashes the CALLING PROCESS exe path and compares it to the path stored at encrypt time (chrome.exe). ad-credhelper.exe is not chrome.exe, so it is refused. This is path-based binding of the key to the browser executable, the deliberate anti-infostealer defense. The only way past is to BE chrome.exe (inject into it), which is the infostealer technique and would AV-flag a signed first-party tool.

So the COM path is genuinely walled and is gated OFF; the standalone hardcoded-key unwrap stays the shippable path (works on matching key eras, best-effort on rotated keys with a decrypt-fail count in stats).

The clean architecture for the actual goal

For "pup signed in AS the user," the approach that does not fight Chrome is to let Chrome itself do the decryption:

  • Cookies (the signed-in part): drive the user's real Chrome over CDP; Network.getAllCookies returns them already decrypted (chrome.exe passes its own path validation), and pup seeds the adom-you profile with those. No ABE fight, no elevation, no UAC, rotation-proof.
  • Passwords are the harder remainder (not exposed via simple CDP), and are often unnecessary once the cookie session is seeded because the user is already logged in.

Net: the AD-core half requested here is built and shipped. The credential-decrypt verb is viable on the Chrome versions where the hardcoded key matches. For the rotation-proof, always-works path to a signed-in pup profile, the recommendation is CDP cookie seeding on the pup side rather than ABE decryption. Happy to write that up as a pup task.

Shipped in v1.9.220 through 1.9.227; production behavior is the hardcoded-key path (COM gated off). Full evidence in those commits.

Log in to reply.