Merge main and verify ID keys through Setup URI and QR

This commit is contained in:
vorotamoroz
2026-09-29 14:28:51 +00:00
51 changed files with 2823 additions and 181 deletions
+7
View File
@@ -35,6 +35,13 @@ Deno.test("generates a current self-hosted Setup URI through the published Commo
const decoded = await decodeSettingsFromSetupURI(setupURI, "setup-secret");
assert(decoded, "Commonlib could not decode the generated Setup URI");
const effectiveSettings = { ...DEFAULT_SETTINGS, ...decoded };
const recoveryCode = stdout.match(/sls-id-v1:[0-9a-f]{64}/u)?.[0];
assert(recoveryCode, "the generator did not print an ID recovery code");
assert(
(effectiveSettings as typeof effectiveSettings & { idDerivationKey?: string }).idDerivationKey ===
recoveryCode.slice("sls-id-v1:".length),
"the CouchDB Setup URI did not contain the generated ID key",
);
assert(
effectiveSettings.isConfigured,
"the CouchDB Setup URI left the imported device unconfigured",
+2
View File
@@ -31,6 +31,8 @@ Authentication and other non-retryable HTTP failures stop immediately. Network a
The existing `flyio/generate_setupuri.ts` path remains a CouchDB-only compatibility wrapper for the Fly.io deployment script.
The generator creates a fresh random ID key by default and includes it in the encrypted Setup URI. It prints a tagged `sls-id-v1:` recovery code. Set `id_recovery_code` to that code when generating another URI for the same Vault; a new run without it creates a different key. This restores only the ID key: reuse the original connection details too. For P2P, provide the original `p2p_room_id` and `p2p_passphrase` because omitted values are generated afresh. Set `id_mode=legacy` to generate a URI with the previous ID behaviour. `id_mode=legacy` and `id_recovery_code` cannot be combined. Keep the recovery code private and retain it if every device might be lost.
### CouchDB
```sh
+99
View File
@@ -34,6 +34,22 @@ 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",
@@ -82,6 +98,12 @@ 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",
@@ -159,3 +181,80 @@ Deno.test("rejects an unknown Setup URI mode", async () => {
}
assert(rejected, "the generator accepted an unknown Setup URI mode");
});
for (const mode of ["ephemeral", "persistent"] as const) {
Deno.test(`preserves ID recovery and explicit legacy IDs in ${mode} URIs`, async () => {
const environment = {
remote_type: "p2p",
uri_mode: mode,
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");
});
}
+44
View File
@@ -23,6 +23,42 @@ export interface GeneratedSetupURI {
setupPassphrase: string;
mode: TimeBoundSetupURIMode;
usableUntil: number | null;
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(
@@ -179,6 +215,7 @@ export async function generateSetupURI(
generateSecret();
const mode = parseSetupURIMode(environment);
const { remoteType, settings } = createSetupSettings(environment);
const idRecoveryCode = configureIdDerivation(settings, environment);
const { uri, usableUntil } = await encodeTimeBoundSetupURI(
settings,
setupPassphrase,
@@ -200,6 +237,7 @@ export async function generateSetupURI(
setupPassphrase,
mode,
usableUntil,
idRecoveryCode,
};
}
@@ -230,6 +268,12 @@ 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);
}