mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-24 18:13:20 +00:00
Add independent ID key configuration and recovery
This commit is contained in:
1 parent
126d6eadb8
commit
bf7822f20b
51 files changed
+2572
-168
No files matched your search
@@ -13,13 +13,26 @@
|
||||
E2EEAlgorithms,
|
||||
type EncryptionSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
deriveIdKey,
|
||||
deriveOrImportIdKey,
|
||||
formatIdRecoveryCode,
|
||||
ID_DERIVATION_VERSION,
|
||||
ID_RECOVERY_CODE_PREFIX,
|
||||
} from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { onMount } from "svelte";
|
||||
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import { copyTo, pickEncryptionSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { TYPE_CANCELLED, type SetupRemoteE2EEResultType } from "./setupDialogTypes";
|
||||
import {
|
||||
TYPE_CANCELLED,
|
||||
type SetupRemoteE2EEInitialData,
|
||||
type SetupRemoteE2EEResultType,
|
||||
} from "./setupDialogTypes";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
type Props = GuestDialogProps<SetupRemoteE2EEResultType, EncryptionSettings>;
|
||||
type Props = GuestDialogProps<SetupRemoteE2EEResultType, SetupRemoteE2EEInitialData>;
|
||||
type IdConfigurationChoice = "keep" | "random" | "custom";
|
||||
type IdCustomChoice = "passphrase" | "source" | "recovery";
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
let default_encryption: EncryptionSettings = {
|
||||
encrypt: true,
|
||||
@@ -27,17 +40,37 @@
|
||||
E2EEAlgorithm: DEFAULT_SETTINGS.E2EEAlgorithm,
|
||||
usePathObfuscation: true,
|
||||
encryptInternalMetadata: true,
|
||||
idDerivationVersion: 0,
|
||||
idDerivationKey: "",
|
||||
};
|
||||
|
||||
let encryptionSettings = $state<EncryptionSettings>({ ...default_encryption });
|
||||
let newVault = $state(false);
|
||||
let idConfigurationChoice = $state<IdConfigurationChoice>("keep");
|
||||
let idCustomChoice = $state<IdCustomChoice>("source");
|
||||
let idDerivationSource = $state("");
|
||||
let idDerivationError = $state("");
|
||||
let recoveryCodeVisible = $state(false);
|
||||
let recoveryCodeCopied = $state(false);
|
||||
|
||||
const idDerivationConfigured = $derived(
|
||||
encryptionSettings.idDerivationVersion === ID_DERIVATION_VERSION &&
|
||||
typeof encryptionSettings.idDerivationKey === "string" &&
|
||||
encryptionSettings.idDerivationKey.length > 0
|
||||
);
|
||||
const recoveryCode = $derived.by(() =>
|
||||
idDerivationConfigured ? formatIdRecoveryCode(encryptionSettings.idDerivationKey) : ""
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
if (getInitialData) {
|
||||
const initialData = getInitialData();
|
||||
if (initialData) {
|
||||
copyTo(initialData, encryptionSettings);
|
||||
copyTo(initialData.settings, encryptionSettings);
|
||||
newVault = initialData.newVault;
|
||||
}
|
||||
}
|
||||
idConfigurationChoice = !idDerivationConfigured && newVault ? "random" : "keep";
|
||||
});
|
||||
let e2eeValid = $derived.by(() => {
|
||||
if (!encryptionSettings.encrypt) return true;
|
||||
@@ -49,8 +82,75 @@
|
||||
encryptionSettings.usePathObfuscation
|
||||
);
|
||||
|
||||
function commit() {
|
||||
setResult(pickEncryptionSettings(encryptionSettings));
|
||||
function resetIdDerivationSource() {
|
||||
idDerivationSource = "";
|
||||
idDerivationError = "";
|
||||
}
|
||||
|
||||
function toggleEncryption(enabled: boolean) {
|
||||
encryptionSettings.encrypt = enabled;
|
||||
if (!enabled) resetIdDerivationSource();
|
||||
}
|
||||
|
||||
function selectIdConfiguration() {
|
||||
recoveryCodeVisible = false;
|
||||
recoveryCodeCopied = false;
|
||||
resetIdDerivationSource();
|
||||
}
|
||||
|
||||
function selectIdCustomSource() {
|
||||
resetIdDerivationSource();
|
||||
}
|
||||
|
||||
async function copyRecoveryCode() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(recoveryCode);
|
||||
recoveryCodeCopied = true;
|
||||
} catch {
|
||||
idDerivationError = translateMessage("The recovery code could not be copied. Select and copy the visible code instead.");
|
||||
}
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
idDerivationError = "";
|
||||
const result = pickEncryptionSettings(encryptionSettings);
|
||||
|
||||
if (encryptionSettings.encrypt && idConfigurationChoice !== "keep") {
|
||||
let source = idDerivationSource;
|
||||
if (idConfigurationChoice === "random") {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
source = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
} else if (idCustomChoice === "passphrase") {
|
||||
source = encryptionSettings.passphrase;
|
||||
}
|
||||
if (source.length === 0) {
|
||||
if (!idDerivationConfigured) {
|
||||
idDerivationError = translateMessage("An ID source is required to enable this option.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
result.idDerivationKey =
|
||||
idConfigurationChoice === "custom" && idCustomChoice !== "passphrase"
|
||||
? await importOrDeriveEnteredIdKey(source, idCustomChoice)
|
||||
: await deriveIdKey(source);
|
||||
result.idDerivationVersion = ID_DERIVATION_VERSION;
|
||||
} catch {
|
||||
idDerivationError = translateMessage("The ID source or recovery code is invalid. Check it and try again.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
idDerivationSource = "";
|
||||
setResult(result);
|
||||
}
|
||||
|
||||
async function importOrDeriveEnteredIdKey(source: string, choice: IdCustomChoice): Promise<string> {
|
||||
if (choice === "recovery" && !source.trim().startsWith(ID_RECOVERY_CODE_PREFIX)) {
|
||||
throw new Error("An ID recovery code is required.");
|
||||
}
|
||||
return await deriveOrImportIdKey(source);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -58,7 +158,11 @@
|
||||
<DialogHeader title={translateMessage("End-to-End Encryption")} />
|
||||
<Guidance>{translateMessage("Please configure your end-to-end encryption settings.")}</Guidance>
|
||||
<InputRow label={translateMessage("End-to-End Encryption")}>
|
||||
<input type="checkbox" bind:checked={encryptionSettings.encrypt} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={encryptionSettings.encrypt}
|
||||
onchange={(event) => toggleEncryption(event.currentTarget.checked)}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote title={translateMessage("Strongly Recommended")}>
|
||||
{translateMessage(
|
||||
@@ -93,6 +197,164 @@
|
||||
</InfoNote>
|
||||
{/if}
|
||||
|
||||
<fieldset class="sls-id-choices" disabled={!encryptionSettings.encrypt}>
|
||||
<legend>{translateMessage("ID generation")}</legend>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-derivation-choice"
|
||||
value="keep"
|
||||
bind:group={idConfigurationChoice}
|
||||
onchange={selectIdConfiguration}
|
||||
/>
|
||||
<div class="sls-id-choice-text">
|
||||
<span>{translateMessage("Keep current configuration")}</span>
|
||||
<small class="sls-current-id-configuration">
|
||||
{#if idDerivationConfigured}
|
||||
{translateMessage(
|
||||
encryptionSettings.encrypt
|
||||
? "Current configuration: a saved ID key is used."
|
||||
: "Current configuration: the saved ID key is retained while E2EE is off."
|
||||
)}
|
||||
{:else}
|
||||
{translateMessage(
|
||||
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase."
|
||||
)}
|
||||
{/if}
|
||||
</small>
|
||||
</div>
|
||||
</label>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-derivation-choice"
|
||||
value="random"
|
||||
bind:group={idConfigurationChoice}
|
||||
onchange={selectIdConfiguration}
|
||||
/>
|
||||
<span>{translateMessage("Generate a random ID key")}</span>
|
||||
</label>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-derivation-choice"
|
||||
value="custom"
|
||||
bind:group={idConfigurationChoice}
|
||||
onchange={selectIdConfiguration}
|
||||
/>
|
||||
<span>{translateMessage("Set an ID key")}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
{#if encryptionSettings.encrypt && idConfigurationChoice === "keep" && !idDerivationConfigured}
|
||||
<InfoNote warning>
|
||||
{translateMessage("Changing the E2EE passphrase changes IDs generated by the legacy configuration.")}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{#if (encryptionSettings.encrypt && idConfigurationChoice !== "keep") || idDerivationConfigured}
|
||||
{#if encryptionSettings.encrypt}
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically."
|
||||
)}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{#if idDerivationConfigured}
|
||||
<InfoNote title={translateMessage("Configured")}>
|
||||
{translateMessage("The saved ID key is configured. Its source cannot be shown again.")}
|
||||
</InfoNote>
|
||||
<button type="button" onclick={() => (recoveryCodeVisible = !recoveryCodeVisible)}>
|
||||
{translateMessage(recoveryCodeVisible ? "Hide current recovery code" : "Show current recovery code")}
|
||||
</button>
|
||||
{#if recoveryCodeVisible}
|
||||
<InputRow label={translateMessage("Current ID recovery code")}>
|
||||
<input type="text" readonly value={recoveryCode} aria-label={translateMessage("Current ID recovery code")} />
|
||||
<button type="button" onclick={copyRecoveryCode}>{translateMessage("Copy recovery code")}</button>
|
||||
</InputRow>
|
||||
{#if recoveryCodeCopied}
|
||||
<InfoNote>{translateMessage("Recovery code copied.")}</InfoNote>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if encryptionSettings.encrypt}
|
||||
{#if idConfigurationChoice === "custom"}
|
||||
<fieldset class="sls-id-choices sls-id-custom-choices">
|
||||
<legend>{translateMessage("How to set the ID key")}</legend>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-custom-choice"
|
||||
value="passphrase"
|
||||
bind:group={idCustomChoice}
|
||||
onchange={selectIdCustomSource}
|
||||
/>
|
||||
<span>{translateMessage("Derive from current E2EE passphrase")}</span>
|
||||
</label>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-custom-choice"
|
||||
value="source"
|
||||
bind:group={idCustomChoice}
|
||||
onchange={selectIdCustomSource}
|
||||
/>
|
||||
<span>{translateMessage("Enter an ID source")}</span>
|
||||
</label>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-custom-choice"
|
||||
value="recovery"
|
||||
bind:group={idCustomChoice}
|
||||
onchange={selectIdCustomSource}
|
||||
/>
|
||||
<span>{translateMessage("Import an ID recovery code")}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
{#if idCustomChoice === "source" || idCustomChoice === "recovery"}
|
||||
<InputRow
|
||||
label={translateMessage(idCustomChoice === "source" ? "ID source" : "ID recovery code")}
|
||||
>
|
||||
<Password
|
||||
name="id-derivation-source"
|
||||
placeholder={translateMessage(
|
||||
idCustomChoice === "source" ? "Enter an ID source" : "Enter an ID recovery code"
|
||||
)}
|
||||
bind:value={idDerivationSource}
|
||||
/>
|
||||
</InputRow>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if idDerivationConfigured && idConfigurationChoice !== "keep"}
|
||||
<InfoNote>
|
||||
{translateMessage("The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.")}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{#if idConfigurationChoice === "custom" && idCustomChoice === "source"}
|
||||
<InfoNote>
|
||||
{translateMessage("Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.")}
|
||||
</InfoNote>
|
||||
{:else if idConfigurationChoice === "custom" && idCustomChoice === "recovery"}
|
||||
<InfoNote>
|
||||
{translateMessage("Paste a tagged recovery code from an existing device to restore the same ID key.")}
|
||||
</InfoNote>
|
||||
{:else if idConfigurationChoice === "random"}
|
||||
<InfoNote warning>
|
||||
{translateMessage("For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.")}
|
||||
</InfoNote>
|
||||
{:else if idConfigurationChoice === "custom" && idCustomChoice === "passphrase"}
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead."
|
||||
)}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{#if idDerivationConfigured && idConfigurationChoice === "custom" && idCustomChoice !== "passphrase"}
|
||||
<InfoNote>{translateMessage("Leave this input empty to keep the saved ID key.")}</InfoNote>
|
||||
{/if}
|
||||
{/if}
|
||||
<InfoNote error visible={idDerivationError !== ""}>{idDerivationError}</InfoNote>
|
||||
{/if}
|
||||
|
||||
<InputRow label="Encrypt internal file Properties">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -162,4 +424,41 @@
|
||||
width: auto;
|
||||
min-width: 8em;
|
||||
}
|
||||
.sls-id-choices {
|
||||
border: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35em;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.sls-id-choices legend {
|
||||
margin-bottom: 0.35em;
|
||||
}
|
||||
.sls-id-choices:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.sls-id-custom-choices {
|
||||
margin-left: 1.5em;
|
||||
}
|
||||
.sls-id-choice {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.sls-id-choice input[type="radio"] {
|
||||
flex: none;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
.sls-id-choice-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sls-current-id-configuration {
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
font-size: var(--font-ui-smaller);
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
</style>
|
||||
@@ -110,6 +110,10 @@ export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | t
|
||||
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
|
||||
|
||||
export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettings;
|
||||
export type SetupRemoteE2EEInitialData = {
|
||||
settings: EncryptionSettings;
|
||||
newVault: boolean;
|
||||
};
|
||||
|
||||
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
|
||||
|
||||
|
||||
Reference in new issue
Block a user