From 9de41e15527ea139e15893b891738dc479747182 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 01:59:56 -0700 Subject: [PATCH] Structure desktop persisted credential errors Co-authored-by: codex --- .../app/DesktopConnectionCatalogStore.test.ts | 16 +- .../src/app/DesktopConnectionCatalogStore.ts | 195 +++++++++++++----- .../settings/DesktopSavedEnvironments.test.ts | 21 +- .../src/settings/DesktopSavedEnvironments.ts | 152 +++++++++++--- 4 files changed, 291 insertions(+), 93 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index e0be7f39b39..7c7818994f6 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -393,7 +393,21 @@ describe("DesktopConnectionCatalogStore", () => { assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}')); yield* Ref.set(failDecrypt, true); const error = yield* store.get.pipe(Effect.flip); - assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageDecryptError); + assert.instanceOf( + error, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError, + ); + assert.equal(error.operation, "decrypt-catalog"); + assert.equal(error.catalogPath, `${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 ${baseDir}/userdata/connection-catalog.json.`, + ); + 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), diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 8467fe3f077..5ec2edb595f 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -53,8 +53,6 @@ const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([ "write-temporary-file", "replace-catalog-file", ]); -type DesktopConnectionCatalogStoreWriteOperation = - typeof DesktopConnectionCatalogStoreWriteOperation.Type; const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ "read-legacy-registry", @@ -62,8 +60,12 @@ const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ "encode-catalog", "persist-catalog", ]); -type DesktopConnectionCatalogStoreMigrationOperation = - typeof DesktopConnectionCatalogStoreMigrationOperation.Type; + +const DesktopConnectionCatalogStoreProtectionOperation = Schema.Literals([ + "check-encryption-availability", + "encrypt-catalog", + "decrypt-catalog", +]); export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreWriteError", @@ -78,17 +80,6 @@ export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorC } } -const writeError = ( - operation: DesktopConnectionCatalogStoreWriteOperation, - path: string, - cause: unknown, -): DesktopConnectionCatalogStoreWriteError => - new DesktopConnectionCatalogStoreWriteError({ - operation, - path, - cause, - }); - export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreDecodeError", { @@ -142,18 +133,18 @@ export class DesktopConnectionCatalogStoreMigrationError extends Schema.TaggedEr } } -const migrationError = ( - operation: DesktopConnectionCatalogStoreMigrationOperation, - catalogPath: string, - cause: unknown, - environmentId?: string, -): DesktopConnectionCatalogStoreMigrationError => - new DesktopConnectionCatalogStoreMigrationError({ - operation, - catalogPath, - ...(environmentId === undefined ? {} : { environmentId }), - cause, - }); +export class DesktopConnectionCatalogStoreProtectionError extends Schema.TaggedErrorClass()( + "DesktopConnectionCatalogStoreProtectionError", + { + operation: DesktopConnectionCatalogStoreProtectionOperation, + catalogPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop connection catalog protection failed during ${this.operation} at ${this.catalogPath}.`; + } +} export class DesktopConnectionCatalogStore extends Context.Service< DesktopConnectionCatalogStore, @@ -164,13 +155,13 @@ export class DesktopConnectionCatalogStore extends Context.Service< | DesktopConnectionCatalogStoreDocumentDecodeError | DesktopConnectionCatalogStoreDecodeError | DesktopConnectionCatalogStoreMigrationError - | ElectronSafeStorage.ElectronSafeStorageError + | DesktopConnectionCatalogStoreProtectionError >; readonly set: ( catalog: string, ) => Effect.Effect< boolean, - DesktopConnectionCatalogStoreWriteError | ElectronSafeStorage.ElectronSafeStorageError + DesktopConnectionCatalogStoreWriteError | DesktopConnectionCatalogStoreProtectionError >; readonly clear: Effect.Effect; } @@ -236,20 +227,46 @@ const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")( const directory = input.path.dirname(input.catalogPath); const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document).pipe( - Effect.mapError((cause) => writeError("encode-document", input.catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "encode-document", + path: input.catalogPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), ); - yield* input.fileSystem - .makeDirectory(directory, { recursive: true }) - .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); yield* Effect.gen(function* () { - yield* input.fileSystem - .writeFileString(tempPath, `${encoded}\n`) - .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); - yield* input.fileSystem - .rename(tempPath, input.catalogPath) - .pipe( - Effect.mapError((cause) => writeError("replace-catalog-file", input.catalogPath, cause)), - ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.catalogPath).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "replace-catalog-file", + path: input.catalogPath, + cause, + }), + ), + ); }).pipe( Effect.ensuring( input.fileSystem.remove(tempPath, { force: true }).pipe( @@ -330,13 +347,17 @@ const migrateSavedEnvironmentRecords = Effect.fn( wsBaseUrl: record.wsBaseUrl, }), ); - const token = yield* savedEnvironments - .getSecret(record.environmentId) - .pipe( - Effect.mapError((cause) => - migrationError("read-legacy-secret", catalogPath, cause, record.environmentId), - ), - ); + const token = yield* savedEnvironments.getSecret(record.environmentId).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "read-legacy-secret", + catalogPath, + environmentId: record.environmentId, + cause, + }), + ), + ); if (Option.isSome(token)) { credentials.push({ connectionId: id, @@ -362,13 +383,41 @@ 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 encryptionAvailable = safeStorage.isEncryptionAvailable.pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "check-encryption-availability", + catalogPath, + cause, + }), + ), + ); const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* ( catalog: string, ) { - const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog)); + const encryptedCatalog = Encoding.encodeBase64( + yield* safeStorage.encryptString(catalog).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "encrypt-catalog", + catalogPath, + cause, + }), + ), + ), + ); const suffix = (yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => writeError("create-temporary-file-name", catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "create-temporary-file-name", + path: catalogPath, + cause, + }), + ), )).replace(/-/g, ""); yield* writeDocument({ fileSystem, @@ -380,21 +429,42 @@ export const make = Effect.gen(function* () { }); const migrateLegacyCatalog = Effect.gen(function* () { - if (!(yield* safeStorage.isEncryptionAvailable)) { + if (!(yield* encryptionAvailable)) { return Option.none(); } const records = yield* savedEnvironments.getRegistry.pipe( - Effect.mapError((cause) => migrationError("read-legacy-registry", catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "read-legacy-registry", + catalogPath, + cause, + }), + ), ); if (records.length === 0) { return Option.none(); } const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments, catalogPath); const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog).pipe( - Effect.mapError((cause) => migrationError("encode-catalog", catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "encode-catalog", + catalogPath, + cause, + }), + ), ); yield* writeCatalog(encoded).pipe( - Effect.mapError((cause) => migrationError("persist-catalog", catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "persist-catalog", + catalogPath, + cause, + }), + ), ); return Option.some(encoded); }); @@ -405,16 +475,27 @@ export const make = Effect.gen(function* () { if (Option.isNone(document)) { return yield* migrateLegacyCatalog; } - if (!(yield* safeStorage.isEncryptionAvailable)) { + if (!(yield* encryptionAvailable)) { return Option.none(); } const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe( - Effect.flatMap(safeStorage.decryptString), + Effect.flatMap((encryptedCatalog) => + safeStorage.decryptString(encryptedCatalog).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "decrypt-catalog", + catalogPath, + cause, + }), + ), + ), + ), ); return Option.some(decrypted); }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { - if (!(yield* safeStorage.isEncryptionAvailable)) { + if (!(yield* encryptionAvailable)) { return false; } yield* writeCatalog(catalog); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index abd25a39f5b..ec70308b3d3 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -271,10 +271,11 @@ describe("DesktopSavedEnvironments", () => { ), ); - it.effect("surfaces typed safe storage availability failures", () => { + it.effect("adds saved-environment context to safe storage availability failures", () => { const cause = new Error("safe storage unavailable"); return withSavedEnvironments( Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; yield* savedEnvironments.setRegistry([savedRegistryRecord]); @@ -285,8 +286,22 @@ describe("DesktopSavedEnvironments", () => { }) .pipe(Effect.flip); - assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); - assert.equal(error.cause, cause); + assert.instanceOf( + error, + DesktopSavedEnvironments.DesktopSavedEnvironmentSecretProtectionError, + ); + assert.equal(error.operation, "check-encryption-availability"); + assert.equal(error.environmentId, savedRegistryRecord.environmentId); + assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); + assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); + const availabilityError = + error.cause as ElectronSafeStorage.ElectronSafeStorageAvailabilityError; + assert.strictEqual(availabilityError.cause, cause); + assert.equal( + error.message, + `Desktop saved-environment secret protection failed during check-encryption-availability for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`, + ); + assert.notEqual(error.message, availabilityError.message); }), { availabilityError: cause }, ); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 64c40d39f0e..bdda7f9c738 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -77,7 +77,12 @@ const DesktopSavedEnvironmentsWriteOperation = Schema.Literals([ "write-temporary-file", "replace-registry-file", ]); -type DesktopSavedEnvironmentsWriteOperation = typeof DesktopSavedEnvironmentsWriteOperation.Type; + +const DesktopSavedEnvironmentSecretProtectionOperation = Schema.Literals([ + "check-encryption-availability", + "encrypt-secret", + "decrypt-secret", +]); export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsWriteError", @@ -92,17 +97,6 @@ export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedErrorClass< } } -const writeError = ( - operation: DesktopSavedEnvironmentsWriteOperation, - path: string, - cause: unknown, -): DesktopSavedEnvironmentsWriteError => - new DesktopSavedEnvironmentsWriteError({ - operation, - path, - cause, - }); - export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsReadError", { @@ -141,6 +135,20 @@ export class DesktopSavedEnvironmentSecretDecodeError extends Schema.TaggedError } } +export class DesktopSavedEnvironmentSecretProtectionError extends Schema.TaggedErrorClass()( + "DesktopSavedEnvironmentSecretProtectionError", + { + operation: DesktopSavedEnvironmentSecretProtectionOperation, + environmentId: Schema.String, + registryPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop saved-environment secret protection failed during ${this.operation} for environment ${this.environmentId} at ${this.registryPath}.`; + } +} + export type DesktopSavedEnvironmentsReadRegistryError = | DesktopSavedEnvironmentsReadError | DesktopSavedEnvironmentsDocumentDecodeError; @@ -152,11 +160,11 @@ export type DesktopSavedEnvironmentsMutationError = export type DesktopSavedEnvironmentsGetSecretError = | DesktopSavedEnvironmentsReadRegistryError | DesktopSavedEnvironmentSecretDecodeError - | ElectronSafeStorage.ElectronSafeStorageError; + | DesktopSavedEnvironmentSecretProtectionError; export type DesktopSavedEnvironmentsSetSecretError = | DesktopSavedEnvironmentsMutationError - | ElectronSafeStorage.ElectronSafeStorageError; + | DesktopSavedEnvironmentSecretProtectionError; export class DesktopSavedEnvironments extends Context.Service< DesktopSavedEnvironments, @@ -276,19 +284,45 @@ const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistry const directory = input.path.dirname(input.registryPath); const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document).pipe( - Effect.mapError((cause) => writeError("encode-registry", input.registryPath, cause)), + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "encode-registry", + path: input.registryPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), + ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.registryPath).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "replace-registry-file", + path: input.registryPath, + cause, + }), + ), ); - yield* input.fileSystem - .makeDirectory(directory, { recursive: true }) - .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); - yield* input.fileSystem - .writeFileString(tempPath, `${encoded}\n`) - .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); - yield* input.fileSystem - .rename(tempPath, input.registryPath) - .pipe( - Effect.mapError((cause) => writeError("replace-registry-file", input.registryPath, cause)), - ); }, ); @@ -341,8 +375,13 @@ export const make = Effect.gen(function* () { const writeDocument = (document: SavedEnvironmentRegistryDocument) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.mapError((cause) => - writeError("create-temporary-file-name", environment.savedEnvironmentRegistryPath, cause), + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "create-temporary-file-name", + path: environment.savedEnvironmentRegistryPath, + cause, + }), ), Effect.flatMap((suffix) => writeRegistryDocument({ @@ -396,7 +435,21 @@ export const make = Effect.gen(function* () { document.records.find((record) => record.environmentId === environmentId) ?.encryptedBearerToken, ); - if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) { + if (Option.isNone(encoded)) { + return Option.none(); + } + const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "check-encryption-availability", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ); + if (!encryptionAvailable) { return Option.none(); } @@ -405,7 +458,19 @@ export const make = Effect.gen(function* () { environment.savedEnvironmentRegistryPath, encoded.value, ); - return Option.some(yield* safeStorage.decryptString(secretBytes)); + return Option.some( + yield* safeStorage.decryptString(secretBytes).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "decrypt-secret", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ), + ); }), setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) { const { environmentId, secret } = input; @@ -415,11 +480,34 @@ export const make = Effect.gen(function* () { environment.savedEnvironmentRegistryPath, ); - if (!(yield* safeStorage.isEncryptionAvailable)) { + const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "check-encryption-availability", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ); + if (!encryptionAvailable) { return false; } - const encryptedBearerToken = Encoding.encodeBase64(yield* safeStorage.encryptString(secret)); + const encryptedBearerToken = Encoding.encodeBase64( + yield* safeStorage.encryptString(secret).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "encrypt-secret", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ), + ); let found = false; const nextDocument: SavedEnvironmentRegistryDocument = { version: document.version,