Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
195 changes: 138 additions & 57 deletions apps/desktop/src/app/DesktopConnectionCatalogStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,19 @@ const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([
"write-temporary-file",
"replace-catalog-file",
]);
type DesktopConnectionCatalogStoreWriteOperation =
typeof DesktopConnectionCatalogStoreWriteOperation.Type;

const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([
"read-legacy-registry",
"read-legacy-secret",
"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>()(
"DesktopConnectionCatalogStoreWriteError",
Expand All @@ -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>()(
"DesktopConnectionCatalogStoreDecodeError",
{
Expand Down Expand Up @@ -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>()(
"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,
Expand All @@ -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<void>;
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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<string>();
}
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<string>();
}
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);
});
Expand All @@ -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<string>();
}
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);
Expand Down
21 changes: 18 additions & 3 deletions apps/desktop/src/settings/DesktopSavedEnvironments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand All @@ -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 },
);
Expand Down
Loading
Loading