pingdotgg / pingdotgg/t3code

[Bug]: Orphaned safeStorage key makes connection-catalog.json permanently undecryptable, blocking all target listing on Windows

Open
#8,341 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
23k
Forks
5.9k
Avg merge
11h 14m
Merged PRs (30d)
357

Description

Before submitting
  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.
Area

apps/desktop

Steps to reproduce

The trigger is an Electron userData directory that gets recreated while ~/.t3/userdata survives. On Windows that happens on reinstall / profile reset / roaming-dir cleanup.

  1. Use the desktop app so at least one connection is saved. connection-catalog.json is written to %USERPROFILE%\.t3\userdata\, encrypted via safeStorage.
  2. Delete or let something recreate %APPDATA%\t3code\ (the Electron userData dir), leaving %USERPROFILE%\.t3\userdata\ intact.
  3. Launch the app. Electron generates a fresh random os_crypt.encrypted_key in the new Local State.
  4. Open Settings → Connections, or try to add/select an environment.
Expected behavior

An undecryptable catalog should degrade gracefully — treat it as "no saved connections", log a warning, and let the next write re-key the file with the current safeStorage key. Losing saved connection entries is acceptable; being unable to reach the connections UI at all is not.

Actual behavior

The connections UI is dead and no environment can be added or listed:

Could not list targets: ConnectionTransientError: Could not load the local connection catalog:
Error: Error invoking remote method 'desktop:get-connection-catalog':
DesktopConnectionCatalogStoreProtectionError: Desktop connection catalog protection failed
during decrypt-catalog at C:\Users\<user>\.t3\userdata\connection-catalog.json.

Labelled ConnectionTransientError, but it is permanent — every launch fails identically until the file is deleted by hand.

In app.asar, connectionCatalogStore.get maps any safeStorage.decryptString failure straight into a fatal DesktopConnectionCatalogStoreProtectionError, which propagates out of desktop.bootstrap. There is no fallback branch, unlike the success path that returns Option.some(decrypted):

safeStorage.decryptString(encryptedCatalog).pipe(
  Effect.mapError(
    (cause) =>
      new DesktopConnectionCatalogStoreProtectionError({
        operation: "decrypt-catalog",
        catalogPath,
        cause,
      }),
  ),
)
Root cause (verified on my machine)

The ciphertext was encrypted with an AES key that no longer exists. DPAPI itself is healthy — this is not a Windows credential problem.

  • Inner cause chain from desktop.trace.ndjson: DesktopConnectionCatalogStoreProtectionErrorElectronSafeStorageDecryptErrorError while decrypting the ciphertext provided to safeStorage.decryptString.
  • encryptedCatalog base64-decodes to a v10-prefixed blob (622 bytes: v10 + 12-byte nonce + 607 bytes ciphertext+tag), i.e. Chromium AES-256-GCM. The key lives in %APPDATA%\t3code\Local Stateos_crypt.encrypted_key, DPAPI-wrapped.
  • I unwrapped that key manually with ProtectedData.Unprotect(..., CurrentUser): prefix=DPAPI blobLen=283, unprotect OK, 32-byte key. So DPAPI works and the current key is intact — it is simply a different key than the one used at encrypt time.
  • Timestamps confirm it. connection-catalog.json was written 2026-07-11 15:22. The entire %APPDATA%\t3code profile — Local State, .updaterId, Cache, GPUCache, Code Cache, Local Storage — is stamped 2026-08-08 00:17. The userData dir was recreated a month after the catalog was encrypted, generating a new random key and orphaning the file.

The underlying design issue: the catalog lives in ~/.t3/userdata but its key lives in %APPDATA%\t3code. Those two directories have independent lifecycles, so wiping either one alone leaves the app in a state it cannot recover from.

Suggested fixes
  1. Make get recover: on decrypt-catalog failure, fall back to an empty catalog, surface a non-fatal warning ("saved connections could not be decrypted and were reset"), and re-key on next write. Cheapest fix and it turns a hard block into a soft reset.
  2. Optionally move the catalog into the Electron userData dir alongside Local State, or add a plaintext key-fingerprint field so a key mismatch is detectable and self-healing rather than an exception.
  3. Reclassify — this surfaces as ConnectionTransientError, which is misleading for a permanently unrecoverable state.
Impact

Blocks work completely

Version or commit

0.0.34 (Alpha)

Environment

Windows 11 Pro build 26200 (10.0.26200), WSL2 backend (Ubuntu 24.04.4 LTS, kernel 6.18.33.2-microsoft-standard-WSL2)

Logs or stack traces
DesktopConnectionCatalogStoreProtectionError: Desktop connection catalog protection failed during decrypt-catalog at C:\Users\<user>\.t3\userdata\connection-catalog.json.
    at resources\app.asar\apps\desktop\dist-electron\main.cjs:15880:225
    at desktop.connectionCatalogStore.get (main.cjs:15886:25)
    at desktop.ipc.connectionCatalog.get (definition) (main.cjs:15905:25)
    at desktop.ipc.method (main.cjs:720:122)
    at desktop.ipc.invoke (main.cjs:669:68)
    at desktop.ipc.registerInvoke (main.cjs:27741:13)
    at desktop.ipc.installHandlers (main.cjs:28717:9)
    at desktop.bootstrap (main.cjs:28725:23) {
  [cause]: ElectronSafeStorageDecryptError: Electron safe storage failed to decrypt a string.
      at catch (main.cjs:1349:22) {
    [cause]: Error: Error while decrypting the ciphertext provided to safeStorage.decryptString.
        at try (main.cjs:1348:36)
  }
}
Workaround

Quit the app, delete (or rename) %USERPROFILE%\.t3\userdata\connection-catalog.json, relaunch, and re-add the connection. Saved connection entries are lost; clerk-tokens.json, secrets/, and state.sqlite are unaffected.

Related
  • #4750 — connection-catalog.json non-atomic writes; a corrupt document bricks the app permanently. Same "unreadable catalog is fatal" failure mode, different cause (corruption vs. orphaned key). A recovery path in get would cover both.
  • #4991 — identical user-facing Could not list targets message, but from DesktopConnectionCatalogStoreMigrationError / read-legacy-secret rather than decrypt-catalog.
  • #5427 — safeStorage unavailable on Linux breaking the connection catalog; adjacent, but that is "encryption unavailable" rather than "key rotated".

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by locating the desktop connectionCatalogStore.get implementation corresponding to the decrypt-catalog path described in the issue, then trace how its error reaches desktop.bootstrap and the connections UI. Reproduce the orphaned-key case on Windows or cover the decrypt failure path with the existing catalog tests if available. Done means an undecryptable catalog no longer blocks the connections UI, a warning is surfaced, and a later write can recover the catalog.

Written by the indexing model from the issue text.

Assessment

Tech stack
electron, typescript
Domain
desktop, security
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.