# Files changed · PR \#23476 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23476/diffs)

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

Visibility: public

[fix\(desktop\): recover connection catalogs after safeStorage key loss](https://git.cafe/pingdotgg/t3code/pull/23476.md)

Version: 9

Head commit: a4df9e92ee56fab1af6ef772a44454ed8c2450dd

Base commit: 5ffe04593166870d3ada2a83eb5fe55f0d192fc7

Comparison base: ee33c4c65dfde09564c2c9f9da4ffdf53828cb15

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/desktop/scripts/connection-catalog-native-fixture.cjs

Status: added; +139; −0

```diff
@@ -1,0 +1,139 @@
+// Run with Electron, an isolated T3CODE_HOME, and an existing development web server.
+// CATALOG_FIXTURE_MAIN points to dist-electron/main.cjs; CATALOG_FIXTURE_OUTPUT is
+// a disposable evidence directory. Run recovery then reload in the same OS keyring.
+const { app, safeStorage } = require("electron");
+const fs = require("node:fs");
+const path = require("node:path");
+const os = require("node:os");
+const { createHash } = require("node:crypto");
+const assert = require("node:assert/strict");
+
+const mode = process.env.CATALOG_FIXTURE_MODE || "recovery";
+const output = process.env.CATALOG_FIXTURE_OUTPUT;
+const home = process.env.T3CODE_HOME;
+assert(output && home && process.env.CATALOG_FIXTURE_MAIN);
+assert(path.resolve(home).startsWith("/tmp/"), "Use a disposable /tmp home");
+const catalogPath = path.join(home, "userdata", "connection-catalog.json");
+const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
+const fresh = JSON.stringify({
+  schemaVersion: 1,
+  targets: [],
+  profiles: [],
+  credentials: [],
+  remoteDpopTokens: [],
+  disabledEnvironmentIds: [],
+});
+fs.mkdirSync(output, { recursive: true });
+if (mode === "recovery") {
+  fs.mkdirSync(path.dirname(catalogPath), { recursive: true });
+  assert(!fs.existsSync(catalogPath), "Recovery requires a fresh fixture home");
+  const source = JSON.stringify(
+    {
+      version: 1,
+      encryptedCatalog: Buffer.concat([Buffer.from("v10"), Buffer.alloc(64, 81)]).toString(
+        "base64",
+      ),
+    },
+    null,
+    2,
+  );
+  fs.writeFileSync(catalogPath, source);
+  fs.writeFileSync(path.join(output, "source-fixture.json"), source);
+}
+let observed = false;
+app.on("browser-window-created", (_event, window) => {
+  if (observed) return;
+  observed = true;
+  window.webContents.once("did-finish-load", async () => {
+    const steps = [];
+    const record = (step, value) => {
+      steps.push({ step, value });
+      console.log("CATALOG_FIXTURE", JSON.stringify({ step, value }));
+    };
+    try {
+      record("runtime", {
+        electron: process.versions.electron,
+        platform: os.platform(),
+        available: safeStorage.isEncryptionAvailable(),
+        backend: safeStorage.getSelectedStorageBackend(),
+        mainSha256: hash(fs.readFileSync(process.env.CATALOG_FIXTURE_MAIN)),
+      });
+      assert(safeStorage.isEncryptionAvailable());
+      assert.notEqual(safeStorage.getSelectedStorageBackend(), "basic_text");
+      const get = () =>
+        window.webContents.executeJavaScript("window.desktopBridge.getConnectionCatalog()");
+      const backups = () =>
+        fs.readdirSync(path.dirname(catalogPath)).filter((name) => name.endsWith(".undecryptable"));
+      const original = fs.readFileSync(path.join(output, "source-fixture.json"));
+      const sourceDocument = JSON.parse(original);
+      const ciphertextHash = hash(Buffer.from(sourceDocument.encryptedCatalog, "base64"));
+      record("source", { fileSha256: hash(original), ciphertextSha256: ciphertextHash });
+      if (mode === "recovery") {
+        const first = await get();
+        record("firstGet", first);
+        assert.equal(first, null);
+        assert.deepEqual(fs.readFileSync(catalogPath), original);
+        record("activeSourceUnchangedBeforeSave", true);
+        const repeated = await get();
+        const concurrent = await window.webContents.executeJavaScript(
+          "Promise.all([window.desktopBridge.getConnectionCatalog(),window.desktopBridge.getConnectionCatalog()])",
+        );
+        assert.equal(repeated, null);
+        assert.deepEqual(concurrent, [null, null]);
+        assert.equal(backups().length, 1);
+        const backup = fs.readFileSync(path.join(path.dirname(catalogPath), backups()[0]));
+        fs.writeFileSync(path.join(output, "backup-fixture.json"), backup);
+        assert.equal(JSON.parse(backup).encryptedCatalog, sourceDocument.encryptedCatalog);
+        record("repeatAndConcurrentReads", {
+          results: [repeated, ...concurrent],
+          backups: backups().length,
+        });
+        record("preservation", {
+          sourceFileSha256: hash(original),
+          backupFileSha256: hash(backup),
+          rawBytesEqual: original.equals(backup),
+          ciphertextEqual: true,
+          ciphertextSha256: ciphertextHash,
+          backupName: backups()[0],
+        });
+        const saved = await window.webContents.executeJavaScript(
+          `window.desktopBridge.setConnectionCatalog(${JSON.stringify(fresh)})`,
+        );
+        record("save", saved);
+        assert.equal(saved, true);
+      }
+      const reloaded = await get();
+      assert.equal(reloaded, fresh);
+      const document = JSON.parse(fs.readFileSync(catalogPath));
+      assert.equal(
+        safeStorage.decryptString(Buffer.from(document.encryptedCatalog, "base64")),
+        fresh,
+      );
+      assert.equal(backups().length, 1);
+      const backup = fs.readFileSync(path.join(path.dirname(catalogPath), backups()[0]));
+      assert.equal(JSON.parse(backup).encryptedCatalog, sourceDocument.encryptedCatalog);
+      record("reload", {
+        freshProcess: mode === "reload",
+        matches: true,
+        safeStorageDecryptMatches: true,
+        encryptedEnvelope: document.version === 1 && typeof document.encryptedCatalog === "string",
+        backupCount: backups().length,
+        backupCiphertextUnchanged: true,
+        backupFileSha256: hash(backup),
+      });
+      fs.writeFileSync(
+        path.join(output, `${mode}-result.json`),
+        JSON.stringify({ passed: true, steps }, null, 2),
+      );
+      app.exit(0);
+    } catch (error) {
+      console.error(error);
+      fs.writeFileSync(
+        path.join(output, `${mode}-result.json`),
+        JSON.stringify({ passed: false, steps, error: String(error) }, null, 2),
+      );
+      app.exit(1);
+    }
+  });
+});
+require(process.env.CATALOG_FIXTURE_MAIN);
```

## apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts

Status: modified; +149; −27

```diff
@@ -2,12 +2,15 @@
 import { assert, describe, it } from "@effect/vitest";
 import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform";
 import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
+import * as Deferred from "effect/Deferred";
 import * as Effect from "effect/Effect";
 import * as Base64 from "effect/encoding/Base64";
+import * as Fiber from "effect/Fiber";
 import * as FileSystem from "effect/FileSystem";
 import * as Path from "effect/Path";
 import * as Layer from "effect/Layer";
 import * as Option from "effect/Option";
+import * as PlatformError from "effect/PlatformError";
 import * as Ref from "effect/Ref";
 import * as Schema from "effect/Schema";
 
@@ -27,22 +30,25 @@
     Schema.Struct({ version: Schema.Literal(1), records: Schema.Array(Schema.Unknown) }),
   ),
 );
+// `key` models Electron's os_crypt key: replacing it orphans everything encrypted before.
+function layerSafeStorageFor(available: boolean, key: Ref.Ref<string> | null = null) {
+  const prefix = Effect.gen(function* () {
+    return key === null ? "encrypted:" : `encrypted:${yield* Ref.get(key)}:`;
+  });
-function layerSafeStorageFor(available: boolean, failDecrypt: Ref.Ref<boolean> | null = null) {
   return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
     isEncryptionAvailable: Effect.succeed(available),
-    encryptString: (value) => Effect.succeed(textEncoder.encode(`encrypted:${value}`)),
+    encryptString: (value) =>
+      prefix.pipe(Effect.map((current) => textEncoder.encode(`${current}${value}`))),
     decryptString: (value) => {
       return Effect.gen(function* () {
         const decoded = textDecoder.decode(value);
-        if (
-          !decoded.startsWith("encrypted:") ||
+        const current = yield* prefix;
+        if (!decoded.startsWith(current)) {
-          (failDecrypt !== null && (yield* Ref.get(failDecrypt)))
-        ) {
           return yield* new ElectronSafeStorage.ElectronSafeStorageDecryptError({
             cause: new Error("invalid encrypted catalog"),
           });
         }
+        return decoded.slice(current.length);
-        return decoded.slice("encrypted:".length);
       });
     },
     selectedStorageBackend: Effect.succeedNone,
@@ -52,7 +58,7 @@
 function layerFor(
   baseDir: string,
   encryptionAvailable = true,
-  failDecrypt: Ref.Ref<boolean> | null = null,
+  key: Ref.Ref<string> | null = null,
   fileSystemLayer: Layer.Layer<FileSystem.FileSystem> = NodeServices.layer,
 ) {
   const layerEnvironment = DesktopEnvironment.layer({
@@ -70,7 +76,7 @@
       Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
     ),
   );
-  const layerSafeStorage = layerSafeStorageFor(encryptionAvailable, failDecrypt);
+  const layerSafeStorage = layerSafeStorageFor(encryptionAvailable, key);
   const layerDependencies = Layer.mergeAll(
     layerEnvironment,
     layerSafeStorage,
@@ -266,39 +272,155 @@
     ),
   );
 
-  it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () =>
+  it.effect("preserves a catalog encrypted with a lost key and starts a fresh one", () =>
     Effect.gen(function* () {
       const path = yield* Path.Path;
       const fileSystem = yield* FileSystem.FileSystem;
       const baseDir = yield* fileSystem.makeTempDirectoryScoped({
         prefix: "t3-desktop-connection-catalog-test-",
       });
+      const key = yield* Ref.make("original-key");
-      const failDecrypt = yield* Ref.make(false);
-      const layer = layerFor(baseDir, true, failDecrypt);
       const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe(
+        Effect.provide(layerFor(baseDir, true, key)),
-        Effect.provide(layer),
       );
+      const stateDir = path.join(baseDir, "userdata");
+      const catalogPath = path.join(stateDir, "connection-catalog.json");
+      assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}'));
+      const original = yield* fileSystem.readFileString(catalogPath);
+      yield* Ref.set(key, "regenerated-key");
 
+      const results = yield* Effect.all([store.get, store.get], { concurrency: "unbounded" });
+      assert.deepStrictEqual(results, [Option.none(), Option.none()]);
+      const findBackups = Effect.gen(function* () {
+        const backups: Array<string> = [];
+        for (const name of yield* fileSystem.readDirectory(stateDir)) {
+          const entryPath = path.join(stateDir, name);
+          if (
+            entryPath !== catalogPath &&
+            (yield* fileSystem.readFileString(entryPath)) === original
+          ) {
+            backups.push(entryPath);
+          }
+        }
+        return backups;
+      });
+      assert.equal((yield* findBackups).length, 1);
+
+      const fresh = '{"schemaVersion":1,"targets":[],"profiles":[]}';
+      assert.isTrue(yield* store.set(fresh));
+      assert.deepStrictEqual(yield* store.get, Option.some(fresh));
+      assert.equal((yield* findBackups).length, 1);
+    }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+  );
+
+  it.effect("preserves the undecryptable catalog even when a save replaces it mid-read", () =>
+    Effect.gen(function* () {
+      const path = yield* Path.Path;
+      const fileSystem = yield* FileSystem.FileSystem;
+      const baseDir = yield* fileSystem.makeTempDirectoryScoped({
+        prefix: "t3-desktop-connection-catalog-test-",
+      });
+      const key = yield* Ref.make("original-key");
+      const stateDir = path.join(baseDir, "userdata");
+      const catalogPath = path.join(stateDir, "connection-catalog.json");
+      const preserving = yield* Deferred.make<void>();
+      const saved = yield* Deferred.make<void>();
+      const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe(
+        Effect.provide(
+          layerFor(
+            baseDir,
+            true,
+            key,
+            Layer.succeed(FileSystem.FileSystem, {
+              ...fileSystem,
+              exists: (entryPath) =>
+                entryPath.endsWith(".undecryptable")
+                  ? Deferred.succeed(preserving, undefined).pipe(
+                      Effect.andThen(Deferred.await(saved)),
+                      Effect.andThen(fileSystem.exists(entryPath)),
+                    )
+                  : fileSystem.exists(entryPath),
+            }),
+          ),
+        ),
+      );
       assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}'));
+      const original = yield* fileSystem.readFileString(catalogPath);
+      yield* Ref.set(key, "regenerated-key");
+
+      const read = yield* Effect.forkChild(store.get);
+      yield* Deferred.await(preserving);
+      assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[],"profiles":[]}'));
-      yield* Ref.set(failDecrypt, true);
+      yield* Deferred.succeed(saved, undefined);
+      assert.deepStrictEqual(yield* Fiber.join(read), Option.none());
+
+      const backups = (yield* fileSystem.readDirectory(stateDir)).filter((name) =>
+        name.endsWith(".undecryptable"),
+      );
+      assert.equal(backups.length, 1);
+      assert.equal(
+        yield* fileSystem.readFileString(path.join(stateDir, backups[0] ?? "")),
+        original,
+      );
+    }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+  );
+
+  it.effect("keeps an undecryptable catalog in place when it cannot be preserved", () =>
+    Effect.gen(function* () {
+      const path = yield* Path.Path;
+      const fileSystem = yield* FileSystem.FileSystem;
+      const baseDir = yield* fileSystem.makeTempDirectoryScoped({
+        prefix: "t3-desktop-connection-catalog-test-",
+      });
+      const key = yield* Ref.make("original-key");
+      const writable = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe(
+        Effect.provide(layerFor(baseDir, true, key)),
+      );
+      const catalogPath = path.join(baseDir, "userdata", "connection-catalog.json");
+      assert.isTrue(yield* writable.set('{"schemaVersion":1,"targets":[]}'));
+      const original = yield* fileSystem.readFileString(catalogPath);
+      const writeFailure = (method: string) =>
+        Effect.fail(
+          PlatformError.systemError({
+            _tag: "PermissionDenied",
+            module: "FileSystem",
+            method,
+            pathOrDescriptor: catalogPath,
+          }),
+        );
+      const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe(
+        Effect.provide(
+          layerFor(
+            baseDir,
+            true,
+            key,
+            Layer.succeed(FileSystem.FileSystem, {
+              ...fileSystem,
+              copy: () => writeFailure("copy"),
+              copyFile: () => writeFailure("copyFile"),
+              link: () => writeFailure("link"),
+              remove: () => writeFailure("remove"),
+              rename: () => writeFailure("rename"),
+              writeFile: () => writeFailure("writeFile"),
+              writeFileString: () => writeFailure("writeFileString"),
+            }),
+          ),
+        ),
+      );
+      yield* Ref.set(key, "regenerated-key");
+
       const error = yield* store.get.pipe(Effect.flip);
       assert.instanceOf(
         error,
-        DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError,
+        DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreWriteError,
       );
-      assert.equal(error.operation, "decrypt-catalog");
-      assert.equal(error.catalogPath, path.join(baseDir, "userdata", "connection-catalog.json"));
-      assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageDecryptError);
-      const decryptError = error.cause as ElectronSafeStorage.ElectronSafeStorageDecryptError;
-      assert.instanceOf(decryptError.cause, Error);
-      assert.equal(decryptError.cause.message, "invalid encrypted catalog");
-      assert.equal(
+      assert.equal(error.operation, "preserve-undecryptable-catalog");
+      assert.isFalse(
-        error.message,
+        (yield* fileSystem.readDirectory(path.dirname(catalogPath))).some((name) =>
+          name.endsWith(".undecryptable"),
+        ),
-        `Desktop connection catalog protection failed during decrypt-catalog at ${path.join(baseDir, "userdata", "connection-catalog.json")}.`,
       );
+      assert.equal(yield* fileSystem.readFileString(catalogPath), original);
-      assert.notEqual(error.message, decryptError.message);
-      yield* Ref.set(failDecrypt, false);
-      assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}'));
     }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
   );
 });
```

## apps/desktop/src/app/DesktopConnectionCatalogStore.ts

Status: modified; +52; −15

```diff
@@ -1,3 +1,6 @@
+// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` names the backup of an undecryptable catalog by its content.
+import * as NodeCrypto from "node:crypto";
+
 import {
   BearerConnectionCredential,
   BearerConnectionProfile,
@@ -52,6 +55,7 @@
   "create-directory",
   "write-temporary-file",
   "replace-catalog-file",
+  "preserve-undecryptable-catalog",
 ]);
 
 const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([
@@ -64,7 +68,6 @@
 const DesktopConnectionCatalogStoreProtectionOperation = Schema.Literals([
   "check-encryption-availability",
   "encrypt-catalog",
-  "decrypt-catalog",
 ]);
 
 export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedError<DesktopConnectionCatalogStoreWriteError>()(
@@ -156,6 +159,7 @@
       | DesktopConnectionCatalogStoreDecodeError
       | DesktopConnectionCatalogStoreMigrationError
       | DesktopConnectionCatalogStoreProtectionError
+      | DesktopConnectionCatalogStoreWriteError
     >;
     readonly set: (
       catalog: string,
@@ -430,6 +434,43 @@
     });
   });
 
