@vrtmrz/livesync-commonlib 0.1.30 → 0.1.32
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.
- package/README.md +5 -0
- package/dist/API/DirectFileManipulatorV2.d.ts +2 -0
- package/dist/API/DirectFileManipulatorV2.js +17 -5
- package/dist/API/processSetting.d.ts +19 -0
- package/dist/API/processSetting.js +91 -6
- package/dist/common/idDerivation.d.ts +13 -0
- package/dist/common/idDerivation.js +75 -0
- package/dist/common/models/db.definition.d.ts +1 -0
- package/dist/common/models/setting.const.defaults.js +3 -0
- package/dist/common/models/setting.const.qr.js +4 -1
- package/dist/common/models/setting.type.d.ts +6 -0
- package/dist/common/models/tweak.compatibility.js +6 -2
- package/dist/common/models/tweak.definition.d.ts +8 -0
- package/dist/common/models/tweak.definition.js +4 -3
- package/dist/common/settingConstants.d.ts +3 -0
- package/dist/common/utils.js +3 -1
- package/dist/events/coreEvents.d.ts +3 -0
- package/dist/events/coreEvents.js +2 -0
- package/dist/managers/EntryManager/EntryManagerImpls.js +10 -1
- package/dist/managers/HashManager/HashManager.d.ts +1 -0
- package/dist/managers/HashManager/HashManager.js +13 -1
- package/dist/pouchdb/LiveSyncDBFunctions.d.ts +1 -1
- package/dist/pouchdb/LiveSyncDBFunctions.js +33 -38
- package/dist/pouchdb/negotiation.js +7 -3
- package/dist/pouchdb/remoteFeatureCompatibility.d.ts +10 -0
- package/dist/pouchdb/remoteFeatureCompatibility.js +12 -1
- package/dist/pouchdb/remoteIdCompatibility.d.ts +5 -0
- package/dist/pouchdb/remoteIdCompatibility.js +28 -0
- package/dist/replication/CentralCompatibility.d.ts +1 -0
- package/dist/replication/CentralCompatibility.js +2 -1
- package/dist/replication/couchdb/LiveSyncReplicator.d.ts +6 -2
- package/dist/replication/couchdb/LiveSyncReplicator.js +24 -5
- package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +1 -0
- package/dist/replication/journal/LiveSyncJournalReplicator.js +50 -5
- package/dist/replication/trystero/TrysteroReplicator.d.ts +369 -5
- package/dist/replication/trystero/TrysteroReplicator.js +31 -4
- package/dist/services/base/AppLifecycleService.d.ts +2 -0
- package/dist/services/base/AppLifecycleService.js +5 -0
- package/dist/services/base/IService.d.ts +1 -0
- package/dist/services/base/PathService.d.ts +3 -1
- package/dist/services/base/PathService.js +9 -4
- package/dist/services/base/SettingService.d.ts +2 -0
- package/dist/services/base/SettingService.js +57 -3
- package/dist/settings.d.ts +2 -1
- package/dist/settings.js +14 -0
- package/dist/setupUri.d.ts +6 -0
- package/dist/setupUri.js +14 -0
- package/dist/string_and_binary/path.d.ts +1 -1
- package/dist/string_and_binary/path.js +3 -3
- package/dist/worker/bgWorker.js +1 -1
- package/docs/proven-in-use.md +8 -0
- package/docs/releasing.md +1 -1
- package/docs/setup-uri.md +58 -0
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -33,6 +33,7 @@ The package is ESM-only and declares Node 20 or later. Browser entry points are
|
|
|
33
33
|
| `@vrtmrz/livesync-commonlib/rpc` | Existing LiveSync RPC and PouchDB bridge migration | Transitional only; not part of the stable Commonlib 1.0 contract |
|
|
34
34
|
| `@vrtmrz/livesync-commonlib/remote-configurations` | Multiple-remote profile creation, naming, and selection | Focused, package-tested pre-1.0 contract |
|
|
35
35
|
| `@vrtmrz/livesync-commonlib/settings` | New-Vault defaults, stored-setting fallbacks, and settings migration results | Focused, package-tested pre-1.0 contract |
|
|
36
|
+
| `@vrtmrz/livesync-commonlib/setup-uri` | Encrypted Setup URI generation, availability, and import | Focused, package-tested pre-1.0 contract |
|
|
36
37
|
| `@vrtmrz/livesync-commonlib/compat/*` | Exact legacy imports still required by existing clients | Migration-only; paths may be removed as consumers migrate |
|
|
37
38
|
| `@vrtmrz/livesync-commonlib/package.json` | Package metadata for tooling | Metadata export, not a runtime API |
|
|
38
39
|
|
|
@@ -113,6 +114,10 @@ New-Vault recommendations are deliberately separate from the conservative values
|
|
|
113
114
|
|
|
114
115
|
See [the settings lifecycle guide](docs/settings-lifecycle.md) before initialising, importing, resetting, or migrating settings.
|
|
115
116
|
|
|
117
|
+
## Setup URI sharing
|
|
118
|
+
|
|
119
|
+
The focused Setup URI entry offers an Ephemeral mode bound to the current fixed seven-day window and a Persistent mode compatible with existing readers. Both use the same URI structure. See [the Setup URI sharing guide](docs/setup-uri.md) for the availability boundary, import behaviour, and compatibility limits.
|
|
120
|
+
|
|
116
121
|
## Remote connection profiles
|
|
117
122
|
|
|
118
123
|
New profile-management code can create or update a CouchDB, Object Storage, or P2P connection through the focused remote-configurations entry. Profile IDs provide identity, display names remain editable presentation, and the active selection is explicit. Older flat connection fields remain a runtime projection and an import-compatibility boundary rather than the preferred persistence model.
|
|
@@ -13,6 +13,8 @@ export type DirectFileManipulatorOptions = {
|
|
|
13
13
|
passphrase: string | undefined;
|
|
14
14
|
database: string;
|
|
15
15
|
obfuscatePassphrase: string | undefined;
|
|
16
|
+
idDerivationVersion?: 0 | 1;
|
|
17
|
+
idDerivationKey?: string;
|
|
16
18
|
encryptInternalMetadata?: boolean;
|
|
17
19
|
useDynamicIterationCount?: boolean;
|
|
18
20
|
customChunkSize?: number;
|
|
@@ -7,10 +7,8 @@ import {
|
|
|
7
7
|
VER
|
|
8
8
|
} from "../common/types.js";
|
|
9
9
|
import { checkRemoteVersion } from "../pouchdb/negotiation.js";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
usesEncryptedInternalMetadata
|
|
13
|
-
} from "../pouchdb/remoteFeatureCompatibility.js";
|
|
10
|
+
import { requiredRemoteFeatures, usesEncryptedInternalMetadata } from "../pouchdb/remoteFeatureCompatibility.js";
|
|
11
|
+
import { assessRemoteDocumentIds } from "../pouchdb/remoteIdCompatibility.js";
|
|
14
12
|
import { PouchDB } from "../pouchdb/pouchdb-http.js";
|
|
15
13
|
import { LiveSyncLocalDB } from "../pouchdb/LiveSyncLocalDB.js";
|
|
16
14
|
import { isErrorOfMissingDoc } from "../pouchdb/utils_couchdb.js";
|
|
@@ -57,7 +55,19 @@ class DirectFileManipulator {
|
|
|
57
55
|
if (!await this.liveSyncLocalDB.initializeDatabase()) {
|
|
58
56
|
throw new Error("Direct database initialisation was rejected.");
|
|
59
57
|
}
|
|
60
|
-
const
|
|
58
|
+
const idSettings = {
|
|
59
|
+
...this.settings,
|
|
60
|
+
passphrase: this.options.obfuscatePassphrase || this.settings.passphrase
|
|
61
|
+
};
|
|
62
|
+
const idCompatibility = await assessRemoteDocumentIds(
|
|
63
|
+
this.liveSyncLocalDB.localDatabase,
|
|
64
|
+
idSettings,
|
|
65
|
+
(path) => this.services.path.path2idWithSettings(path, idSettings)
|
|
66
|
+
);
|
|
67
|
+
if (idCompatibility === "mismatched") {
|
|
68
|
+
throw new Error("Direct database document IDs do not match the configured ID key.");
|
|
69
|
+
}
|
|
70
|
+
const requiredFeatures = requiredRemoteFeatures(this.settings);
|
|
61
71
|
if (!await checkRemoteVersion(
|
|
62
72
|
this.liveSyncLocalDB.localDatabase,
|
|
63
73
|
async () => false,
|
|
@@ -200,6 +210,8 @@ class DirectFileManipulator {
|
|
|
200
210
|
minimumChunkSize: this.options.minimumChunkSize ?? DEFAULT_SETTINGS.minimumChunkSize,
|
|
201
211
|
encrypt: this.options.passphrase ? true : false,
|
|
202
212
|
passphrase: this.options.passphrase ?? "",
|
|
213
|
+
idDerivationVersion: this.options.idDerivationVersion ?? 0,
|
|
214
|
+
idDerivationKey: this.options.idDerivationKey ?? "",
|
|
203
215
|
deleteMetadataOfDeletedFiles: DEFAULT_SETTINGS.deleteMetadataOfDeletedFiles,
|
|
204
216
|
customChunkSize: this.options.customChunkSize ?? DEFAULT_SETTINGS.customChunkSize,
|
|
205
217
|
doNotPaceReplication: DEFAULT_SETTINGS.doNotPaceReplication,
|
|
@@ -26,6 +26,20 @@ export interface SplitQRCodeData {
|
|
|
26
26
|
*/
|
|
27
27
|
export declare function encodeQR(settingString: string, format: OutputFormat): string | SplitQRCodeData;
|
|
28
28
|
type ErasureProperties = keyof ObsidianLiveSyncSettings;
|
|
29
|
+
export type TimeBoundSetupURIMode = "ephemeral" | "persistent";
|
|
30
|
+
export interface TimeBoundSetupURIOptions {
|
|
31
|
+
mode?: TimeBoundSetupURIMode;
|
|
32
|
+
removeProperties?: ErasureProperties[];
|
|
33
|
+
skipDefaultValue?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface TimeBoundSetupURIResult {
|
|
36
|
+
uri: string;
|
|
37
|
+
usableUntil: number | null;
|
|
38
|
+
}
|
|
39
|
+
/** Return the exclusive end of the current fixed Setup URI window. */
|
|
40
|
+
export declare function getTimeBoundSetupURIUsableUntil(): number;
|
|
41
|
+
/** Check the same fixed window that the Setup URI reader uses. */
|
|
42
|
+
export declare function isTimeBoundSetupURIUsableNow(usableUntil: number | null): boolean;
|
|
29
43
|
/**
|
|
30
44
|
* Generate setup URI with encrypted settings
|
|
31
45
|
* @param settingString Settings to encode
|
|
@@ -37,8 +51,12 @@ type ErasureProperties = keyof ObsidianLiveSyncSettings;
|
|
|
37
51
|
* @returns Generated setup URI
|
|
38
52
|
*/
|
|
39
53
|
export declare function encodeSettingsToSetupURI(settingString: ObsidianLiveSyncSettings, passphrase: string, removeProperties?: ErasureProperties[], skipDefaultValue?: boolean): Promise<string>;
|
|
54
|
+
/** Generate a Setup URI for the current UTC window or in the existing persistent format. */
|
|
55
|
+
export declare function encodeTimeBoundSetupURI(settingString: ObsidianLiveSyncSettings, passphrase: string, options?: TimeBoundSetupURIOptions): Promise<TimeBoundSetupURIResult>;
|
|
40
56
|
export declare function decodeSettingsFromSetupURI(uri: string, passphrase: string): Promise<false | Omit<{
|
|
41
57
|
encryptInternalMetadata: boolean;
|
|
58
|
+
idDerivationVersion: 0 | 1;
|
|
59
|
+
idDerivationKey: string;
|
|
42
60
|
liveSync: boolean;
|
|
43
61
|
syncOnSave: boolean;
|
|
44
62
|
syncOnStart: boolean;
|
|
@@ -74,6 +92,7 @@ export declare function decodeSettingsFromSetupURI(uri: string, passphrase: stri
|
|
|
74
92
|
disableMarkdownAutoMerge: boolean;
|
|
75
93
|
configPassphraseStore: import("../common/types.js").ConfigPassphraseStore;
|
|
76
94
|
encryptedPassphrase: string;
|
|
95
|
+
encryptedIdDerivationKey: string;
|
|
77
96
|
encryptedCouchDBConnection: string;
|
|
78
97
|
periodicReplication: boolean;
|
|
79
98
|
periodicReplicationInterval: number;
|
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
} from "../common/types.js";
|
|
10
10
|
import { decryptString, encryptString } from "../encryption/stringEncryption.js";
|
|
11
11
|
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
|
|
12
|
+
import { configuredIdKey } from "../common/idDerivation.js";
|
|
12
13
|
function encodeSettingsToQRCodeData(settings) {
|
|
13
14
|
settings = omitP2PRuntimeSettings(settings);
|
|
15
|
+
configuredIdKey(settings);
|
|
14
16
|
const fullIndexes = Object.entries(KeyIndexOfSettings);
|
|
15
17
|
let maxIndex = 0;
|
|
16
18
|
for (const [, index] of fullIndexes) {
|
|
@@ -50,6 +52,7 @@ function decodeSettingsFromQRCodeData(qr) {
|
|
|
50
52
|
LOG_LEVEL_VERBOSE
|
|
51
53
|
);
|
|
52
54
|
}
|
|
55
|
+
configuredIdKey(newSettings);
|
|
53
56
|
return omitP2PRuntimeSettings(newSettings);
|
|
54
57
|
}
|
|
55
58
|
var OutputFormat = /* @__PURE__ */ ((OutputFormat2) => {
|
|
@@ -94,15 +97,47 @@ function encodeQR(settingString, format) {
|
|
|
94
97
|
return { total, parts };
|
|
95
98
|
}
|
|
96
99
|
}
|
|
100
|
+
const SETUP_URI_WINDOW_MS = 6048e5;
|
|
101
|
+
function getTimeBoundSetupURIUsableUntil() {
|
|
102
|
+
const usableUntil = (setupURIWindow(Date.now()) + 1) * SETUP_URI_WINDOW_MS;
|
|
103
|
+
if (!Number.isSafeInteger(usableUntil)) throw new Error("Invalid Setup URI clock");
|
|
104
|
+
return usableUntil;
|
|
105
|
+
}
|
|
106
|
+
function isTimeBoundSetupURIUsableNow(usableUntil) {
|
|
107
|
+
if (usableUntil === null) return true;
|
|
108
|
+
if (!Number.isSafeInteger(usableUntil) || usableUntil < SETUP_URI_WINDOW_MS) return false;
|
|
109
|
+
try {
|
|
110
|
+
return setupURIWindow(Date.now()) === usableUntil / SETUP_URI_WINDOW_MS - 1;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function setupURIWindow(now) {
|
|
116
|
+
if (!Number.isSafeInteger(now) || now < 0) throw new Error("Invalid Setup URI clock");
|
|
117
|
+
return Math.floor(now / SETUP_URI_WINDOW_MS);
|
|
118
|
+
}
|
|
119
|
+
async function ephemeralSetupURIPassphrase(passphrase, window) {
|
|
120
|
+
const text = new TextEncoder();
|
|
121
|
+
const keyBytes = await crypto.subtle.digest("SHA-256", text.encode(passphrase));
|
|
122
|
+
const key = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
123
|
+
const context = JSON.stringify(["livesync/setup-uri", "tb1", "ephemeral", window]);
|
|
124
|
+
const signed = await crypto.subtle.sign("HMAC", key, text.encode(context));
|
|
125
|
+
return Array.from(new Uint8Array(signed), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
126
|
+
}
|
|
97
127
|
const necessaryErasureProperties = [
|
|
98
128
|
"configPassphraseStore",
|
|
99
129
|
"encryptedCouchDBConnection",
|
|
100
|
-
"encryptedPassphrase"
|
|
130
|
+
"encryptedPassphrase",
|
|
131
|
+
"encryptedIdDerivationKey"
|
|
101
132
|
];
|
|
102
133
|
async function encodeSettingsToSetupURI(settingString, passphrase, removeProperties = ["pluginSyncExtendedSetting"], skipDefaultValue = false) {
|
|
103
134
|
const setting = {
|
|
104
135
|
...omitP2PRuntimeSettings(settingString)
|
|
105
136
|
};
|
|
137
|
+
configuredIdKey({
|
|
138
|
+
idDerivationVersion: setting.idDerivationVersion ?? 0,
|
|
139
|
+
idDerivationKey: setting.idDerivationKey ?? ""
|
|
140
|
+
});
|
|
106
141
|
delete setting.P2P_managedType;
|
|
107
142
|
delete setting.P2P_managedId;
|
|
108
143
|
delete setting.P2P_managedToken;
|
|
@@ -124,15 +159,62 @@ async function encodeSettingsToSetupURI(settingString, passphrase, removePropert
|
|
|
124
159
|
const uri = `${configURIBase}${encryptedSetting} `;
|
|
125
160
|
return uri;
|
|
126
161
|
}
|
|
162
|
+
async function encodeTimeBoundSetupURI(settingString, passphrase, options = {}) {
|
|
163
|
+
const { mode = "ephemeral", removeProperties, skipDefaultValue } = options;
|
|
164
|
+
if (mode === "persistent") {
|
|
165
|
+
return {
|
|
166
|
+
uri: await encodeSettingsToSetupURI(settingString, passphrase, removeProperties, skipDefaultValue),
|
|
167
|
+
usableUntil: null
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (mode !== "ephemeral") throw new Error("Invalid Setup URI mode");
|
|
171
|
+
const window = setupURIWindow(Date.now());
|
|
172
|
+
const effectivePassphrase = await ephemeralSetupURIPassphrase(passphrase, window);
|
|
173
|
+
const uri = await encodeSettingsToSetupURI(settingString, effectivePassphrase, removeProperties, skipDefaultValue);
|
|
174
|
+
if (setupURIWindow(Date.now()) !== window) throw new Error("Setup URI window changed during generation");
|
|
175
|
+
return { uri, usableUntil: (window + 1) * SETUP_URI_WINDOW_MS };
|
|
176
|
+
}
|
|
127
177
|
async function decodeSettingsFromSetupURI(uri, passphrase) {
|
|
128
178
|
const encryptedSetting = uri.substring(configURIBase.length);
|
|
129
|
-
const
|
|
179
|
+
const encrypted = decodeURIComponent(encryptedSetting);
|
|
180
|
+
let decrypted;
|
|
181
|
+
if (encrypted.startsWith("%$")) {
|
|
182
|
+
let window;
|
|
183
|
+
try {
|
|
184
|
+
window = setupURIWindow(Date.now());
|
|
185
|
+
} catch {
|
|
186
|
+
}
|
|
187
|
+
const matches = [];
|
|
188
|
+
if (window !== void 0) {
|
|
189
|
+
const candidate = await ephemeralSetupURIPassphrase(passphrase, window);
|
|
190
|
+
try {
|
|
191
|
+
matches.push({ mode: "ephemeral", value: await decryptString(encrypted, candidate) });
|
|
192
|
+
} catch {
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
matches.push({ mode: "persistent", value: await decryptString(encrypted, passphrase) });
|
|
197
|
+
} catch {
|
|
198
|
+
}
|
|
199
|
+
if (matches.length !== 1) throw new Error("Cannot open Setup URI");
|
|
200
|
+
const matched = matches[0];
|
|
201
|
+
if (matched.mode === "ephemeral" && setupURIWindow(Date.now()) !== window) {
|
|
202
|
+
throw new Error("Cannot open Setup URI");
|
|
203
|
+
}
|
|
204
|
+
decrypted = matched.value;
|
|
205
|
+
} else {
|
|
206
|
+
decrypted = await decryptString(encrypted, passphrase);
|
|
207
|
+
}
|
|
130
208
|
try {
|
|
131
209
|
const imported = JSON.parse(decrypted);
|
|
132
|
-
|
|
210
|
+
const settings = {
|
|
133
211
|
...imported,
|
|
134
|
-
encryptInternalMetadata: typeof imported.encryptInternalMetadata === "boolean" ? imported.encryptInternalMetadata : false
|
|
135
|
-
|
|
212
|
+
encryptInternalMetadata: typeof imported.encryptInternalMetadata === "boolean" ? imported.encryptInternalMetadata : false,
|
|
213
|
+
idDerivationVersion: imported.idDerivationVersion ?? 0,
|
|
214
|
+
idDerivationKey: imported.idDerivationKey ?? ""
|
|
215
|
+
};
|
|
216
|
+
configuredIdKey(settings);
|
|
217
|
+
return omitP2PRuntimeSettings(settings);
|
|
136
218
|
} catch {
|
|
137
219
|
Logger(`Failed to parse settings from decrypted data`, LOG_LEVEL_NOTICE);
|
|
138
220
|
return false;
|
|
@@ -144,5 +226,8 @@ export {
|
|
|
144
226
|
decodeSettingsFromSetupURI,
|
|
145
227
|
encodeQR,
|
|
146
228
|
encodeSettingsToQRCodeData,
|
|
147
|
-
encodeSettingsToSetupURI
|
|
229
|
+
encodeSettingsToSetupURI,
|
|
230
|
+
encodeTimeBoundSetupURI,
|
|
231
|
+
getTimeBoundSetupURIUsableUntil,
|
|
232
|
+
isTimeBoundSetupURIUsableNow
|
|
148
233
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EncryptionSettings } from "./models/setting.type.js";
|
|
2
|
+
export declare const ID_DERIVATION_VERSION = 1;
|
|
3
|
+
export declare const ID_RECOVERY_CODE_PREFIX = "sls-id-v1:";
|
|
4
|
+
/** Derive a portable key once, when the source string is saved. */
|
|
5
|
+
export declare function deriveIdKey(source: string): Promise<string>;
|
|
6
|
+
/** Export a saved key in a format which can be imported without deriving it again. */
|
|
7
|
+
export declare function formatIdRecoveryCode(key: string): string;
|
|
8
|
+
/** Treat a tagged recovery code as a saved key; derive ordinary source strings. */
|
|
9
|
+
export declare function deriveOrImportIdKey(input: string): Promise<string>;
|
|
10
|
+
/** Return the independent ID key, rejecting an incomplete new-mode setting. */
|
|
11
|
+
export declare function configuredIdKey(settings: Pick<EncryptionSettings, "idDerivationVersion" | "idDerivationKey">): string | false;
|
|
12
|
+
/** Derive distinct identifiers for content and document paths from one saved key. */
|
|
13
|
+
export declare function computeKeyedId(key: string, purpose: "chunk" | "document" | "peer-agreement" | "remote-agreement", value: string): Promise<string>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { getWebCrypto } from "../mods.js";
|
|
2
|
+
import { hexStringToUint8Array, uint8ArrayToHexString } from "../string_and_binary/convert.js";
|
|
3
|
+
const ID_DERIVATION_VERSION = 1;
|
|
4
|
+
const ID_RECOVERY_CODE_PREFIX = "sls-id-v1:";
|
|
5
|
+
const SOURCE_SALT = new TextEncoder().encode("self-hosted-livesync:id-source:v1");
|
|
6
|
+
const SOURCE_ITERATIONS = 31e4;
|
|
7
|
+
const HEX_KEY = /^[0-9a-f]{64}$/u;
|
|
8
|
+
async function deriveIdKey(source) {
|
|
9
|
+
if (source.length === 0) {
|
|
10
|
+
throw new Error("An ID source is required.");
|
|
11
|
+
}
|
|
12
|
+
const crypto = await getWebCrypto();
|
|
13
|
+
const sourceKey = await crypto.subtle.importKey(
|
|
14
|
+
"raw",
|
|
15
|
+
new TextEncoder().encode(source.normalize("NFC")),
|
|
16
|
+
"PBKDF2",
|
|
17
|
+
false,
|
|
18
|
+
["deriveBits"]
|
|
19
|
+
);
|
|
20
|
+
const bits = await crypto.subtle.deriveBits(
|
|
21
|
+
{ name: "PBKDF2", hash: "SHA-256", salt: SOURCE_SALT, iterations: SOURCE_ITERATIONS },
|
|
22
|
+
sourceKey,
|
|
23
|
+
256
|
|
24
|
+
);
|
|
25
|
+
return uint8ArrayToHexString(new Uint8Array(bits));
|
|
26
|
+
}
|
|
27
|
+
function formatIdRecoveryCode(key) {
|
|
28
|
+
if (!HEX_KEY.test(key)) throw new Error("The configured ID key is invalid.");
|
|
29
|
+
return `${ID_RECOVERY_CODE_PREFIX}${key}`;
|
|
30
|
+
}
|
|
31
|
+
async function deriveOrImportIdKey(input) {
|
|
32
|
+
const candidate = input.trim();
|
|
33
|
+
if (candidate.startsWith(ID_RECOVERY_CODE_PREFIX)) {
|
|
34
|
+
const key = candidate.slice(ID_RECOVERY_CODE_PREFIX.length);
|
|
35
|
+
if (!HEX_KEY.test(key)) throw new Error("The ID recovery code is invalid.");
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
if (candidate.startsWith("sls-id-v")) throw new Error("The ID recovery code version is unsupported.");
|
|
39
|
+
return await deriveIdKey(input);
|
|
40
|
+
}
|
|
41
|
+
function configuredIdKey(settings) {
|
|
42
|
+
const version = settings.idDerivationVersion;
|
|
43
|
+
const key = settings.idDerivationKey;
|
|
44
|
+
if (version === 0 || version === void 0) {
|
|
45
|
+
if (key) throw new Error("An ID key requires a supported derivation version.");
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (version !== ID_DERIVATION_VERSION || !HEX_KEY.test(key)) {
|
|
49
|
+
throw new Error("The configured ID derivation is unavailable or invalid.");
|
|
50
|
+
}
|
|
51
|
+
return key;
|
|
52
|
+
}
|
|
53
|
+
async function computeKeyedId(key, purpose, value) {
|
|
54
|
+
if (!HEX_KEY.test(key)) throw new Error("The configured ID key is invalid.");
|
|
55
|
+
const crypto = await getWebCrypto();
|
|
56
|
+
const hmacKey = await crypto.subtle.importKey(
|
|
57
|
+
"raw",
|
|
58
|
+
hexStringToUint8Array(key),
|
|
59
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
60
|
+
false,
|
|
61
|
+
["sign"]
|
|
62
|
+
);
|
|
63
|
+
const input = new TextEncoder().encode(`self-hosted-livesync:id-v1:${purpose}\0${value}`);
|
|
64
|
+
const digest = await crypto.subtle.sign("HMAC", hmacKey, input);
|
|
65
|
+
return uint8ArrayToHexString(new Uint8Array(digest));
|
|
66
|
+
}
|
|
67
|
+
export {
|
|
68
|
+
ID_DERIVATION_VERSION,
|
|
69
|
+
ID_RECOVERY_CODE_PREFIX,
|
|
70
|
+
computeKeyedId,
|
|
71
|
+
configuredIdKey,
|
|
72
|
+
deriveIdKey,
|
|
73
|
+
deriveOrImportIdKey,
|
|
74
|
+
formatIdRecoveryCode
|
|
75
|
+
};
|
|
@@ -56,6 +56,8 @@ const SETTINGS_SCHEMA_DEFAULTS = {
|
|
|
56
56
|
syncOnFileOpen: false,
|
|
57
57
|
encrypt: false,
|
|
58
58
|
passphrase: "",
|
|
59
|
+
idDerivationVersion: 0,
|
|
60
|
+
idDerivationKey: "",
|
|
59
61
|
usePathObfuscation: false,
|
|
60
62
|
encryptInternalMetadata: false,
|
|
61
63
|
doNotDeleteFolder: false,
|
|
@@ -104,6 +106,7 @@ const SETTINGS_SCHEMA_DEFAULTS = {
|
|
|
104
106
|
syncAfterMerge: false,
|
|
105
107
|
configPassphraseStore: "",
|
|
106
108
|
encryptedPassphrase: "",
|
|
109
|
+
encryptedIdDerivationKey: "",
|
|
107
110
|
encryptedCouchDBConnection: "",
|
|
108
111
|
permitEmptyPassphrase: false,
|
|
109
112
|
remoteConfigurations: {},
|
|
@@ -177,7 +177,10 @@ const KeyIndexOfSettings = {
|
|
|
177
177
|
// Do not encode into the QR Code.
|
|
178
178
|
P2P_maxWirePayloadBytes: 163,
|
|
179
179
|
P2P_connectionPath: 164,
|
|
180
|
-
encryptInternalMetadata: 165
|
|
180
|
+
encryptInternalMetadata: 165,
|
|
181
|
+
idDerivationVersion: 166,
|
|
182
|
+
idDerivationKey: 167,
|
|
183
|
+
encryptedIdDerivationKey: -1
|
|
181
184
|
};
|
|
182
185
|
export {
|
|
183
186
|
KeyIndexOfSettings
|
|
@@ -65,6 +65,8 @@ interface EncryptedUserSettings {
|
|
|
65
65
|
* The encrypted passphrase used for E2EE.
|
|
66
66
|
*/
|
|
67
67
|
encryptedPassphrase: string;
|
|
68
|
+
/** Encrypted copy of the independent ID key in local persisted settings. */
|
|
69
|
+
encryptedIdDerivationKey: string;
|
|
68
70
|
/**
|
|
69
71
|
* The encrypted connection details for CouchDB.
|
|
70
72
|
*/
|
|
@@ -585,6 +587,10 @@ export interface EncryptionSettings {
|
|
|
585
587
|
* The passphrase used for E2EE.
|
|
586
588
|
*/
|
|
587
589
|
passphrase: string;
|
|
590
|
+
/** Zero preserves the historical passphrase-derived ID scheme. */
|
|
591
|
+
idDerivationVersion: 0 | 1;
|
|
592
|
+
/** A 256-bit key encoded as lower-case hex; never the source string. */
|
|
593
|
+
idDerivationKey: string;
|
|
588
594
|
/**
|
|
589
595
|
* Indicates whether path obfuscation is used.
|
|
590
596
|
* If not, the path will be stored as it is, as the document ID.
|
|
@@ -46,7 +46,7 @@ function transition(entries, sourceSide, targetSide) {
|
|
|
46
46
|
const effectiveValueDiffers = source.effectiveValue !== target.effectiveValue;
|
|
47
47
|
const shouldFillMissingTarget = !targetAdvertised && sourceAdvertised;
|
|
48
48
|
if (source.effectiveValue === void 0 || !effectiveValueDiffers && !shouldFillMissingTarget) continue;
|
|
49
|
-
changes[entry.key] = source.effectiveValue;
|
|
49
|
+
if (entry.key !== "idDerivationVersion") changes[entry.key] = source.effectiveValue;
|
|
50
50
|
if (entry.relation === "equal") continue;
|
|
51
51
|
const effect = reconstructionForTransition(entry.key, target.effectiveValue, source.effectiveValue);
|
|
52
52
|
if (effect === "none") continue;
|
|
@@ -67,6 +67,8 @@ function assessTweakCompatibility(current, preferred) {
|
|
|
67
67
|
const currentValues = snapshotTweakValues(current);
|
|
68
68
|
const preferredValues = snapshotTweakValues(preferred);
|
|
69
69
|
let representationDiffers = false;
|
|
70
|
+
const currentUsesKeyedDocumentIds = currentValues.encrypt !== false && currentValues.usePathObfuscation !== false;
|
|
71
|
+
const preferredUsesKeyedDocumentIds = preferredValues.encrypt !== false && preferredValues.usePathObfuscation !== false;
|
|
70
72
|
const entries = TweakCompatibilityRules.map((rule) => {
|
|
71
73
|
const knownDefault = "knownDefault" in rule ? rule.knownDefault : void 0;
|
|
72
74
|
const currentValue = assessedValue(currentValues, rule.key, knownDefault);
|
|
@@ -74,7 +76,9 @@ function assessTweakCompatibility(current, preferred) {
|
|
|
74
76
|
const currentAdvertised = currentValue.present && currentValue.rawValue !== void 0;
|
|
75
77
|
const preferredAdvertised = preferredValue.present && preferredValue.rawValue !== void 0;
|
|
76
78
|
let relation;
|
|
77
|
-
if (
|
|
79
|
+
if (rule.key === "idDerivationVersion" && !currentUsesKeyedDocumentIds && !preferredUsesKeyedDocumentIds) {
|
|
80
|
+
relation = "equal";
|
|
81
|
+
} else if ((!currentAdvertised || !preferredAdvertised) && knownDefault === void 0) {
|
|
78
82
|
relation = "unadvertised";
|
|
79
83
|
} else {
|
|
80
84
|
relation = currentValue.effectiveValue === preferredValue.effectiveValue ? "equal" : "different";
|
|
@@ -20,6 +20,11 @@ export declare const TweakCompatibilityRules: readonly [{
|
|
|
20
20
|
readonly key: "usePathObfuscation";
|
|
21
21
|
readonly templateValue: false;
|
|
22
22
|
readonly reconstruction: "required";
|
|
23
|
+
}, {
|
|
24
|
+
readonly key: "idDerivationVersion";
|
|
25
|
+
readonly templateValue: 0;
|
|
26
|
+
readonly reconstruction: "required";
|
|
27
|
+
readonly knownDefault: 0;
|
|
23
28
|
}, {
|
|
24
29
|
readonly key: "encryptInternalMetadata";
|
|
25
30
|
readonly templateValue: false;
|
|
@@ -112,6 +117,7 @@ export declare const TweakValuesTemplate: {
|
|
|
112
117
|
tweakModified: number;
|
|
113
118
|
usePluginSyncV2: boolean;
|
|
114
119
|
encrypt: boolean;
|
|
120
|
+
idDerivationVersion: 0 | 1;
|
|
115
121
|
usePathObfuscation: boolean;
|
|
116
122
|
encryptInternalMetadata: boolean;
|
|
117
123
|
E2EEAlgorithm: import("./setting.type.js").E2EEAlgorithm;
|
|
@@ -164,6 +170,7 @@ export declare const TweakValuesTemplate: {
|
|
|
164
170
|
disableMarkdownAutoMerge?: boolean;
|
|
165
171
|
configPassphraseStore?: import("./setting.type.js").ConfigPassphraseStore;
|
|
166
172
|
encryptedPassphrase?: string;
|
|
173
|
+
encryptedIdDerivationKey?: string;
|
|
167
174
|
encryptedCouchDBConnection?: string;
|
|
168
175
|
periodicReplication?: boolean;
|
|
169
176
|
periodicReplicationInterval?: number;
|
|
@@ -234,6 +241,7 @@ export declare const TweakValuesTemplate: {
|
|
|
234
241
|
forcePathStyle?: boolean;
|
|
235
242
|
remoteType?: import("./setting.type.js").RemoteType;
|
|
236
243
|
passphrase?: string;
|
|
244
|
+
idDerivationKey?: string;
|
|
237
245
|
doNotUseFixedRevisionForChunks?: boolean;
|
|
238
246
|
checkIntegrityOnSave?: boolean;
|
|
239
247
|
useHistory?: boolean;
|
|
@@ -5,6 +5,7 @@ const TweakCompatibilityRules = Object.freeze([
|
|
|
5
5
|
{ key: "longLineThreshold", templateValue: 250, reconstruction: "none" },
|
|
6
6
|
{ key: "encrypt", templateValue: false, reconstruction: "required" },
|
|
7
7
|
{ key: "usePathObfuscation", templateValue: false, reconstruction: "required" },
|
|
8
|
+
{ key: "idDerivationVersion", templateValue: 0, reconstruction: "required", knownDefault: 0 },
|
|
8
9
|
{ key: "encryptInternalMetadata", templateValue: false, reconstruction: "none", knownDefault: false },
|
|
9
10
|
{ key: "enableCompression", templateValue: false, reconstruction: "none" },
|
|
10
11
|
{ key: "useEden", templateValue: false, reconstruction: "none" },
|
|
@@ -81,9 +82,9 @@ const TweakValuesRecommendedTemplate = {
|
|
|
81
82
|
};
|
|
82
83
|
const TweakValuesDefault = {
|
|
83
84
|
...Object.fromEntries(
|
|
84
|
-
TweakCompatibilityRules.filter(
|
|
85
|
-
(
|
|
86
|
-
)
|
|
85
|
+
TweakCompatibilityRules.filter(
|
|
86
|
+
(rule) => "knownDefault" in rule && rule.key !== "handleFilenameCaseSensitive"
|
|
87
|
+
).map(({ key, knownDefault }) => [key, knownDefault])
|
|
87
88
|
),
|
|
88
89
|
tweakModified: DEFAULT_SETTINGS.tweakModified
|
|
89
90
|
};
|
|
@@ -64,6 +64,7 @@ export declare const AllSettingDefault: {
|
|
|
64
64
|
disableMarkdownAutoMerge: boolean;
|
|
65
65
|
configPassphraseStore: import("./types.js").ConfigPassphraseStore;
|
|
66
66
|
encryptedPassphrase: string;
|
|
67
|
+
encryptedIdDerivationKey: string;
|
|
67
68
|
encryptedCouchDBConnection: string;
|
|
68
69
|
periodicReplication: boolean;
|
|
69
70
|
periodicReplicationInterval: number;
|
|
@@ -135,6 +136,8 @@ export declare const AllSettingDefault: {
|
|
|
135
136
|
remoteType: import("./types.js").RemoteType;
|
|
136
137
|
encrypt: boolean;
|
|
137
138
|
passphrase: string;
|
|
139
|
+
idDerivationVersion: 0 | 1;
|
|
140
|
+
idDerivationKey: string;
|
|
138
141
|
usePathObfuscation: boolean;
|
|
139
142
|
encryptInternalMetadata: boolean;
|
|
140
143
|
E2EEAlgorithm: import("./types.js").E2EEAlgorithm;
|
package/dist/common/utils.js
CHANGED
|
@@ -449,7 +449,9 @@ function pickEncryptionSettings(setting) {
|
|
|
449
449
|
encrypt: setting.encrypt,
|
|
450
450
|
passphrase: setting.passphrase,
|
|
451
451
|
usePathObfuscation: setting.usePathObfuscation,
|
|
452
|
-
encryptInternalMetadata: setting.encryptInternalMetadata
|
|
452
|
+
encryptInternalMetadata: setting.encryptInternalMetadata,
|
|
453
|
+
idDerivationVersion: setting.idDerivationVersion,
|
|
454
|
+
idDerivationKey: setting.idDerivationKey
|
|
453
455
|
};
|
|
454
456
|
}
|
|
455
457
|
function pickP2PSyncSettings(setting) {
|
|
@@ -7,6 +7,8 @@ export declare const EVENT_FILE_RENAMED = "file-renamed";
|
|
|
7
7
|
export declare const EVENT_FILE_SAVED = "file-saved";
|
|
8
8
|
export declare const EVENT_LEAF_ACTIVE_CHANGED = "leaf-active-changed";
|
|
9
9
|
export declare const EVENT_DATABASE_REBUILT = "database-rebuilt";
|
|
10
|
+
/** Emitted by `AppLifecycleService.markIsReady()` each time it establishes application readiness. */
|
|
11
|
+
export declare const EVENT_APPLICATION_READY = "application-ready";
|
|
10
12
|
export declare const EVENT_LOG_ADDED = "log-added";
|
|
11
13
|
export declare const EVENT_REQUEST_OPEN_SETUP_URI = "request-open-setup-uri";
|
|
12
14
|
export declare const EVENT_REQUEST_COPY_SETUP_URI = "request-copy-setup-uri";
|
|
@@ -37,6 +39,7 @@ declare global {
|
|
|
37
39
|
old: FilePathWithPrefix;
|
|
38
40
|
};
|
|
39
41
|
[EVENT_DATABASE_REBUILT]: undefined;
|
|
42
|
+
[EVENT_APPLICATION_READY]: undefined;
|
|
40
43
|
[EVENT_REQUEST_OPEN_P2P_SETTINGS]: undefined;
|
|
41
44
|
[EVENT_LEAF_ACTIVE_CHANGED]: undefined;
|
|
42
45
|
[EVENT_REQUEST_OPEN_SETUP_URI]: undefined;
|
|
@@ -6,6 +6,7 @@ const EVENT_FILE_RENAMED = "file-renamed";
|
|
|
6
6
|
const EVENT_FILE_SAVED = "file-saved";
|
|
7
7
|
const EVENT_LEAF_ACTIVE_CHANGED = "leaf-active-changed";
|
|
8
8
|
const EVENT_DATABASE_REBUILT = "database-rebuilt";
|
|
9
|
+
const EVENT_APPLICATION_READY = "application-ready";
|
|
9
10
|
const EVENT_LOG_ADDED = "log-added";
|
|
10
11
|
const EVENT_REQUEST_OPEN_SETUP_URI = "request-open-setup-uri";
|
|
11
12
|
const EVENT_REQUEST_COPY_SETUP_URI = "request-copy-setup-uri";
|
|
@@ -21,6 +22,7 @@ const EVENT_ON_UNRESOLVED_ERROR = "on-unresolved-error";
|
|
|
21
22
|
const EVENT_REQUEST_CHECK_REMOTE_SIZE = "request-check-remote-size";
|
|
22
23
|
const EVENT_CONFLICT_CANCELLED = "conflict-cancelled";
|
|
23
24
|
export {
|
|
25
|
+
EVENT_APPLICATION_READY,
|
|
24
26
|
EVENT_CONFLICT_CANCELLED,
|
|
25
27
|
EVENT_DATABASE_REBUILT,
|
|
26
28
|
EVENT_FILE_CHANGED,
|
|
@@ -228,6 +228,11 @@ function isTargetFile(host, filenameSrc) {
|
|
|
228
228
|
return true;
|
|
229
229
|
}
|
|
230
230
|
async function prepareChunk({ chunkManager, hashManager }, piece) {
|
|
231
|
+
if (hashManager.usesIndependentIdKey()) {
|
|
232
|
+
const id = `${IDPrefixes.Chunk}${await hashManager.computeHash(piece)}`;
|
|
233
|
+
const cached = chunkManager.getCachedChunk(id);
|
|
234
|
+
return { isNew: !cached || cached.data !== piece, id, piece };
|
|
235
|
+
}
|
|
231
236
|
const cachedChunkId = chunkManager.getChunkIDFromCache(piece);
|
|
232
237
|
if (cachedChunkId !== false) {
|
|
233
238
|
return { isNew: false, id: cachedChunkId, piece };
|
|
@@ -387,7 +392,11 @@ async function respondEntryFromMeta({ localDatabase, chunkManager }, settings, f
|
|
|
387
392
|
}));
|
|
388
393
|
edenChunks = Object.fromEntries(chunks2.map((e) => [e._id, e]));
|
|
389
394
|
}
|
|
390
|
-
const { waitForDelivery, preventRemoteRequest } = computeChunkRetrievalMethod(
|
|
395
|
+
const { waitForDelivery, preventRemoteRequest } = computeChunkRetrievalMethod(
|
|
396
|
+
waitForReady,
|
|
397
|
+
settings,
|
|
398
|
+
localOnly
|
|
399
|
+
);
|
|
391
400
|
const childrenKeys = [...meta.children];
|
|
392
401
|
const chunks = await chunkManager.read(
|
|
393
402
|
childrenKeys,
|
|
@@ -43,6 +43,7 @@ export declare class HashManager extends HashManagerCore {
|
|
|
43
43
|
* @returns The hash value (returned as a Promise)
|
|
44
44
|
*/
|
|
45
45
|
computeHash(piece: string): Promise<string>;
|
|
46
|
+
usesIndependentIdKey(): boolean;
|
|
46
47
|
/**
|
|
47
48
|
* Computes the hash value without encryption.
|
|
48
49
|
*
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { FallbackWasmHashManager, XXHash32RawHashManager, XXHash64HashManager } from "./XXHashHashManager.js";
|
|
2
2
|
import { FallbackPureJSHashManager, PureJSHashManager, SHA1HashManager } from "./PureJSHashManager.js";
|
|
3
|
-
import { HashManagerCore } from "./HashManagerCore.js";
|
|
3
|
+
import { HashEncryptedPrefix, HashManagerCore } from "./HashManagerCore.js";
|
|
4
4
|
import { LOG_LEVEL_VERBOSE, Logger } from "../../common/logger.js";
|
|
5
|
+
import { computeKeyedId, configuredIdKey } from "../../common/idDerivation.js";
|
|
5
6
|
const HashManagers = [
|
|
6
7
|
XXHash64HashManager,
|
|
7
8
|
XXHash32RawHashManager,
|
|
@@ -71,8 +72,17 @@ class HashManager extends HashManagerCore {
|
|
|
71
72
|
* @returns The hash value (returned as a Promise)
|
|
72
73
|
*/
|
|
73
74
|
async computeHash(piece) {
|
|
75
|
+
const settings = this.options.settingService.currentSettings();
|
|
76
|
+
const key = configuredIdKey(settings);
|
|
77
|
+
if (settings.encrypt && key) {
|
|
78
|
+
return HashEncryptedPrefix + await computeKeyedId(key, "chunk", piece);
|
|
79
|
+
}
|
|
74
80
|
return await this.manager.computeHash(piece);
|
|
75
81
|
}
|
|
82
|
+
usesIndependentIdKey() {
|
|
83
|
+
const settings = this.options.settingService.currentSettings();
|
|
84
|
+
return settings.encrypt && configuredIdKey(settings) !== false;
|
|
85
|
+
}
|
|
76
86
|
/**
|
|
77
87
|
* Computes the hash value without encryption.
|
|
78
88
|
*
|
|
@@ -89,6 +99,8 @@ class HashManager extends HashManagerCore {
|
|
|
89
99
|
* @returns The hash value (returned as a Promise)
|
|
90
100
|
*/
|
|
91
101
|
computeHashWithEncryption(piece) {
|
|
102
|
+
const key = configuredIdKey(this.options.settingService.currentSettings());
|
|
103
|
+
if (key) return computeKeyedId(key, "chunk", piece);
|
|
92
104
|
return this.manager.computeHashWithEncryption(piece);
|
|
93
105
|
}
|
|
94
106
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type EntryDoc, type EntryMilestoneInfo, type RemoteDBSettings, type ChunkVersionRange, type TweakValues, type DeviceInfo, type TweakAssessment } from "../common/types.js";
|
|
2
2
|
export declare function getEffectiveTweakValues(setting: RemoteDBSettings): TweakValues;
|
|
3
|
-
export type ENSURE_DB_RESULT = "OK" | "INCOMPATIBLE" | "LOCKED" | "NODE_LOCKED" | "NODE_CLEANED" | ["MISMATCHED", TweakValues];
|
|
3
|
+
export type ENSURE_DB_RESULT = "OK" | "INCOMPATIBLE" | "ID_KEY_MISMATCH" | "LOCKED" | "NODE_LOCKED" | "NODE_CLEANED" | ["MISMATCHED", TweakValues];
|
|
4
4
|
/**
|
|
5
5
|
* Ensures that the remote database is compatible with the current device.
|
|
6
6
|
*
|