From 14640edea7d4aaf218e02b2f7a9028428528f754 Mon Sep 17 00:00:00 2001 From: Marcus Widing <245375637+widingmarcus-cyber@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:57:04 +0200 Subject: [PATCH] fix(desktop): recover undecryptable connection catalogs safely --- .../app/DesktopConnectionCatalogStore.test.ts | 83 +++++++++++++++---- .../src/app/DesktopConnectionCatalogStore.ts | 43 +++++++++- 2 files changed, 105 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 5cb7aafbbd20..26fb9fb4644e 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -405,7 +405,7 @@ describe("DesktopConnectionCatalogStore", () => { ), ); - it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () => + it.effect("preserves an undecryptable catalog and allows a fresh encrypted catalog", () => Effect.gen(function* () { const path = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; @@ -413,31 +413,78 @@ describe("DesktopConnectionCatalogStore", () => { prefix: "t3-desktop-connection-catalog-test-", }); const failDecrypt = yield* Ref.make(false); - const layer = makeLayer(baseDir, true, failDecrypt); const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( - Effect.provide(layer), + Effect.provide(makeLayer(baseDir, true, failDecrypt)), ); + const stateDir = path.join(baseDir, "userdata"); + const catalogPath = path.join(stateDir, "connection-catalog.json"); + const catalog = '{"schemaVersion":1,"targets":[]}'; + assert.isTrue(yield* store.set(catalog)); + const original = yield* fileSystem.readFileString(catalogPath); + yield* Ref.set(failDecrypt, true); + + const results = yield* Effect.all([store.get, store.get], { concurrency: "unbounded" }); + assert.deepStrictEqual(results, [Option.none(), Option.none()]); + const backups = (yield* fileSystem.readDirectory(stateDir)).filter((name) => + name.endsWith(".undecryptable"), + ); + assert.equal(backups.length, 1); + const backupPath = path.join(stateDir, backups[0]!); + assert.equal(yield* fileSystem.readFileString(backupPath), original); + assert.isFalse(yield* fileSystem.exists(catalogPath)); + + yield* Ref.set(failDecrypt, false); + assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[],"profiles":[]}')); + assert.deepStrictEqual( + yield* store.get, + Option.some('{"schemaVersion":1,"targets":[],"profiles":[]}'), + ); + assert.equal(yield* fileSystem.readFileString(backupPath), original); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); - assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}')); + it.effect("does not discard an undecryptable catalog when preserving it fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-connection-catalog-test-", + }); + const failDecrypt = yield* Ref.make(false); + const normalStore = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( + Effect.provide(makeLayer(baseDir, true, failDecrypt)), + ); + yield* normalStore.set("{}"); + const catalogPath = path.join(baseDir, "userdata", "connection-catalog.json"); + const original = yield* fileSystem.readFileString(catalogPath); + const permissionError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + pathOrDescriptor: catalogPath, + }); + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( + Effect.provide( + makeLayer( + baseDir, + true, + failDecrypt, + Layer.succeed(FileSystem.FileSystem, { + ...fileSystem, + rename: () => Effect.fail(permissionError), + }), + ), + ), + ); yield* Ref.set(failDecrypt, true); const error = yield* store.get.pipe(Effect.flip); assert.instanceOf( error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError, - ); - 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( - error.message, - `Desktop connection catalog protection failed during decrypt-catalog at ${path.join(baseDir, "userdata", "connection-catalog.json")}.`, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreWriteError, ); - assert.notEqual(error.message, decryptError.message); - yield* Ref.set(failDecrypt, false); - assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); + assert.equal(error.operation, "preserve-undecryptable-catalog"); + assert.strictEqual(error.cause, permissionError); + assert.equal(yield* fileSystem.readFileString(catalogPath), original); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); }); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 46b2546e681c..d6e69e46191e 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -21,6 +21,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts"; @@ -52,6 +53,7 @@ const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([ "create-directory", "write-temporary-file", "replace-catalog-file", + "preserve-undecryptable-catalog", ]); const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ @@ -151,6 +153,7 @@ export class DesktopConnectionCatalogStore extends Context.Service< { readonly get: Effect.Effect< Option.Option, + | DesktopConnectionCatalogStoreWriteError | DesktopConnectionCatalogStoreReadError | DesktopConnectionCatalogStoreDocumentDecodeError | DesktopConnectionCatalogStoreDecodeError @@ -385,6 +388,7 @@ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); + const mutex = yield* Semaphore.make(1); const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe( Effect.mapError( (cause) => @@ -495,14 +499,46 @@ export const make = Effect.gen(function* () { ), ); return Option.some(decrypted); - }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), - set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { + }).pipe( + Effect.catchTag( + "DesktopConnectionCatalogStoreProtectionError", + ( + error, + ): Effect.Effect< + Option.Option, + DesktopConnectionCatalogStoreProtectionError | DesktopConnectionCatalogStoreWriteError + > => + error.operation !== "decrypt-catalog" + ? Effect.fail(error) + : Effect.gen(function* () { + const backupPath = `${catalogPath}.${yield* crypto.randomUUIDv4}.undecryptable`; + yield* fileSystem.rename(catalogPath, backupPath); + yield* Effect.logWarning( + "Saved connections could not be decrypted. Preserved the catalog; reconnect saved remote environments.", + { catalogPath, backupPath }, + ); + return Option.none(); + }).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "preserve-undecryptable-catalog", + path: catalogPath, + cause, + }), + ), + ), + ), + mutex.withPermits(1), + Effect.withSpan("desktop.connectionCatalogStore.get"), + ), + set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog: string) { if (!(yield* encryptionAvailable)) { return false; } yield* writeCatalog(catalog); return true; - }), + }, mutex.withPermits(1)), clear: fileSystem.remove(catalogPath, { force: true }).pipe( Effect.catch((error) => Effect.logWarning("Could not clear the desktop connection catalog.", { @@ -510,6 +546,7 @@ export const make = Effect.gen(function* () { error, }), ), + mutex.withPermits(1), Effect.withSpan("desktop.connectionCatalogStore.clear"), ), });