+  // Electron regenerates its key when the OS keyring entry is lost, orphaning the catalog for
+  // good. Read it as empty so the user can reconnect, but keep a copy first: the catalog stays
+  // in place until the next save replaces it, and the content-derived name keeps repeated and
+  // concurrent reads to one backup.
+  const preserveUndecryptableCatalog = Effect.fn(
+    "desktop.connectionCatalogStore.preserveUndecryptableCatalog",
+  )(function* (
+    document: EncryptedConnectionCatalogDocument,
+    encryptedCatalog: Uint8Array,
+    decryptError: ElectronSafeStorage.ElectronSafeStorageDecryptError,
+  ) {
+    const digest = NodeCrypto.createHash("sha256").update(encryptedCatalog).digest("hex");
+    const backupPath = `${catalogPath}.${digest}.undecryptable`;
+    const preserveError = (cause: unknown) =>
+      new DesktopConnectionCatalogStoreWriteError({
+        operation: "preserve-undecryptable-catalog",
+        path: backupPath,
+        cause,
+      });
+    if (!(yield* fileSystem.exists(backupPath).pipe(Effect.mapError(preserveError)))) {
+      const suffix = (yield* crypto.randomUUIDv4().pipe(Effect.mapError(preserveError))).replace(
+        /-/g,
+        "",
+      );
+      // Back up the document that failed to decrypt, not the file at `catalogPath`: a
+      // concurrent `set` may already have replaced it.
+      yield* writeDocument({ fileSystem, path, catalogPath: backupPath, document, suffix }).pipe(
+        Effect.mapError(preserveError),
+      );
+      yield* Effect.logWarning(
+        "The desktop connection catalog can no longer be decrypted; starting a new one.",
+        { catalogPath, backupPath, reason: decryptError._tag },
+      );
+    }
+    return Option.none<string>();
+  });
+
   const migrateLegacyCatalog = Effect.gen(function* () {
     if (!(yield* encryptionAvailable)) {
       return Option.none<string>();
@@ -480,21 +521,17 @@
       if (!(yield* encryptionAvailable)) {
         return Option.none<string>();
       }
+      const encryptedCatalog = yield* decodeSecretBytes(
+        catalogPath,
+        document.value.encryptedCatalog,
+      );
+      return yield* safeStorage.decryptString(encryptedCatalog).pipe(
+        Effect.asSome,
+        Effect.catchTags({
+          ElectronSafeStorageDecryptError: (decryptError) =>
-      const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe(
-        Effect.flatMap((encryptedCatalog) =>
-          safeStorage.decryptString(encryptedCatalog).pipe(
-            Effect.mapError(
-              (cause) =>
-                new DesktopConnectionCatalogStoreProtectionError({
-                  operation: "decrypt-catalog",
-                  catalogPath,
-                  cause,
-                }),
-            ),
-          ),
-        ),
+            preserveUndecryptableCatalog(document.value, encryptedCatalog, decryptError),
+        }),
       );
-      return Option.some(decrypted);
     }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")),
     set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) {
       if (!(yield* encryptionAvailable)) {
```

## knip.jsonc

Status: modified; +1; −0

```diff
@@ -60,6 +60,7 @@
       // Electron loads these bundles by filename rather than importing them.
       "entry": [
         "gnome-extension/extension.js!",
+        "scripts/connection-catalog-native-fixture.cjs!",
         "src/electron/WindowsForegroundFocusWorker.ts!",
         "src/snapShot/GlobalShiftShortcutWorker.ts!",
         "src/snapShot/RegionSnapShotWorker.ts!",
```
