@vrtmrz/livesync-commonlib 0.1.36 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -71,7 +71,7 @@ const SettingInformation = {
71
71
  },
72
72
  configPassphrase: {
73
73
  name: "Passphrase of sensitive configuration items",
74
- desc: "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again."
74
+ desc: "SettingService.ConfigurationPassphraseDeviceLocalDescription"
75
75
  },
76
76
  configPassphraseStore: {
77
77
  name: "Encrypting sensitive configuration items"
@@ -5,6 +5,13 @@
5
5
  * these messages so that symbolic keys are never exposed to users.
6
6
  */
7
7
  export declare const commonlibEnglishMessages: {
8
+ readonly "SettingService.ConfigurationPassphraseDeviceLocalDescription": "This passphrase stays on this device. When another device needs to decrypt protected settings without a saved key, enter the same passphrase at start-up.";
9
+ readonly "SettingService.ConfigurationPassphraseTitle": "Configuration passphrase";
10
+ readonly "SettingService.ConfigurationPassphraseRetryTitle": "Configuration could not be decrypted. Enter the configuration passphrase again.";
11
+ readonly "SettingService.ConfigurationPassphraseCancelled": "Configuration passphrase entry was cancelled or unavailable. Start-up was stopped; saved settings were retained.";
12
+ readonly "SettingService.ConfigurationEncryptionSaveFailed": "Configuration encryption could not be saved. Verify the saved settings and device key before retrying.";
13
+ readonly "SettingService.ConfigurationPassphraseRollbackFailed": "The configuration passphrase could not be restored after a write failure. Verify the saved settings and device key before retrying.";
14
+ readonly "SettingService.ConfigurationCouldNotBeDecrypted": "Existing configuration could not be decrypted. The configuration key was not changed.";
8
15
  readonly "(BETA) Always overwrite with a newer file": "(BETA) Always overwrite with a newer file";
9
16
  readonly "(Beta) Use ignore files": "(Beta) Use ignore files";
10
17
  readonly "(Days passed, 0 to disable automatic-deletion)": "(Days passed, 0 to disable automatic-deletion)";
@@ -1,4 +1,11 @@
1
1
  const commonlibEnglishMessages = {
2
+ "SettingService.ConfigurationPassphraseDeviceLocalDescription": "This passphrase stays on this device. When another device needs to decrypt protected settings without a saved key, enter the same passphrase at start-up.",
3
+ "SettingService.ConfigurationPassphraseTitle": "Configuration passphrase",
4
+ "SettingService.ConfigurationPassphraseRetryTitle": "Configuration could not be decrypted. Enter the configuration passphrase again.",
5
+ "SettingService.ConfigurationPassphraseCancelled": "Configuration passphrase entry was cancelled or unavailable. Start-up was stopped; saved settings were retained.",
6
+ "SettingService.ConfigurationEncryptionSaveFailed": "Configuration encryption could not be saved. Verify the saved settings and device key before retrying.",
7
+ "SettingService.ConfigurationPassphraseRollbackFailed": "The configuration passphrase could not be restored after a write failure. Verify the saved settings and device key before retrying.",
8
+ "SettingService.ConfigurationCouldNotBeDecrypted": "Existing configuration could not be decrypted. The configuration key was not changed.",
2
9
  "(BETA) Always overwrite with a newer file": "(BETA) Always overwrite with a newer file",
3
10
  "(Beta) Use ignore files": "(Beta) Use ignore files",
4
11
  "(Days passed, 0 to disable automatic-deletion)": "(Days passed, 0 to disable automatic-deletion)",
@@ -1,6 +1,6 @@
1
1
  import type { FetchHttpHandler } from "@smithy/fetch-http-handler";
2
2
  import { type LOG_LEVEL } from "octagonal-wheels/common/logger";
3
- import type { AnyEntry, AUTO_MERGED, CouchDBCredentials, diff_result, DocumentID, EntryDoc, EntryHasPath, FileEventItem, FilePath, FilePathWithPrefix, LoadedEntry, MetaEntry, MISSING_OR_ERROR, ObsidianLiveSyncSettings, RemoteDBSettings, RemotePreferredTweakResult, SettingsMigrationState, TweakAssessment, TweakValues, UXFileInfo, UXFileInfoStub } from "../../common/types.js";
3
+ import type { AnyEntry, AUTO_MERGED, CouchDBCredentials, ConfigPassphraseStore, diff_result, DocumentID, EntryDoc, EntryHasPath, FileEventItem, FilePath, FilePathWithPrefix, LoadedEntry, MetaEntry, MISSING_OR_ERROR, ObsidianLiveSyncSettings, RemoteDBSettings, RemotePreferredTweakResult, SettingsMigrationState, TweakAssessment, TweakValues, UXFileInfo, UXFileInfoStub } from "../../common/types.js";
4
4
  import type { LiveSyncLocalDB } from "../../pouchdb/LiveSyncLocalDB.js";
5
5
  import type { ReplicatorInstance } from "../../replication/ReplicatorInstance.js";
6
6
  import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
@@ -323,6 +323,8 @@ export interface ISettingService {
323
323
  deleteDeviceLocalConfig(key: string): void;
324
324
  onBeforeSaveSettingData(nextSettings: ObsidianLiveSyncSettings, previousSettings: ObsidianLiveSyncSettings): Promise<(Partial<ObsidianLiveSyncSettings> | void)[]>;
325
325
  saveSettingData(): Promise<void>;
326
+ /** Save a new configuration wrapper and its device-local key, retaining the protected values. */
327
+ changeConfigurationEncryption(store: ConfigPassphraseStore, passphrase?: string): Promise<void>;
326
328
  currentSettings(): ObsidianLiveSyncSettings;
327
329
  updateSettings(updateFn: (current: ObsidianLiveSyncSettings) => ObsidianLiveSyncSettings, saveImmediately?: boolean): Promise<void>;
328
330
  applyExternalSettings(partial: Partial<ObsidianLiveSyncSettings>, saveImmediately?: boolean): Promise<void>;
@@ -1,4 +1,4 @@
1
- import { type ObsidianLiveSyncSettings, type SettingsMigrationState } from "../../common/types.js";
1
+ import { type ConfigPassphraseStore, type ObsidianLiveSyncSettings, type SettingsMigrationState } from "../../common/types.js";
2
2
  import type { IAPIService, ISettingService } from "./IService.js";
3
3
  import { ServiceBase, type ServiceContext } from "./ServiceBase.js";
4
4
  import { createInstanceLogFunction } from "../lib/logUtils.js";
@@ -22,6 +22,7 @@ export declare abstract class SettingService<T extends ServiceContext = ServiceC
22
22
  protected abstract loadData(): Promise<ObsidianLiveSyncSettings | undefined>;
23
23
  private _lastPersistedSettings?;
24
24
  private _settingsMigrationState?;
25
+ private saveQueue;
25
26
  getSettingsMigrationState(): SettingsMigrationState | undefined;
26
27
  _log: ReturnType<typeof createInstanceLogFunction>;
27
28
  constructor(context: T, dependencies: SettingServiceDependencies);
@@ -63,6 +64,14 @@ export declare abstract class SettingService<T extends ServiceContext = ServiceC
63
64
  * Save the current settings to storage.
64
65
  */
65
66
  saveSettingData(): Promise<void>;
67
+ private serialiseSave;
68
+ /** Change the configuration wrapper without changing the protected plaintext values. */
69
+ changeConfigurationEncryption(store: ConfigPassphraseStore, passphrase?: string): Promise<void>;
70
+ private restoreConfigurationPassphrase;
71
+ private prepareConfigurationKeyChange;
72
+ private prepareSettingSave;
73
+ private writePreparedSettings;
74
+ private commitPreparedSettings;
66
75
  private encryptRemoteConfigurationUris;
67
76
  private hasManagedP2PProfileURI;
68
77
  private decryptRemoteConfigurationUris;
@@ -104,13 +113,15 @@ export declare abstract class SettingService<T extends ServiceContext = ServiceC
104
113
  applyExternalSettings(partial: Partial<ObsidianLiveSyncSettings>, saveImmediately?: boolean): Promise<void>;
105
114
  applyPartial(partial: Partial<ObsidianLiveSyncSettings>, saveImmediately?: boolean): Promise<void>;
106
115
  getPassphrase(settings: ObsidianLiveSyncSettings): Promise<string | false>;
107
- private usedPassphrase;
116
+ private usedPassphrase?;
117
+ private getSavingPassphrase;
118
+ private rememberPassphrase;
108
119
  /**
109
120
  * Clear any used passphrase from memory.
110
121
  */
111
122
  clearUsedPassphrase(): void;
112
123
  decryptConfigurationItem(encrypted: string, passphrase: string): Promise<string | false>;
113
- encryptConfigurationItem(src: string, settings: ObsidianLiveSyncSettings): Promise<string>;
124
+ encryptConfigurationItem(src: string, settings: ObsidianLiveSyncSettings, passphrase?: string): Promise<string>;
114
125
  private encryptPlainConfigurationItem;
115
126
  private encryptConfigurationItemWith;
116
127
  /**
@@ -118,6 +129,11 @@ export declare abstract class SettingService<T extends ServiceContext = ServiceC
118
129
  * @param settings The settings to decrypt.
119
130
  */
120
131
  decryptSettings(settings: ObsidianLiveSyncSettings): Promise<ObsidianLiveSyncSettings>;
132
+ private decryptSettingsWithPassphrase;
133
+ private hasProtectedConfiguration;
134
+ private validateStoredIdConfiguration;
135
+ private isPlainConfigurationUri;
136
+ private loadConfiguration;
121
137
  loadSettings(): Promise<void>;
122
138
  private tryDecodeJson;
123
139
  private cloneSettings;
@@ -13,7 +13,12 @@ import { ServiceBase } from "./ServiceBase.js";
13
13
  import { createInstanceLogFunction } from "../lib/logUtils.js";
14
14
  import { isCloudantURI } from "../../pouchdb/utils_couchdb.js";
15
15
  import { decryptString, encryptString } from "../../encryption/stringEncryption.js";
16
- import { encryptWithEphemeralSalt } from "octagonal-wheels/encryption/hkdf";
16
+ import { encryptWithEphemeralSalt, HKDF_SALTED_ENCRYPTED_PREFIX } from "octagonal-wheels/encryption/hkdf";
17
+ import {
18
+ ENCRYPT_V1_PREFIX_PROBABLY,
19
+ ENCRYPT_V2_PREFIX,
20
+ ENCRYPT_V3_PREFIX
21
+ } from "octagonal-wheels/encryption/encryption";
17
22
  import {
18
23
  activateP2PRemoteConfiguration,
19
24
  activateRemoteConfiguration,
@@ -31,6 +36,8 @@ import {
31
36
  restoreConnectionSettings,
32
37
  selectConnectionSettings
33
38
  } from "../../common/models/setting.policy.js";
39
+ class ConfigurationPassphraseError extends Error {
40
+ }
34
41
  class SettingService extends ServiceBase {
35
42
  deviceAndVaultName = "";
36
43
  APIService;
@@ -44,6 +51,7 @@ class SettingService extends ServiceBase {
44
51
  }
45
52
  _lastPersistedSettings;
46
53
  _settingsMigrationState;
54
+ saveQueue = Promise.resolve();
47
55
  getSettingsMigrationState() {
48
56
  return this._settingsMigrationState;
49
57
  }
@@ -152,31 +160,131 @@ class SettingService extends ServiceBase {
152
160
  /**
153
161
  * Save the current settings to storage.
154
162
  */
155
- async saveSettingData() {
156
- this.saveDeviceAndVaultName();
163
+ saveSettingData() {
164
+ return this.serialiseSave(async () => {
165
+ const prepared = await this.prepareSettingSave(this.cloneSettings(this.settings));
166
+ await this.writePreparedSettings(prepared);
167
+ this.commitPreparedSettings(prepared);
168
+ });
169
+ }
170
+ serialiseSave(operation) {
171
+ const result = this.saveQueue.then(operation);
172
+ this.saveQueue = result.catch(() => void 0);
173
+ return result;
174
+ }
175
+ /** Change the configuration wrapper without changing the protected plaintext values. */
176
+ changeConfigurationEncryption(store, passphrase) {
177
+ const result = this.serialiseSave(async () => {
178
+ if (store !== "" && store !== "LOCALSTORAGE" && store !== "ASK_AT_LAUNCH") {
179
+ throw new Error("The configuration passphrase storage mode is unsupported.");
180
+ }
181
+ const oldLocalKey = this.getDeviceLocalConfig("ls-setting-passphrase");
182
+ const settings = this.cloneSettings(this.settings);
183
+ let targetKey;
184
+ if (store === "LOCALSTORAGE") {
185
+ targetKey = passphrase ?? oldLocalKey ?? false;
186
+ } else if (store === "") {
187
+ targetKey = "*";
188
+ } else {
189
+ targetKey = await this.getSavingPassphrase({ ...settings, configPassphraseStore: store });
190
+ }
191
+ if (!targetKey) {
192
+ throw new Error("A configuration passphrase is required. Settings were not saved.");
193
+ }
194
+ await this.prepareConfigurationKeyChange(settings, targetKey);
195
+ settings.configPassphraseStore = store;
196
+ const prepared = await this.prepareSettingSave(settings, targetKey);
197
+ const changesLocalKey = store === "LOCALSTORAGE" && targetKey !== oldLocalKey;
198
+ try {
199
+ if (changesLocalKey) this.setDeviceLocalConfig("ls-setting-passphrase", targetKey);
200
+ await this.writePreparedSettings(prepared);
201
+ } catch (error) {
202
+ if (changesLocalKey) this.restoreConfigurationPassphrase(oldLocalKey, error);
203
+ throw error;
204
+ }
205
+ this.settings.configPassphraseStore = store;
206
+ this.commitPreparedSettings(prepared);
207
+ });
208
+ return result.catch((error) => {
209
+ this._log(this.context.translate("SettingService.ConfigurationEncryptionSaveFailed"), LOG_LEVEL_URGENT);
210
+ throw error;
211
+ });
212
+ }
213
+ restoreConfigurationPassphrase(previous, cause) {
214
+ try {
215
+ if (previous) this.setDeviceLocalConfig("ls-setting-passphrase", previous);
216
+ else this.deleteDeviceLocalConfig("ls-setting-passphrase");
217
+ } catch {
218
+ const message = this.context.translate("SettingService.ConfigurationPassphraseRollbackFailed");
219
+ this._log(message, LOG_LEVEL_URGENT);
220
+ throw new Error(message, { cause });
221
+ }
222
+ }
223
+ async prepareConfigurationKeyChange(settings, targetKey) {
224
+ for (const config of Object.values(settings.remoteConfigurations)) {
225
+ if (config.isEncrypted && this.isPlainConfigurationUri(config.uri)) config.isEncrypted = false;
226
+ }
227
+ const hasCiphertext = this.hasProtectedConfiguration(settings);
228
+ if (!hasCiphertext) return;
229
+ const oldKey = await this.getSavingPassphrase(settings);
230
+ const failure = () => new Error(this.context.translate("SettingService.ConfigurationCouldNotBeDecrypted"));
231
+ if (!oldKey) throw failure();
232
+ if (settings.encryptedCouchDBConnection) {
233
+ const connection = this.tryDecodeJson(
234
+ await this.decryptConfigurationItem(settings.encryptedCouchDBConnection, oldKey)
235
+ );
236
+ if (!restoreConnectionSettings(this.cloneSettings(settings), connection)) throw failure();
237
+ settings.encryptedCouchDBConnection = "";
238
+ }
239
+ if (settings.encryptedPassphrase) {
240
+ const plaintext = await this.decryptConfigurationItem(settings.encryptedPassphrase, oldKey);
241
+ if (plaintext === false) throw failure();
242
+ settings.encryptedPassphrase = requireConfigurationCiphertext(
243
+ await this.encryptPlainConfigurationItem(plaintext, settings, targetKey)
244
+ );
245
+ }
246
+ if (settings.idDerivationVersion === 1 && settings.encryptedIdDerivationKey) {
247
+ const idKey = await this.decryptConfigurationItem(settings.encryptedIdDerivationKey, oldKey);
248
+ if (idKey === false) throw failure();
249
+ configuredIdKey({ ...settings, idDerivationKey: idKey });
250
+ }
251
+ for (const config of Object.values(settings.remoteConfigurations)) {
252
+ if (!config.isEncrypted) continue;
253
+ const plaintext = await this.decryptConfigurationItem(config.uri, oldKey);
254
+ if (plaintext === false) {
255
+ try {
256
+ ConnectionStringParser.parse(config.uri);
257
+ } catch {
258
+ throw failure();
259
+ }
260
+ } else {
261
+ config.uri = plaintext;
262
+ }
263
+ config.isEncrypted = false;
264
+ }
265
+ }
266
+ async prepareSettingSave(settings, selectedPassphrase) {
157
267
  const previousSettings = this._lastPersistedSettings ?? this.cloneSettings(this.settings);
158
- const settings = this.cloneSettings(this.settings);
159
268
  const hookResults = await this.onBeforeSaveSettingData(settings, previousSettings);
269
+ const savedPatch = {};
160
270
  for (const patch of hookResults) {
161
271
  if (patch instanceof Error || !patch) continue;
162
272
  Object.assign(settings, patch);
163
- Object.assign(this.settings, patch);
273
+ Object.assign(savedPatch, patch);
164
274
  }
275
+ const runtime = this.cloneSettings(settings);
165
276
  const idKey = configuredIdKey(settings);
277
+ const configurationPassphrase = selectedPassphrase ?? await this.getSavingPassphrase(settings);
166
278
  if (settings.idDerivationVersion === 1) {
167
279
  if (idKey === false) {
168
280
  throw new Error("The configured ID derivation key is unavailable.");
169
281
  }
170
- let passphrase = this.usedPassphrase;
171
- if (passphrase === "") {
172
- passphrase = await this.getPassphrase(settings);
173
- }
174
- if (passphrase === false || passphrase === "") {
282
+ if (!configurationPassphrase) {
175
283
  const message = "Failed to retrieve a passphrase for the ID derivation key. Settings were not saved.";
176
284
  this._log(message, LOG_LEVEL_URGENT);
177
285
  throw new Error(message);
178
286
  }
179
- const encryptedIdKey = await encryptString(idKey, passphrase + SALT_OF_PASSPHRASE);
287
+ const encryptedIdKey = await encryptString(idKey, configurationPassphrase + SALT_OF_PASSPHRASE);
180
288
  if (encryptedIdKey === "") {
181
289
  const message = "Failed to encrypt the ID derivation key. Settings were not saved.";
182
290
  this._log(message, LOG_LEVEL_URGENT);
@@ -184,18 +292,14 @@ class SettingService extends ServiceBase {
184
292
  }
185
293
  settings.encryptedIdDerivationKey = encryptedIdKey;
186
294
  settings.idDerivationKey = "";
187
- this.usedPassphrase = passphrase;
188
295
  } else {
189
296
  settings.encryptedIdDerivationKey = "";
190
297
  }
191
298
  settings.deviceAndVaultName = "";
192
299
  if (settings.P2P_DevicePeerName && settings.P2P_DevicePeerName.trim() !== "") {
193
- this._log("Saving device peer name to small config");
194
- this.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, settings.P2P_DevicePeerName.trim());
195
300
  settings.P2P_DevicePeerName = "";
196
301
  }
197
302
  omitUnpersistedSettings(settings);
198
- const configurationPassphrase = this.usedPassphrase || await this.getPassphrase(settings);
199
303
  if (!configurationPassphrase) {
200
304
  if (Object.values(settings.remoteConfigurations).some((config) => this.hasManagedP2PProfileURI(config.uri))) {
201
305
  const message = "Failed to retrieve a passphrase for managed P2P source data. Settings were not saved.";
@@ -213,7 +317,11 @@ class SettingService extends ServiceBase {
213
317
  } else {
214
318
  if (hasConnectionSettings(settings)) {
215
319
  settings.encryptedCouchDBConnection = requireConfigurationCiphertext(
216
- await this.encryptConfigurationItem(JSON.stringify(selectConnectionSettings(settings)), settings)
320
+ await this.encryptConfigurationItem(
321
+ JSON.stringify(selectConnectionSettings(settings)),
322
+ settings,
323
+ configurationPassphrase
324
+ )
217
325
  );
218
326
  clearConnectionSettings(settings);
219
327
  } else if (settings.encryptedCouchDBConnection) {
@@ -226,18 +334,32 @@ class SettingService extends ServiceBase {
226
334
  }
227
335
  if (settings.encrypt && settings.passphrase != "") {
228
336
  settings.encryptedPassphrase = requireConfigurationCiphertext(
229
- await this.encryptPlainConfigurationItem(settings.passphrase, settings)
337
+ await this.encryptPlainConfigurationItem(settings.passphrase, settings, configurationPassphrase)
230
338
  );
231
339
  settings.passphrase = "";
232
340
  }
233
- await this.encryptRemoteConfigurationUris(settings);
341
+ await this.encryptRemoteConfigurationUris(settings, configurationPassphrase);
234
342
  }
235
343
  const persisted = prepareSettingsForPersistence(settings);
236
- await this.saveData(persisted);
237
- this._lastPersistedSettings = this.cloneSettings(this.settings);
238
- void this.onSettingSaved(settings);
344
+ return { persisted, runtime, patch: savedPatch, passphrase: configurationPassphrase };
239
345
  }
240
- async encryptRemoteConfigurationUris(settings) {
346
+ async writePreparedSettings(prepared) {
347
+ this.saveDeviceAndVaultName();
348
+ if (prepared.runtime.P2P_DevicePeerName?.trim()) {
349
+ this.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, prepared.runtime.P2P_DevicePeerName.trim());
350
+ }
351
+ await this.saveData(prepared.persisted);
352
+ }
353
+ commitPreparedSettings(prepared) {
354
+ Object.assign(this.settings, prepared.patch);
355
+ this.settings.encryptedCouchDBConnection = prepared.persisted.encryptedCouchDBConnection;
356
+ this.settings.encryptedPassphrase = prepared.persisted.encryptedPassphrase;
357
+ this.settings.encryptedIdDerivationKey = prepared.persisted.encryptedIdDerivationKey;
358
+ this._lastPersistedSettings = prepared.runtime;
359
+ if (prepared.passphrase) this.rememberPassphrase(prepared.runtime, prepared.passphrase);
360
+ void this.onSettingSaved(prepared.persisted);
361
+ }
362
+ async encryptRemoteConfigurationUris(settings, passphrase) {
241
363
  const configs = settings.remoteConfigurations || {};
242
364
  for (const [id, config] of Object.entries(configs)) {
243
365
  if (config.isEncrypted || config.uri.trim() === "") {
@@ -246,7 +368,7 @@ class SettingService extends ServiceBase {
246
368
  const managedP2PProfile = this.hasManagedP2PProfileURI(config.uri);
247
369
  let encryptedURI;
248
370
  try {
249
- encryptedURI = await this.encryptConfigurationItem(config.uri, settings);
371
+ encryptedURI = await this.encryptConfigurationItem(config.uri, settings, passphrase);
250
372
  } catch (error) {
251
373
  if (managedP2PProfile) {
252
374
  const message = `Failed to encrypt managed P2P remote configuration '${id}'. Settings were not saved.`;
@@ -288,6 +410,7 @@ class SettingService extends ServiceBase {
288
410
  return (new URLSearchParams(query).get("managedType") ?? "").trim().length > 0;
289
411
  }
290
412
  async decryptRemoteConfigurationUris(settings, passphrase) {
413
+ let verified = false;
291
414
  const configs = settings.remoteConfigurations || {};
292
415
  for (const [id, config] of Object.entries(configs)) {
293
416
  if (!config.isEncrypted) {
@@ -319,7 +442,9 @@ class SettingService extends ServiceBase {
319
442
  uri: decryptedURI,
320
443
  isEncrypted: false
321
444
  };
445
+ verified = true;
322
446
  }
447
+ return verified;
323
448
  }
324
449
  /**
325
450
  * Event triggered before realising the settings.
@@ -404,24 +529,37 @@ class SettingService extends ServiceBase {
404
529
  const methods = {
405
530
  "": () => Promise.resolve("*"),
406
531
  LOCALSTORAGE: () => Promise.resolve(this.getDeviceLocalConfig("ls-setting-passphrase") ?? false),
407
- ASK_AT_LAUNCH: () => this.APIService.confirm.askString("Passphrase", "passphrase", "")
532
+ ASK_AT_LAUNCH: () => this.APIService.confirm.askString(
533
+ this.context.translate("SettingService.ConfigurationPassphraseTitle"),
534
+ "passphrase",
535
+ "",
536
+ true
537
+ )
408
538
  };
409
539
  const method = settings.configPassphraseStore;
410
540
  const methodFunc = method in methods ? methods[method] : methods[""];
411
541
  return methodFunc();
412
542
  }
413
- usedPassphrase = "";
543
+ usedPassphrase;
544
+ getSavingPassphrase(settings) {
545
+ if (settings.configPassphraseStore === "ASK_AT_LAUNCH" && this.usedPassphrase?.store === "ASK_AT_LAUNCH") {
546
+ return Promise.resolve(this.usedPassphrase.value);
547
+ }
548
+ return this.getPassphrase(settings);
549
+ }
550
+ rememberPassphrase(settings, passphrase) {
551
+ this.usedPassphrase = { store: settings.configPassphraseStore, value: passphrase };
552
+ }
414
553
  /**
415
554
  * Clear any used passphrase from memory.
416
555
  */
417
556
  clearUsedPassphrase() {
418
- this.usedPassphrase = "";
557
+ this.usedPassphrase = void 0;
419
558
  }
420
559
  async decryptConfigurationItem(encrypted, passphrase) {
421
560
  try {
422
561
  const dec = await decryptString(encrypted, passphrase + SALT_OF_PASSPHRASE);
423
562
  if (dec) {
424
- this.usedPassphrase = passphrase;
425
563
  return dec;
426
564
  }
427
565
  } catch (ex) {
@@ -430,18 +568,15 @@ class SettingService extends ServiceBase {
430
568
  }
431
569
  return false;
432
570
  }
433
- async encryptConfigurationItem(src, settings) {
434
- return this.encryptConfigurationItemWith(src, settings, encryptString);
571
+ async encryptConfigurationItem(src, settings, passphrase) {
572
+ return this.encryptConfigurationItemWith(src, settings, encryptString, passphrase);
435
573
  }
436
- async encryptPlainConfigurationItem(src, settings) {
437
- return this.encryptConfigurationItemWith(src, settings, encryptWithEphemeralSalt);
574
+ async encryptPlainConfigurationItem(src, settings, passphrase) {
575
+ return this.encryptConfigurationItemWith(src, settings, encryptWithEphemeralSalt, passphrase);
438
576
  }
439
- async encryptConfigurationItemWith(src, settings, encrypt) {
440
- if (this.usedPassphrase != "") {
441
- return await encrypt(src, this.usedPassphrase + SALT_OF_PASSPHRASE);
442
- }
443
- const passphrase = await this.getPassphrase(settings);
444
- if (passphrase === false) {
577
+ async encryptConfigurationItemWith(src, settings, encrypt, selectedPassphrase) {
578
+ const passphrase = selectedPassphrase ?? await this.getSavingPassphrase(settings);
579
+ if (!passphrase) {
445
580
  this._log(
446
581
  "Failed to obtain passphrase when saving data.json! Please verify the configuration.",
447
582
  LOG_LEVEL_URGENT
@@ -450,7 +585,7 @@ class SettingService extends ServiceBase {
450
585
  }
451
586
  const dec = await encrypt(src, passphrase + SALT_OF_PASSPHRASE);
452
587
  if (dec) {
453
- this.usedPassphrase = passphrase;
588
+ if (selectedPassphrase === void 0) this.rememberPassphrase(settings, passphrase);
454
589
  return dec;
455
590
  }
456
591
  return "";
@@ -460,19 +595,24 @@ class SettingService extends ServiceBase {
460
595
  * @param settings The settings to decrypt.
461
596
  */
462
597
  async decryptSettings(settings) {
463
- if (settings.idDerivationVersion !== 1) {
464
- configuredIdKey(settings);
465
- } else if (!settings.encryptedIdDerivationKey) {
466
- throw new Error("The configured ID derivation key is missing or unavailable.");
467
- }
598
+ this.validateStoredIdConfiguration(settings);
468
599
  const passphrase = await this.getPassphrase(settings);
600
+ const result = await this.decryptSettingsWithPassphrase(settings, passphrase);
601
+ if (result.verified && passphrase) this.rememberPassphrase(settings, passphrase);
602
+ return result.settings;
603
+ }
604
+ async decryptSettingsWithPassphrase(settings, passphrase) {
605
+ let verified = false;
606
+ this.validateStoredIdConfiguration(settings);
469
607
  if (settings.idDerivationVersion === 1) {
470
608
  if (passphrase === false || passphrase === "") {
471
- throw new Error("The configured ID derivation key cannot be decrypted without a passphrase.");
609
+ throw new ConfigurationPassphraseError(
610
+ "The configured ID derivation key cannot be decrypted without a passphrase."
611
+ );
472
612
  }
473
613
  const decryptedIdKey = await this.decryptConfigurationItem(settings.encryptedIdDerivationKey, passphrase);
474
614
  if (decryptedIdKey === false) {
475
- throw new Error("The configured ID derivation key could not be decrypted.");
615
+ throw new ConfigurationPassphraseError("The configured ID derivation key could not be decrypted.");
476
616
  }
477
617
  try {
478
618
  configuredIdKey({ ...settings, idDerivationKey: decryptedIdKey });
@@ -480,6 +620,7 @@ class SettingService extends ServiceBase {
480
620
  throw new Error("The configured ID derivation key is invalid.");
481
621
  }
482
622
  settings.idDerivationKey = decryptedIdKey;
623
+ verified = true;
483
624
  }
484
625
  if (passphrase === false) {
485
626
  this._log("No passphrase found for data.json! Verify configuration before syncing.", LOG_LEVEL_URGENT);
@@ -503,6 +644,8 @@ class SettingService extends ServiceBase {
503
644
  LOG_LEVEL_URGENT
504
645
  );
505
646
  clearConnectionSettings(settings);
647
+ } else {
648
+ verified = true;
506
649
  }
507
650
  }
508
651
  if (settings.encrypt && settings.encryptedPassphrase) {
@@ -510,6 +653,7 @@ class SettingService extends ServiceBase {
510
653
  const decrypted = await this.decryptConfigurationItem(encrypted, passphrase);
511
654
  if (decrypted) {
512
655
  settings.passphrase = decrypted;
656
+ verified = true;
513
657
  } else {
514
658
  this._log(
515
659
  "Failed to decrypt passphrase from data.json! Ensure configuration is correct before syncing with remote.",
@@ -518,9 +662,80 @@ class SettingService extends ServiceBase {
518
662
  settings.passphrase = "";
519
663
  }
520
664
  }
521
- await this.decryptRemoteConfigurationUris(settings, passphrase);
665
+ verified = await this.decryptRemoteConfigurationUris(settings, passphrase) || verified;
666
+ }
667
+ return { settings, verified };
668
+ }
669
+ hasProtectedConfiguration(settings) {
670
+ return !!(settings.encryptedCouchDBConnection || settings.encrypt && settings.encryptedPassphrase || settings.encryptedIdDerivationKey || Object.values(settings.remoteConfigurations || {}).some(
671
+ (config) => config.isEncrypted && !this.isPlainConfigurationUri(config.uri)
672
+ ));
673
+ }
674
+ validateStoredIdConfiguration(settings) {
675
+ if (settings.idDerivationVersion !== 1) {
676
+ configuredIdKey(settings);
677
+ } else if (!settings.encryptedIdDerivationKey) {
678
+ throw new Error("The configured ID derivation key is missing or unavailable.");
679
+ }
680
+ }
681
+ isPlainConfigurationUri(uri) {
682
+ try {
683
+ ConnectionStringParser.parse(uri);
684
+ return true;
685
+ } catch {
686
+ return false;
687
+ }
688
+ }
689
+ async loadConfiguration(settings) {
690
+ if (settings.configPassphraseStore !== "LOCALSTORAGE" || !this.hasProtectedConfiguration(settings)) {
691
+ return this.decryptSettings(settings);
692
+ }
693
+ if (settings.idDerivationVersion === 1) {
694
+ const ciphertext = settings.encryptedIdDerivationKey;
695
+ if (![HKDF_SALTED_ENCRYPTED_PREFIX, ENCRYPT_V1_PREFIX_PROBABLY, ENCRYPT_V2_PREFIX, ENCRYPT_V3_PREFIX].some(
696
+ (prefix) => ciphertext?.startsWith(prefix)
697
+ )) {
698
+ throw new Error("The configured ID derivation key is missing or unavailable.");
699
+ }
700
+ } else {
701
+ configuredIdKey(settings);
702
+ }
703
+ const previous = this.getDeviceLocalConfig("ls-setting-passphrase");
704
+ let candidate = previous || false;
705
+ let retry = false;
706
+ while (true) {
707
+ if (candidate) {
708
+ try {
709
+ const result = await this.decryptSettingsWithPassphrase(this.cloneSettings(settings), candidate);
710
+ if (result.verified) {
711
+ if (candidate !== previous) {
712
+ try {
713
+ this.setDeviceLocalConfig("ls-setting-passphrase", candidate);
714
+ } catch (error) {
715
+ this.restoreConfigurationPassphrase(previous, error);
716
+ throw error;
717
+ }
718
+ }
719
+ this.rememberPassphrase(settings, candidate);
720
+ return result.settings;
721
+ }
722
+ } catch (error) {
723
+ if (!(error instanceof ConfigurationPassphraseError)) throw error;
724
+ }
725
+ retry = true;
726
+ }
727
+ candidate = await this.APIService.confirm.askString(
728
+ this.context.translate(
729
+ retry ? "SettingService.ConfigurationPassphraseRetryTitle" : "SettingService.ConfigurationPassphraseTitle"
730
+ ),
731
+ "passphrase",
732
+ "",
733
+ true
734
+ );
735
+ if (!candidate) {
736
+ throw new Error(this.context.translate("SettingService.ConfigurationPassphraseCancelled"));
737
+ }
522
738
  }
523
- return settings;
524
739
  }
525
740
  async loadSettings() {
526
741
  const prepared = prepareSettingsForLoad(await this.loadData());
@@ -536,7 +751,7 @@ class SettingService extends ServiceBase {
536
751
  settings.isConfigured = false;
537
752
  }
538
753
  }
539
- this.settings = await this.decryptSettings(settings);
754
+ this.settings = await this.loadConfiguration(settings);
540
755
  this.onDisplayLanguageChanged?.(this.settings.displayLanguage);
541
756
  await this.adjustSettings(this.settings);
542
757
  const migratedLegacyRemoteConfigurations = !hadRemoteConfigurations && Object.keys(this.settings.remoteConfigurations ?? {}).length > 0;
@@ -8,6 +8,8 @@ The code paths below identify the maintained Self-hosted LiveSync composition po
8
8
 
9
9
  ## Obsidian plug-in
10
10
 
11
+ Unreleased configuration-encryption tests cover changing the wrapper key without changing connection, E2EE, profile, or independent ID values. They also cover custom-key input before loading, failed writes, concurrent saves, cancellation, and unreadable-payload retention. Commonlib owns these service results. LiveSync owns the Apply controls, setting-tab registration, and database startup order; actual Apply and restart behaviour requires its focused real-Obsidian scenario.
12
+
11
13
  The CouchDB connection fix bypasses browser HTTP caching so encrypted replication can continue after a remote Rebuild preserves document IDs and revisions. Commonlib's regression tests cover Metadata and Chunks encrypted with a replacement Security Seed while an earlier response remains cached. Self-hosted LiveSync's `couchdb-http-cache` scenario additionally populates the real browser HTTP cache before rebuilding, confirms that the Seed and ciphertext change while the revision remains unchanged, and requires subsequent replication to succeed without clearing that cache. The released LiveSync 1.0.34 fails this scenario; a local checked Commonlib artefact passes in the maintained LiveSync 1.0.34 composition on Linux Obsidian 1.12.7. This establishes the tested build's behaviour, with the reporter's Windows and Obsidian 1.13.7 environment still unverified.
12
14
 
13
15
  Unreleased settings tests verify that Object Storage-only credentials, JWT values, and authentication headers reach the host persistence handler only after connection encryption, and that unavailable or failed required encryption preserves the previous persisted settings. The focused `/settings` entry also supplies conditional whole-profile Markdown preparation and import preservation. A clean packed consumer checks the exhaustive property policy and prepared output types with an ES2018 compile fixture. Commonlib owns these result and type contracts; Self-hosted LiveSync owns Markdown file writes, its settings interface, and restoration through a real Obsidian restart.
@@ -74,6 +74,10 @@ The standard Commonlib `SettingService` performs this preparation automatically.
74
74
 
75
75
  ## Preparing settings for persistence and Markdown
76
76
 
77
+ The existing `SettingService` provides `changeConfigurationEncryption(store, passphrase?)` for hosts which edit the configuration encryption mode or device-local custom key. Await it before marking those edits as saved. It prepares one wrapper key for connection credentials, the E2EE passphrase, remote profile URIs, and the independent ID key, and serialises the change with ordinary settings saves. It rejects empty keys and unreadable existing protected items. A failed write attempts to restore the previous device key and does not send a success event. The device key and settings document use separate host writes, so this is not a guarantee of rollback after process termination or an uncertain persistence error.
78
+
79
+ When `loadSettings()` cannot validate a custom configuration key, it requests a masked key through the host's existing `confirm.askString` capability. It validates each candidate on a fresh copy and stores only a validated key before continuing initialisation. Cancellation or unavailable input stops that load without changing the saved settings. The ID-key rejection contract remains in `decryptSettings()`; loading never replaces a missing or invalid ID key with another key. ASK mode retains one successful in-memory key for ordinary saves in that launch. Hosts own the visible prompt, cancellation on disposal, and the policy which prevents database and replication work before loading completes.
80
+
77
81
  `SettingService` prepares a separate settings object before calling the host's persistence handler. Object Storage credentials, CouchDB credentials, JWT values, and authentication headers share the existing `encryptedCouchDBConnection` payload. Encryption runs when any protected connection property is present, including when every CouchDB property is empty. The same policy supplies the properties encrypted, cleared before saving, and restored on loading. Older plaintext settings and encrypted payloads without newer properties remain readable.
78
82
 
79
83
  An unavailable configuration passphrase or an empty connection-encryption result stops the save before the host's persistence handler receives the object. Remote profile URI encryption also stops on failure when a configuration passphrase is explicitly selected. The previous persisted settings remain intact. Removing all connection values clears an older encrypted payload only after it can be decrypted, preserving recovery after a failed decryption. Profile names, IDs, and selections remain ordinary settings; profile URIs are encrypted separately.
@@ -116,3 +120,5 @@ The settings schema version is independent of the package version and of any rem
116
120
  The settings lifecycle unit tests cover blank stores, representative legacy choices, migration idempotence, legacy file-name case normalisation, legacy review state, future-schema downgrade protection, and the distinction between new-Vault recommendations and stored-setting fallbacks. Credential tests cover Object Storage-only persistence, JWT and header restoration, legacy settings, removed credentials, encryption failures, conditional whole-profile Markdown export, and preservation of local values on import. Tweak-assessment tests cover effective defaults, unadvertised values, directional change sets, reconstruction impact, raw representation differences, immutable snapshots, and document-ID case behaviour. The packed-package test imports the focused `/settings` entry from a clean consumer and checks its declarations and runtime exports. Its ES2018 compile fixture checks missing policies and attempts to bypass prepared output types.
117
121
 
118
122
  Hosts remain responsible for testing when setup is classified as new, how local review acknowledgement is stored, how replication is gated, and how their settings interface presents the result.
123
+
124
+ `SettingService.configurationEncryption.unit.spec.ts` covers key changes with ID versions 0 and 1, stale-cache avoidance, per-launch ASK reuse, concurrent saves, preparation and persistence errors, rollback reporting, custom-key input and cancellation, and retention of unreadable items. These are Commonlib-owned service contracts; Apply controls and real application restarts require downstream verification.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrtmrz/livesync-commonlib",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "description": "Shared data, storage, and replication primitives for Self-hosted LiveSync clients.",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",
@@ -1010,7 +1010,7 @@
1010
1010
  "idb": "^8.0.3",
1011
1011
  "markdown-it": "^14.2.0",
1012
1012
  "minimatch": "^10.2.5",
1013
- "octagonal-wheels": "^0.1.53",
1013
+ "octagonal-wheels": "^0.1.55",
1014
1014
  "pouchdb-adapter-http": "^9.0.0",
1015
1015
  "pouchdb-adapter-idb": "^9.0.0",
1016
1016
  "pouchdb-adapter-indexeddb": "^9.0.0",