Use existing Setup URI and QR sharing for TURN settings

This commit is contained in:
vorotamoroz
2026-09-15 17:18:40 +00:00
parent 93bc161f20
commit ab55eb5aff
20 changed files with 89 additions and 116 deletions
+26 -33
View File
@@ -1,6 +1,6 @@
---
date: 2026-09-15
commonlib-version: "0.1.25-dev.turn-credentials.3"
commonlib-version: "0.1.25-dev.turn-credentials.4"
self-hosted-livesync-version: "1.0.28"
status: unreleased
---
@@ -151,21 +151,21 @@ and report a safe error; it must not silently save a plaintext replacement.
| Destination | Provider API token | Issued TURN username and credential |
| --- | --- | --- |
| Saved P2P profile | Included | Omitted |
| Encrypted Setup URI | Included with the source and Key ID | Omitted |
| Setup URI and QR code | Included with the source and Key ID | Omitted |
| Runtime room configuration | Available only to the source | Cached in memory and passed to WebRTC |
| General report or diagnostic log | Redacted | Redacted |
Encrypted Setup URI sharing is the complete sharing route for managed
profiles. Preserve the independent main-remote and P2P selections and the
receiving device's own peer name. Raw profile and unencrypted QR copy actions
should offer encrypted Setup URI sharing when their output includes a managed
source, including one in an inactive profile. Never substitute a temporary
TURN password or silently export a profile missing its API token.
Setup URI, QR code, and profile sharing include the source configuration as
ordinary connection settings. Preserve the independent main-remote and P2P
selections and the receiving device's own peer name. Setup URIs keep their
existing passphrase encryption; QR codes keep their existing unencrypted
format and 'FOR YOUR EYES ONLY' display. Include managed sources in inactive
profiles as well. Issued temporary credentials remain runtime state.
Markdown settings export must not leak tokens through either the top-level
source or a profile URI. For this first delivery, omit the profile collection,
its selections, and the source projection together when managed profiles are
present, and explain that connection sharing uses the encrypted Setup URI.
present, and explain that connection sharing uses Setup URIs or QR codes.
Importing Markdown without that group preserves the local profiles and
selections rather than replacing them with a filtered collection.
@@ -404,31 +404,24 @@ LiveSync Setup dialogue, import handler, profile export, Markdown settings,
and report paths. Existing fixed-field serialisers would otherwise discard
the source. Generated credentials never populate the manual fields.
Managed profile strings need a distinguishable format,
`sls+p2p-v2://`. Commonlib `0.1.24` rejects that scheme, whereas it silently
drops unknown fields in ordinary `sls+p2p://` strings. Manual profiles retain
their current format. Validate the source before activation; unknown sources
must not become manual connections.
P2P profiles keep `sls+p2p://` and carry the optional source descriptor in an
additional `source` query parameter. Full Setup URIs keep
`obsidian://setuplivesync?settings=` and encrypt the existing settings object
directly. QR codes carry the same source through an appended setting index.
Existing setting indices and the established formats remain unchanged.
Full encrypted Setup URIs also need a distinguishable outer format,
`obsidian://setuplivesync-v2?settings=`, and a versioned encrypted
envelope when managed profiles are included. The old import path decrypts and
merges arbitrary JSON, so a nested profile version alone is insufficient.
Validate the new envelope before applying settings in every maintained host.
Apply stored settings schema checks on load and import, including downgrades;
older clients must not activate a managed profile after dropping its source.
Document any minimum-client and downgrade requirements with the implementation.
Missing source settings use the ordinary manual defaults. Older clients follow
their existing handling of additional fields; this feature adds no URI-version
gate or stored-settings restriction to prevent them from loading settings.
Clients which support source descriptors preserve unknown identifiers and
versions and validate them before activation, rather than silently selecting
manual TURN when an explicitly configured source is unsupported.
For a selected managed source, save the complete P2P connection in its
versioned profile and disable the persisted legacy P2P projection: clear its
Group ID and passphrase, and save `P2P_Enabled` and `P2P_AutoStart` as false.
A compatible client restores those runtime values from the selected profile.
This prevents an older client which rejects the profile URI from connecting
through leftover manual fields. Source-only settings without a configured
room can remain disabled until setup is complete. The live settings and
setting-saved notifications retain their usable runtime values. A selected
manual profile retains its established persisted representation, even when
another saved profile has a managed source.
Persist the ordinary P2P settings projection alongside its profile. Keep the
Group ID, enabled state, and autostart preference consistent with the current
settings. Optional configuration encryption continues to protect both the
profile and its source configuration. Issued credentials never populate the
persisted manual TURN fields.
The P2P data protocol and Group ID remain unchanged. A peer using manually
configured TURN can communicate with one using issued credentials; validate
@@ -465,7 +458,7 @@ results contain no provider token, TURN username, or TURN credential.
- Manual configuration, default STUN, and existing Setup URIs retain their
behaviour. Unsupported managed sources fail explicitly.
- Provider tokens survive restart, profile selection, optional configuration
encryption, and encrypted Setup URI sharing. Reports and logs reveal no
encryption, and ordinary Setup URI and QR code sharing. Reports and logs reveal no
tokens or issued credentials, including inactive and encoded copies.
- Issued credentials never enter persisted settings, exports, or reports.
- Equivalent settings and valid credentials reuse the room. Expired
+6 -7
View File
@@ -53,13 +53,12 @@ particular TURN provider or operate a credential broker. See Cloudflare's
[credential instructions](https://developers.cloudflare.com/realtime/turn/generate-credentials/)
for creating a TURN key and its API token.
The API token is saved with the P2P profile. Use an encrypted Setup URI to share
it with your other devices. Managed profiles use the versioned Setup URI format
and require a client which supports that format; update receiving devices
before importing it. Older clients leave the saved managed P2P connection
disabled. Select and save a manual TURN configuration in a compatible client
before downgrading if P2P must remain usable. Plain QR export redirects to
encrypted Setup URI sharing.
The API token is saved with the P2P profile and included when sharing settings
through an existing Setup URI or QR code. Setup URIs retain their existing
passphrase encryption. QR codes retain their existing unencrypted format and
'FOR YOUR EYES ONLY' display. Missing source settings use the ordinary manual
configuration defaults. Receiving clients need support for the selected source
to acquire its temporary TURN credentials.
Markdown settings omit the connection profile group when it contains a managed
TURN source, including inactive profiles, and importing those omitted settings
preserves this device's existing profiles. Diagnostic reports redact the source
+1 -1
View File
@@ -491,7 +491,7 @@ Setting key: P2P_iceServerSource
Select **Manual** for the existing TURN server fields, or **Cloudflare** for a
TURN Key ID and TURN Key API Token. The API token is persisted with the profile
and included in encrypted Setup URI sharing. Issued temporary credentials are
and included in Setup URI and QR code sharing. Issued temporary credentials are
kept in memory only. Reports redact the source configuration. See
[TURN credentials](p2p.md#turn-credentials) for sharing, expiry, and reconnect
behaviour.
+4 -4
View File
@@ -23,7 +23,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.3.tgz",
"@vrtmrz/livesync-commonlib": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.4.tgz",
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
@@ -4567,9 +4567,9 @@
}
},
"node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.25-dev.turn-credentials.3",
"resolved": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.3.tgz",
"integrity": "sha512-hngE1zlNocD8IeMgRvssX4WN7SpINOyWOp9Mzs0PGM10yzVvYtR5WcVdxWtEIbaA71DLk9SPmgmQkgu+o7Oy8g==",
"version": "0.1.25-dev.turn-credentials.4",
"resolved": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.4.tgz",
"integrity": "sha512-q8jFJYzBzp7fsE+fU48A8gTvQ5DnJexkhReV8hUu2vhZvlV0e2ywUpPxkzx5KNNwSVpLSX96JCHRBLUvDMXKUQ==",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
+1 -1
View File
@@ -181,7 +181,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.3.tgz",
"@vrtmrz/livesync-commonlib": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.4.tgz",
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
+2 -2
View File
@@ -1,5 +1,5 @@
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { configURIBase, configURIBaseV2 } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import {
DEFAULT_SETTINGS,
type FilePathWithPrefix,
@@ -298,7 +298,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
throw new Error("setup requires one argument: <setupURI>");
}
const setupURI = options.commandArgs[0].trim();
if (!setupURI.startsWith(configURIBase) && !setupURI.startsWith(configURIBaseV2)) {
if (!setupURI.startsWith(configURIBase)) {
throw new Error(`setup URI must start with ${configURIBase}`);
}
const passphrase = await standardIo.prompt("Enter setup URI passphrase: ");
@@ -1,7 +1,7 @@
import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { configURIBase, configURIBaseV2 } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
@@ -419,7 +419,7 @@ describe("runCommand abnormal cases", () => {
expect(appliedSettings.useIndexedDBAdapter).toBe(false);
});
it("setup imports managed TURN through the versioned encrypted URI", async () => {
it("setup imports managed TURN through the existing encrypted URI", async () => {
const core = createCoreMock();
const source = {
version: 1,
@@ -434,7 +434,7 @@ describe("runCommand abnormal cases", () => {
},
passphrase
);
expect(setupURI.startsWith(configURIBaseV2)).toBe(true);
expect(setupURI.startsWith(configURIBase)).toBe(true);
expect(setupURI).not.toContain("private-token");
core.services.context.standardIo.prompt.mockResolvedValue(passphrase);
await runCommand(makeOptions("setup", [setupURI]), { ...context, core });
@@ -16,8 +16,8 @@ export const liveSyncProvisionalEnglishMessages = {
"TURN Key ID": "TURN Key ID",
"TURN Key API Token": "TURN Key API Token",
"Unsupported TURN configuration": "Unsupported TURN configuration",
"The API token is saved with this profile and included in encrypted Setup URI sharing. Temporary TURN credentials are kept in memory only.":
"The API token is saved with this profile and included in encrypted Setup URI sharing. Temporary TURN credentials are kept in memory only.",
"The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.":
"The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.",
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings.":
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings.",
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.":
+2 -2
View File
@@ -23,7 +23,7 @@ describe("TURN credentials in diagnostic reports", () => {
id: "inactive",
name: "Inactive TURN",
isEncrypted: false,
uri: `sls+p2p-v2://room?source=${encodeURIComponent(JSON.stringify(source))}`,
uri: `sls+p2p://room?source=${encodeURIComponent(JSON.stringify(source))}`,
},
},
};
@@ -34,7 +34,7 @@ describe("TURN credentials in diagnostic reports", () => {
expect(text).not.toContain(encodeURIComponent(token));
expect(text).not.toContain("private-key");
expect(text).not.toContain("encrypted-private-copy");
expect(report.pluginConfig.remoteConfigurations.inactive.uri).toBe("sls+p2p-v2://");
expect(report.pluginConfig.remoteConfigurations.inactive.uri).toBe("sls+p2p://");
expect(settings.P2P_iceServerSource).toEqual(source);
expect(settings.encryptedP2PIceServerSource).toBe("encrypted-private-copy");
});
+1 -1
View File
@@ -21,7 +21,7 @@ export function redactTurnSourceForReport(settings: Partial<ObsidianLiveSyncSett
}
}
/** Managed connection profiles are shared through encrypted Setup URIs. */
/** Managed connection profiles are shared through Setup URIs and QR codes. */
export function omitManagedTurnProfilesFromMarkdown(settings: Partial<ObsidianLiveSyncSettings>): void {
if (!hasManagedTurnSettings(settings)) return;
delete settings.P2P_iceServerSource;
+2 -2
View File
@@ -20,7 +20,7 @@ function configuredSettings() {
id: "managed",
name: "Managed TURN",
isEncrypted: false,
uri: "sls+p2p-v2://room?source=private-token",
uri: "sls+p2p://room?source=private-token",
},
},
activeConfigurationId: "central",
@@ -42,7 +42,7 @@ describe("managed TURN settings privacy", () => {
settings.P2P_iceServerSource.id = "manual";
expect(hasManagedTurnSettings(settings)).toBe(true);
omitManagedTurnProfilesFromMarkdown(settings);
expect(JSON.stringify(settings)).not.toMatch(/private-token|private-key-id|sls\+p2p-v2/);
expect(JSON.stringify(settings)).not.toMatch(/private-token|private-key-id|sls\+p2p/);
expect(settings).not.toHaveProperty("remoteConfigurations");
expect(settings).not.toHaveProperty("activeConfigurationId");
expect(settings).not.toHaveProperty("P2P_ActiveRemoteConfigurationId");
+1 -1
View File
@@ -51,7 +51,7 @@ export type queueItem = {
export const FileWatchEventQueueMax = 10;
export { configURIBase, configURIBaseV2, configURIBaseQR } from "@vrtmrz/livesync-commonlib/compat/common/types";
export { configURIBase, configURIBaseQR } from "@vrtmrz/livesync-commonlib/compat/common/types";
export {
CHeader,
@@ -68,7 +68,7 @@
autocomplete={field.secret ? "new-password" : "off"} autocapitalize="off" spellcheck="false" />
</label>
{/each}
<p>{translate("The API token is saved with this profile and included in encrypted Setup URI sharing. Temporary TURN credentials are kept in memory only.")}</p>
<p>{translate("The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.")}</p>
{/if}
{#if error}
<p role="status" class="turn-error">{translateIfAvailable(error)}</p>
@@ -1,5 +1,3 @@
import { copySetupURI } from "@/serviceFeatures/setupObsidian/setupUri";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import {
REMOTE_COUCHDB,
REMOTE_MINIO,
@@ -418,15 +416,6 @@ export function paneRemoteConfig(
})
.addItem((item) => {
item.setTitle("📤 Export").onClick(async () => {
if (config.uri.startsWith("sls+p2p-v2://")) {
await copySetupURI(
this.core,
createInstanceLogFunction("TURN setup sharing", this.services.API),
true,
getSettingsFromEditingSettings(this.editingSettings)
);
return;
}
await this.services.UI.promptCopyToClipboard(
`Remote configuration: ${config.name}`,
config.uri
@@ -1,5 +1,5 @@
<script lang="ts">
import { configURIBase, configURIBaseV2 } from "@/common/types";
import { configURIBase } from "@/common/types";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
@@ -29,7 +29,7 @@
}
});
const seemsValid = $derived(setupURI.startsWith(configURIBase) || setupURI.startsWith(configURIBaseV2));
const seemsValid = $derived(setupURI.startsWith(configURIBase));
async function processSetupURI() {
error = "";
if (!seemsValid) return;
@@ -1,6 +1,3 @@
import { hasManagedTurnSettings } from "@/common/turnSettingsPrivacy";
import { copySetupURI } from "./setupUri";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import {
encodeQR,
@@ -13,10 +10,6 @@ import type { SetupFeatureHost } from "./types";
export async function encodeSetupSettingsAsQR(host: SetupFeatureHost) {
const settings = host.services.setting.currentSettings();
if (hasManagedTurnSettings(settings)) {
await copySetupURI(host, createInstanceLogFunction("SF:SetupQRCode", host.services.API));
return "";
}
const settingString = encodeSettingsToQRCodeData(settings);
const result = encodeQR(settingString, OutputFormat.SVG);
if (result === "") {
@@ -18,15 +18,36 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => {
});
describe("setupObsidian/qrCode", () => {
it("routes inactive managed profiles through encrypted Setup URI sharing", async () => {
const settings = {
remoteConfigurations: { managed: { uri: "sls+p2p-v2://room?source=private-token" } },
it("shows managed TURN settings and inactive profiles through the ordinary QR dialogue", async () => {
const source = {
version: 1,
id: "cloudflare",
configuration: { turnKeyId: "turn-key", apiToken: "private-token" },
};
const host = { services: { API: { addLog: vi.fn() }, setting: { currentSettings: () => settings } } } as any;
await encodeSetupSettingsAsQR(host);
expect(copySetupURI).toHaveBeenCalledWith(host, expect.any(Function));
expect(encodeSettingsToQRCodeData).not.toHaveBeenCalled();
expect(encodeQR).not.toHaveBeenCalled();
const settings = {
P2P_iceServerSource: source,
remoteConfigurations: {
managed: { uri: `sls+p2p://room?source=${encodeURIComponent(JSON.stringify(source))}` },
},
};
const confirmWithMessage = vi.fn();
const translate = vi.fn(() => "qr-message");
const host = {
services: {
API: { addLog: vi.fn() },
context: createServiceContext({ translate }),
setting: { currentSettings: () => settings },
UI: { confirm: { confirmWithMessage } },
},
} as any;
vi.mocked(encodeSettingsToQRCodeData).mockReturnValue("encoded-settings");
vi.mocked(encodeQR).mockReturnValue("<svg/>");
expect(await encodeSetupSettingsAsQR(host)).toBe("<svg/>");
expect(encodeSettingsToQRCodeData).toHaveBeenCalledWith(settings);
expect(translate).toHaveBeenCalledWith("Setup.QRCode", { qr_image: "<svg/>" });
expect(confirmWithMessage).toHaveBeenCalledWith("Settings QR Code", "qr-message", ["OK"], "OK");
expect(copySetupURI).not.toHaveBeenCalled();
});
afterEach(() => {
@@ -2,23 +2,20 @@ import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import type { SetupFeatureHost } from "@/serviceFeatures/setupObsidian/types";
import { configURIBase, configURIBaseV2 } from "@/common/types";
import { configURIBase } from "@/common/types";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { type SetupManager, UserMode } from "@/modules/features/SetupManager";
async function handleSetupProtocol(setupManager: SetupManager, conf: Record<string, string>, uriBase = configURIBase) {
async function handleSetupProtocol(setupManager: SetupManager, conf: Record<string, string>) {
if (conf.settings) {
await setupManager.onUseSetupURI(UserMode.Unknown, `${uriBase}${encodeURIComponent(conf.settings)}`);
} else if (conf.settingsQR && uriBase === configURIBase) {
await setupManager.onUseSetupURI(UserMode.Unknown, `${configURIBase}${encodeURIComponent(conf.settings)}`);
} else if (conf.settingsQR) {
await setupManager.decodeQR(conf.settingsQR);
}
}
export function registerSetupProtocolHandler(host: SetupFeatureHost, log: LogFunction, setupManager: SetupManager) {
try {
host.services.API.registerProtocolHandler("setuplivesync-v2", async (conf) => {
await handleSetupProtocol(setupManager, conf, configURIBaseV2);
});
host.services.API.registerProtocolHandler("setuplivesync", async (conf) => {
await handleSetupProtocol(setupManager, conf);
});
@@ -4,7 +4,6 @@ import { registerSetupProtocolHandler, useSetupProtocolFeature } from "./setupPr
vi.mock("@/common/types", () => {
return {
configURIBase: "mock-config://",
configURIBaseV2: "mock-config-v2://",
};
});
@@ -18,19 +17,6 @@ vi.mock("@/modules/features/SetupManager", () => {
});
describe("setupObsidian/setupProtocol", () => {
it("routes the versioned encrypted payload through the matching URI format", async () => {
const handlers = new Map<string, (params: Record<string, string>) => Promise<void>>();
const host = {
services: { API: { registerProtocolHandler: vi.fn((action, handler) => handlers.set(action, handler)) } },
} as any;
const setupManager = { onUseSetupURI: vi.fn(), decodeQR: vi.fn() } as any;
registerSetupProtocolHandler(host, vi.fn(), setupManager);
await handlers.get("setuplivesync-v2")!({ settings: "encrypted settings" });
expect(setupManager.onUseSetupURI).toHaveBeenCalledWith("unknown", "mock-config-v2://encrypted%20settings");
await handlers.get("setuplivesync-v2")!({ settingsQR: "plain settings" });
expect(setupManager.decodeQR).not.toHaveBeenCalled();
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
@@ -16,16 +16,11 @@ export async function askEncryptingPassphrase(host: SetupFeatureHost): Promise<s
);
}
export async function copySetupURI(
host: SetupFeatureHost,
log: LogFunction,
stripExtra = true,
settings = host.services.setting.currentSettings()
) {
export async function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra = true) {
const encryptingPassphrase = await askEncryptingPassphrase(host);
if (encryptingPassphrase === false) return;
const encryptedURI = await encodeSettingsToSetupURI(
settings,
host.services.setting.currentSettings(),
encryptingPassphrase,
[...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])],
true