# \[Bug\]: Orphaned safeStorage key makes connection-catalog.json permanently undecryptable, blocking all target listing on Windows · Issue \#8341 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/issues/8341)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

State: backlog

Priority: 0

## Description

[Discussion and activity](https://git.cafe/pingdotgg/t3code/issues/8341)

---

### Before submitting

- [x] I searched existing issues and did not find a duplicate.
- [x] 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)`:

```js
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`: `DesktopConnectionCatalogStoreProtectionError` → `ElectronSafeStorageDecryptError` → `Error 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 State` → `os_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

```shell
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".

