Add independent ID key configuration and recovery

This commit is contained in:
vorotamoroz
2026-09-29 12:58:11 +00:00
parent 126d6eadb8
commit bf7822f20b
51 changed files with 2572 additions and 168 deletions
+62
View File
@@ -26,6 +26,20 @@ Deno.test("generates an Object Storage Setup URI with a selected S3 profile", as
);
assert(decoded, "Commonlib could not decode the Object Storage Setup URI");
const effective = { ...DEFAULT_SETTINGS, ...decoded };
const recoveryCode = generated.idRecoveryCode;
assert(
typeof recoveryCode === "string" && recoveryCode.startsWith("sls-id-v1:"),
"the generator did not return an ID recovery code",
);
assert(
(effective as typeof effective & { idDerivationVersion?: number }).idDerivationVersion === 1,
"the Setup URI did not enable independent IDs",
);
assert(
(effective as typeof effective & { idDerivationKey?: string }).idDerivationKey ===
recoveryCode.slice("sls-id-v1:".length),
"the Setup URI did not contain the generated ID key",
);
assert(
effective.isConfigured,
"the Setup URI left the imported device unconfigured",
@@ -74,6 +88,11 @@ Deno.test("generates a random-room P2P Setup URI without copying a device identi
);
assert(decoded, "Commonlib could not decode the P2P Setup URI");
const effective = { ...DEFAULT_SETTINGS, ...decoded };
assert(
(effective as typeof effective & { idDerivationKey?: string }).idDerivationKey ===
generated.idRecoveryCode?.slice("sls-id-v1:".length),
"the P2P Setup URI did not contain the generated ID key",
);
assert(
/^\d{3}-\d{3}-\d{3}-[a-z0-9]{3}$/.test(effective.P2P_roomID),
"Commonlib did not generate the expected random room ID",
@@ -121,3 +140,46 @@ Deno.test("generates a random-room P2P Setup URI without copying a device identi
"the selected profile was not a P2P connection URI",
);
});
Deno.test("reuses the ID key from a recovery code and permits explicit legacy IDs", async () => {
const environment = {
remote_type: "p2p",
passphrase: "vault-secret",
uri_passphrase: "setup-secret",
};
const first = await generateSetupURI(environment);
const second = await generateSetupURI({ ...environment, id_recovery_code: first.idRecoveryCode });
const independentlyGenerated = await generateSetupURI(environment);
assert(second.idRecoveryCode === first.idRecoveryCode, "the recovery code changed on repeat generation");
assert(independentlyGenerated.idRecoveryCode !== first.idRecoveryCode, "the default ID key was reused");
const repeatedSettings = await decodeSettingsFromSetupURI(second.setupURI, second.setupPassphrase);
assert(repeatedSettings, "the repeated Setup URI could not be decoded");
assert(
(repeatedSettings as typeof repeatedSettings & { idDerivationKey?: string }).idDerivationKey ===
first.idRecoveryCode?.slice("sls-id-v1:".length),
"the recovery code did not restore the original ID key",
);
const legacy = await generateSetupURI({ ...environment, id_mode: "legacy" });
const decoded = await decodeSettingsFromSetupURI(legacy.setupURI, legacy.setupPassphrase);
assert(decoded, "the legacy Setup URI could not be decoded");
assert(legacy.idRecoveryCode === undefined, "legacy mode returned an ID recovery code");
assert(
(decoded as typeof decoded & { idDerivationVersion?: number }).idDerivationVersion !== 1,
"legacy mode enabled independent IDs",
);
let rejected = false;
try {
await generateSetupURI({ ...environment, id_recovery_code: "sls-id-v1:wrong" });
} catch {
rejected = true;
}
assert(rejected, "an invalid recovery code was accepted");
rejected = false;
try {
await generateSetupURI({ ...environment, id_mode: "legacy", id_recovery_code: first.idRecoveryCode });
} catch {
rejected = true;
}
assert(rejected, "legacy mode silently ignored a recovery code");
});
+36 -1
View File
@@ -19,6 +19,36 @@ export interface GeneratedSetupURI {
remoteType: SetupRemoteType;
setupURI: string;
setupPassphrase: string;
idRecoveryCode?: string;
}
const ID_RECOVERY_CODE_PREFIX = "sls-id-v1:";
const ID_RECOVERY_CODE_PATTERN = /^sls-id-v1:([0-9a-f]{64})$/u;
function generateRandomIdKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function configureIdDerivation(
settings: ObsidianLiveSyncSettings,
environment: SetupGeneratorEnvironment,
): string | undefined {
const mode = environment.id_mode?.trim().toLowerCase() || "random";
if (mode !== "random" && mode !== "legacy") {
throw new Error("id_mode must be random or legacy");
}
const suppliedCode = environment.id_recovery_code?.trim();
if (mode === "legacy") {
if (suppliedCode) throw new Error("id_recovery_code cannot be used with id_mode=legacy");
return undefined;
}
const key = suppliedCode
? ID_RECOVERY_CODE_PATTERN.exec(suppliedCode)?.[1]
: generateRandomIdKey();
if (!key) throw new Error("id_recovery_code must be a valid sls-id-v1 recovery code");
Object.assign(settings, { idDerivationVersion: 1, idDerivationKey: key });
return `${ID_RECOVERY_CODE_PREFIX}${key}`;
}
function requireValue(
@@ -166,11 +196,12 @@ export async function generateSetupURI(
const setupPassphrase = environment.uri_passphrase?.trim() ||
generateSecret();
const { remoteType, settings } = createSetupSettings(environment);
const idRecoveryCode = configureIdDerivation(settings, environment);
const setupURI = await encodeSettingsToSetupURI(settings, setupPassphrase, [
"pluginSyncExtendedSetting",
"doNotUseFixedRevisionForChunks",
], true);
return { remoteType, setupURI: setupURI.trim(), setupPassphrase };
return { remoteType, setupURI: setupURI.trim(), setupPassphrase, idRecoveryCode };
}
export async function runSetupURIGenerator(
@@ -183,6 +214,10 @@ export async function runSetupURIGenerator(
generated.setupPassphrase,
);
console.log("This passphrase is never shown again, so store it safely.");
if (generated.idRecoveryCode) {
console.log("ID recovery code:", generated.idRecoveryCode);
console.log("Use id_recovery_code with this value and reuse the same remote settings when generating another Setup URI for the same Vault.");
}
console.log(generated.setupURI);
}
+5 -5
View File
@@ -4,9 +4,9 @@
export {
decodeSettingsFromSetupURI,
encodeSettingsToSetupURI,
} from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/API/processSetting";
export { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/common/utils";
export { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/remote-configurations";
} from "npm:@vrtmrz/livesync-commonlib@0.1.32/compat/API/processSetting";
export { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.1.32/compat/common/utils";
export { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.32/remote-configurations";
export {
createNewVaultSettings,
DEFAULT_SETTINGS,
@@ -14,5 +14,5 @@ export {
PREFERRED_BASE,
PREFERRED_JOURNAL_SYNC,
PREFERRED_SETTING_SELF_HOSTED,
} from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/settings";
export type { ObsidianLiveSyncSettings } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/settings";
} from "npm:@vrtmrz/livesync-commonlib@0.1.32/settings";
export type { ObsidianLiveSyncSettings } from "npm:@vrtmrz/livesync-commonlib@0.1.32/settings";