diff --git a/docs/design_docs/time_bound_setup_uri.md b/docs/design_docs/time_bound_setup_uri.md new file mode 100644 index 00000000..75fb9131 --- /dev/null +++ b/docs/design_docs/time_bound_setup_uri.md @@ -0,0 +1,396 @@ +--- +date: 2026-09-28 +commonlib-version: "0.1.31" +feasibility-probe-version: "0.1.27" +self-hosted-livesync-version: "1.0.30" +status: implementing +--- + +# Time-bound Setup URIs + +## Purpose and status + +Offer two modes when generating a [Setup URI](../glossary.md): + +- **Ephemeral**, selected by default, derives an effective passphrase from the + entered passphrase and the current fixed time window. +- **Persistent** uses the entered passphrase and the existing URI format + unchanged. It also serves as the compatibility mode for older readers. + +The generation mode choice shows the exact time until which an Ephemeral URI +can be opened through the ordinary reader. Import still requires only the URI and +the entered passphrase. Neither the mode nor a timestamp is stored in the URI. + +The design is technically feasible with the existing encryption primitives. +An executable protocol probe passes 22 cases against Commonlib 0.1.27 and +octagonal-wheels 0.1.54. The implementation is based on Commonlib 0.1.31. +The 0.1.32-next.0 candidate has passed local package and downstream checks; +publication, exact dependency pins, the independent Deno lockfile update, and +mobile performance validation remain outstanding. + +This document records the proposed key derivation and integration contract. The +companion probe demonstrates them without changing an application entry point. + +## Scope + +The first version offers Ephemeral and Persistent only. Ephemeral uses one +fixed seven-day window shared by every implementation. Arbitrary start dates, +custom end dates, selectable window lengths, and rolling seven-day lifetimes +are outside this version. + +Keep the current settings filtering, remote profiles, main and P2P selections, +and short/full export variants. Time binding affects opening the exported +settings; it does not alter Vault encryption, remote credentials, replication, +or settings already imported by another device. + +The existing `settingsQR` format and QR aggregator are separate sharing paths. +They remain outside this design and must not be presented as time-bound. + +## Time window + +Use Unix milliseconds and the following protocol constants: + +```text +windowMilliseconds = 604800000 +windowNumber = floor(nowMilliseconds / windowMilliseconds) +windowStart = windowNumber * windowMilliseconds +usableUntil = (windowNumber + 1) * windowMilliseconds +``` + +The anchor is the Unix epoch, `1970-01-01T00:00:00Z`. Consequently, boundaries +fall on Thursdays at 00:00 UTC. This is a protocol convention, independent of +the device's locale, time zone, daylight-saving rules, and calendar week. +The anchor and window length are part of the derivation profile. Changing them +requires an explicit compatibility design; there is no visible version field +from which an importer can discover a different time condition. + +For example, a URI generated at `2026-09-28T12:00:00Z` belongs to the window +`[2026-09-24T00:00:00Z, 2026-10-01T00:00:00Z)`. Ordinary import rejects it at +the end instant. Generating it one second before that instant leaves one +second of availability. The UI must not describe this as seven days from +generation. + +Use safe, non-negative integer timestamps without 32-bit coercion. The +reader tries only its current window. There is no previous-window allowance, +future-window search, modulo wraparound, or grace period. Devices whose clocks +fall on different sides of a boundary can disagree; local date formatting +does not affect the calculation. + +## Passphrase derivation and encryption + +The time factor follows the counter construction used by +[TOTP, RFC 6238 section 4.2](https://www.rfc-editor.org/rfc/rfc6238.html#section-4.2). +This is a time-bound encryption format, not a TOTP authentication protocol: +there is no six-digit code, trusted validation server, or single-use state. + +Reuse Commonlib's current `encryptString` and `decryptString` path. It uses +octagonal-wheels' salted PBKDF2/HKDF and AES-GCM implementation. Persistent +passes the entered passphrase directly to that path. Only Ephemeral applies +a full-length transformation: + +```text +if mode == "persistent": + effectivePassphrase = enteredPassphrase +else: + hmacKey = SHA-256(UTF-8(enteredPassphrase)) + context = JSON.stringify([ + "livesync/setup-uri", + "tb1", + "ephemeral", + windowNumber + ]) + effectivePassphrase = lowercaseHex(HMAC-SHA-256(hmacKey, UTF-8(context))) +encryptedSettings = encryptString(preparedSettingsJSON, effectivePassphrase) +``` + +The mode strings are exactly `ephemeral` and `persistent`. JSON encoding, +field order, lowercase hexadecimal, and the domain/profile strings are part +of the proposed Ephemeral derivation. `tb1` is an internal derivation identifier; +it is not a URI prefix or stored field. Persistent applies no new transformation. + +The initial SHA-256 provides a fixed-length HMAC key, including for an empty +low-level input. It does not replace password stretching: the existing +encryption function still performs PBKDF2. Do not trim or normalise the +entered passphrase within this transformation. Existing host requirements for +a non-empty passphrase remain in force; this design adds no new password +policy. + +At the assessed dependency versions, the encryption path uses PBKDF2-SHA-256 +with 310,000 iterations, HKDF-SHA-256, and AES-256-GCM with a 128-bit tag. Its +`%$` payload includes the material needed for decryption. The PBKDF2 salt may +be reused within a session; the IV and HKDF salt are generated for each +encryption. Retain these existing semantics rather than claiming a new +per-URI PBKDF2 salt or introducing another encryption implementation. + +For the synthetic passphrase `test-passphrase`, fixed derivation vectors are: + +```text +context: ["livesync/setup-uri","tb1","ephemeral",1234] +effectivePassphrase: b39361c51f0b7bd835554db1dffbc9a540fb30789aa62bf53e39e07d1073013b + +mode: persistent +effectivePassphrase: test-passphrase +``` + +The probe checks the Ephemeral Web Crypto output against a value calculated +separately through Node's SHA-256 and HMAC interface, and checks that Persistent +retains the exact input. Encryption remains randomised; these vectors specify +the effective passphrase, not the complete URI. + +This composition needs Commonlib changes but no new cryptographic dependency +or remote service. The prototype verifies interoperability with the existing +encryption path; it is not an independent cryptographic audit. + +## URI format and decoding + +Keep the existing URI prefix and encrypted representation without a new marker: + +```text +obsidian://setuplivesync?settings= +``` + +Both modes use the existing `%$` encrypted representation. There is no mode +flag, timestamp, window number, duration, derivation-version marker, or separate +query parameter. For identical prepared settings, both modes have the same +binary layout and ciphertext length. Percent-encoded text length may vary with +the random ciphertext; equal URI text length is not a requirement. + +Persistent delegates to the existing encoder with the entered passphrase and +the same settings-filtering options. A new marker or a transformed Persistent +passphrase would defeat older readers. Ephemeral uses the same external format +to preserve mode hiding. The protocol handler continues to reconstruct the URI +from the existing `settings` query value. + +For a `%$` payload, including an existing URI made before this feature, the +updated decoder: + +1. validates the envelope and captures the current window; +2. derives the Ephemeral candidate for that window and takes the entered + passphrase unchanged as the Persistent candidate; +3. attempts authenticated decryption with both candidates, without returning + early after the first success; +4. checks the current window again before returning an Ephemeral result; and +5. returns settings only if exactly one candidate is accepted. + +Keep expected candidate failures internal. Two failed candidates produce one +generic opening failure. A wrong passphrase, an out-of-window Ephemeral URI, +and damaged authenticated ciphertext do not receive different user messages. +There is no information from which to report a definite historical end time +for an unreadable URI. + +The raw-passphrase attempt is an intentional part of the two-candidate reader. +An Ephemeral URI from another window still fails both candidates with the +entered passphrase; this attempt does not remove its time condition. There is +no plaintext recovery, time-window search, or additional key fallback. + +Older encryption representations other than `%$` retain their existing +legacy-only decoder and passphrase handling. Unsupported prefixes remain +unsupported. Malformed `%$` payloads fail authentication with both candidates. +Bound time-bound candidate work to two attempts; the existing decoder continues +to own any historical encryption-format compatibility trials. + +Because the derivation profile is not stored, an older reader cannot identify +an Ephemeral URI and give an upgrade-specific error. It reports a decryption +failure. A future change to the Ephemeral profile must define its bounded +candidate policy explicitly; the reader must not infer arbitrary profiles or +scan them without a limit. + +Two trials make the amount of cryptographic work predictable, but do not +constitute a constant-time implementation. This design does not claim to hide +the mode from a caller who can instrument the decoder and knows the passphrase. + +## Generation and import interaction + +Keep the existing passphrase prompt. After it, use the existing confirmation +dialogue to choose Time-bound (the Ephemeral encoder mode) or Compatible (the +Persistent encoder mode). Select Time-bound by default, show its exact absolute +end in the device's local time zone with its time-zone name or UTC offset, and +explain that this is the end of a fixed UTC window rather than seven days from +generation. Compatible has no time condition and remains readable by older +clients. No synchronised setting or permanent Vault preference is needed. + +Commonlib exposes the current window end for this pre-generation choice. +Compare it with the `usableUntil` returned alongside the generated URI. If +the window changed during selection or encryption, show a fresh choice with +the new end before opening the existing copy dialogue. Persistent generation +does not consult the clock and returns `null` for `usableUntil`. + +The existing copy dialogue does not monitor the clock. If it stays open across +the boundary, it can still display and copy a URI whose window has ended; +the reader will reject that URI. The end time was shown at the mode choice. +The copy dialogue does not claim a rolling week or silently switch to +Compatible. + +Import requires no mode selector or date input. Recheck the window at the +end of decryption so that an Ephemeral operation crossing the boundary does +not return settings. Once settings have been returned successfully, subsequent +confirmation, setup, and synchronisation do not remain time-bound: the URI +has already been opened. This is an import boundary, not remote revocation. + +## Security and privacy boundary + +The hidden mode is an external-format property. A passive holder of the URI +does not receive an explicit time condition or Persistent marker. Randomised +encryption and the unchanged prepared settings representation avoid adding +a mode-dependent field or length difference. + +The clock and reader are controlled by the receiving device. Anyone with the +correct passphrase can reproduce historical candidates, modify the reader, +or change the supplied clock. Including Persistent adds one possible +candidate; it does not make a small calendar search cryptographically hard. +Time binding adds no independent secret and is not a substitute for a strong +Setup URI passphrase. + +The same URI can be opened repeatedly within its window. A replayed clock +earlier than generation, but inside that same window, also derives the same +key. Successfully copied settings and remote credentials remain usable after +the window ends, subject to their own remote policies. + +No URI, entered or effective passphrase, derived key, decrypted settings, or +candidate-specific authentication result should enter ordinary logs or reports. +The generation screen necessarily reveals the selected mode and end time to +its user; hiding information in the URI does not hide that screen or a +separately shared message. + +## Ownership and compatibility + +Commonlib owns preparation of exported settings, the time-window calculation, +passphrase derivation, format parsing, and dual-candidate decoding. Make these +changes in the Commonlib repository. LiveSync must consume a validated packed +artefact and then an exact reviewed package version, following +[the Commonlib dependency workflow](../../devs.md#commonlib-dependency). + +Preserve the existing positional encoder API. Its third and fourth arguments +already mean properties to remove and whether to omit default values. Add a +separate encoder, provisionally `encodeTimeBoundSetupURI`, with an options +object containing the mode and the existing export options. Its result is: + +```typescript +type TimeBoundSetupURIResult = { + uri: string; + usableUntil: number | null; +}; +``` + +The new encoder defaults to Ephemeral. Preserve the old encoder's legacy +behaviour for callers which have not migrated. Extend +`decodeSettingsFromSetupURI` to try both candidates for `%$` while retaining its +settings return contract and historical format handling. Persistent generation +through the new API delegates to the old encoder. Time injection belongs in +internal test seams, not an import option exposed to users. + +| Surface | Required work | +| ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Obsidian generation](../../src/serviceFeatures/setupObsidian/setupUri.ts) | Reuse the passphrase and copy dialogues, add a mode choice with the exact current window end, and use the new encoder for all three copy variants and the copy event. Preserve each variant's export filters. | +| [Obsidian import](../../src/modules/features/SetupWizard/dialogs/UseSetupURI.svelte) | Consume the updated decoder and retain one authentication failure message. The existing prefix check indicates URI shape, not successful authentication. | +| [Protocol handler](../../src/serviceFeatures/setupObsidian/setupProtocol.ts) | Keep the existing `settings` reconstruction; test encoded delimiters for both modes. | +| [CLI setup](../../src/apps/cli/commands/runCommand.ts) | Consume the updated decoder and leave settings unchanged after any rejection. | +| [WebPeer generator](../../src/apps/webpeer/src/P2PCheckSetup.ts) | Choose a mode explicitly and return availability to its result UI; keep Ephemeral as the default. | +| [Setup utility](../../utils/setup/generate_setup_uri.ts) | Add an explicit mode input, default to Ephemeral, and return/print the end time. Persistent is an explicit choice for durable provisioning output. | +| [Setup utility package facade](../../utils/setup/livesync-commonlib.ts) | Update its independent Commonlib pin and associated lockfiles; updating the root npm dependency alone is insufficient. | + +The Fly.io wrapper delegates to the setup utility and should inherit the same +contract. All maintained generators must migrate explicitly before this +feature is described as the default across applications. Non-interactive +automation which needs a durable URI must select Persistent. + +WebPeer hides an Ephemeral URI and disables its copy and additional-device +actions after the window ends. Its connection monitor remains available for a +device that imported the URI before the end; opening the URI does not expire +the imported P2P credentials. + +| URI | Updated reader | Reader without time-bound support | +| -------------- | ------------------------------------------------------- | --------------------------------- | +| Legacy | Existing behaviour, without a time condition | Existing behaviour | +| New Ephemeral | Opens in the current window with the correct passphrase | Rejected | +| New Persistent | Opens with the correct passphrase | Existing behaviour | + +The assessed, unchanged Commonlib 0.1.27 decoder opens Persistent with the +entered passphrase and rejects Ephemeral with that same passphrase. Persistent +compatibility means compatibility with readers which already support the +ordinary generated `%$` representation; it does not add that representation +to still older readers. Historical supported versions and their settings +schemas still need compatibility fixtures before rollout. + +Keep the original raw-passphrase behaviour of existing URIs. They are +indistinguishable from newly generated Persistent URIs to the updated reader. +The previous marked-envelope sketch rejected an added Persistent compatibility +case with `Unsupported encryption format`. The same synthetic settings and +entered passphrase now open through the unchanged decoder, while separate +tests retain old-reader rejection of Ephemeral and rejection outside its window. + +## Feasibility evidence and validation + +The [executable design probe](time_bound_setup_uri.probe.mjs) uses Web Crypto +for the proposed transformation and the actual Commonlib encoder/decoder for +settings filtering and encryption. It is an isolated specification exercise; +it is not imported by an application or registered in the production test suite. + +Run it after installing the dependency versions assessed by this document: + +```sh +NODE_OPTIONS=--max-old-space-size=512 node docs/design_docs/time_bound_setup_uri.probe.mjs +``` + +Alternatively, pass the directory of an unpacked Commonlib 0.1.27 package as +the first argument. Its module resolution must supply octagonal-wheels 0.1.54 +and the remaining declared dependencies. The probe verifies the Commonlib +version before running; it does not install or download packages. + +The assessment used Node.js 24.18.0 and registry artefacts for Commonlib 0.1.27 +and octagonal-wheels 0.1.54, verified against the lockfile's SHA-512 values. +All 22 cases passed: + +- fixed Ephemeral derivation checked through Web Crypto and Node HMAC, and an + unchanged Persistent passphrase; +- current-window success, previous/later-window rejection, and exact end; +- Persistent across distant timestamps and both candidate attempts on success; +- wrong-passphrase rejection and a shared external format; +- stable end-time metadata and randomised repeated encryption; +- legacy import, old-reader acceptance of Persistent, and old-reader rejection + of Ephemeral; +- unsupported-prefix, malformed-ciphertext, and ciphertext-tampering rejection; +- Persistent generation without reading the clock; +- protocol query decoding/re-encoding; +- generation and import crossing a boundary; +- equivalent timestamps expressed with different UTC offsets; +- Unicode, whitespace, empty low-level passphrase input, and distinct Ephemeral + and raw-passphrase candidates; +- the deliberate limitation that clock rollback reproduces an old key. + +This demonstrates the proposed mechanism and baseline interoperability, not +completed application support, formal mode indistinguishability, or a mobile +latency bound. The present encryption API may perform two PBKDF2 derivations +on a cold import. Do not infer production performance from this small probe, +whose operations can reuse the dependency's in-memory key cache. + +Implementation validation must cover Commonlib unit and packed-package tests, +plus shared fixed-input protocol vectors across consumers. Add targeted LiveSync +tests for the generation metadata, cancellation, a window change during mode +selection, encoded deep links, and rejected imports without settings writes. Extend CLI, WebPeer, +and Deno setup-tool round trips, including their Persistent selection. + +Then run the required LiveSync checks, unit suite, and production build, one +broad process at a time with `NODE_OPTIONS=--max-old-space-size=3072` and bounded +test workers. Use the existing focused real-Obsidian Setup URI workflow for +copy/paste, deep-link import, cancellation, and successful setup. Verify +selection-boundary handling with an injected test clock and mobile behaviour on a +supported runtime; do not change the host's system clock. No remote database +service is needed for the protocol tests themselves. + +There is no identified architectural blocker. The 0.1.31-based Commonlib +candidate passes its type, unit, boundary, release-process, and packed-package +gates. LiveSync passes source checks, the unit suite, the Obsidian plug-in +build, and the WebPeer and CLI builds against that local tarball. The complete +unit suite passed with subprocess permissions needed by its CLI installer +fixtures. Focused real-Obsidian generation checks passed on desktop and +emulated mobile, including the displayed Time-bound end, Compatible output, +and mobile touch targets. Unit and protocol tests cover window boundaries and +preserve the existing distinction between an empty passphrase and cancellation. +The WebPeer browser test confirms that its monitor remains available after the +URI window ends for a device that has already imported the URI. +The Deno generator passed against the same local packed +candidate through a temporary import map. These results do not establish a +mobile performance bound or replace validation of the published package and +its lockfiles. The first implementation changes only primary-language +resources; translation changes require separate scope. diff --git a/docs/design_docs/time_bound_setup_uri.probe.mjs b/docs/design_docs/time_bound_setup_uri.probe.mjs new file mode 100644 index 00000000..d26a1143 --- /dev/null +++ b/docs/design_docs/time_bound_setup_uri.probe.mjs @@ -0,0 +1,272 @@ +// Executable design probe; this file is not part of any application bundle. +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const packageDirectory = process.argv[2] + ? resolve(process.argv[2]) + : fileURLToPath(new URL(".", import.meta.resolve("@vrtmrz/livesync-commonlib/package.json"))); +const packageURL = pathToFileURL(`${packageDirectory}/`); +const metadata = JSON.parse(await readFile(new URL("package.json", packageURL), "utf8")); +assert.equal(metadata.version, "0.1.27", "Run against the Commonlib version assessed by this design"); +const { encodeSettingsToSetupURI: encodeLegacy, decodeSettingsFromSetupURI: decodeLegacy } = await import( + new URL("dist/API/processSetting.js", packageURL).href +); +const { configURIBase } = await import(new URL("dist/common/types.js", packageURL).href); + +const WEEK_MS = 604_800_000; +const passphrase = "synthetic time-bound URI passphrase"; +const settings = { + couchDB_URI: "https://example.invalid", + couchDB_USER: "synthetic-user", + couchDB_PASSWORD: "synthetic-secret", + isConfigured: true, +}; +const midweek = Date.parse("2026-09-28T12:00:00Z"); +const slot = (now) => { + assert.ok(Number.isSafeInteger(now) && now >= 0, "A supported UTC timestamp is required"); + return Math.floor(now / WEEK_MS); +}; +const end = (slot(midweek) + 1) * WEEK_MS; +const payloadOf = (uri) => { + assert.ok(uri.trim().startsWith(configURIBase)); + return decodeURIComponent(uri.trim().slice(configURIBase.length)); +}; +const wrap = (payload) => configURIBase + encodeURIComponent(payload); +const fixedClock = (now) => () => now; + +async function effectivePassphrase(secret, mode, bucket) { + if (mode === "persistent") return secret; + assert.equal(mode, "ephemeral"); + const text = new TextEncoder(); + const keyBytes = await crypto.subtle.digest("SHA-256", text.encode(secret)); + const key = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const context = JSON.stringify(["livesync/setup-uri", "tb1", "ephemeral", bucket]); + const signed = await crypto.subtle.sign("HMAC", key, text.encode(context)); + return Array.from(new Uint8Array(signed), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function encode(mode, clock, secret = passphrase) { + assert.ok(mode === "ephemeral" || mode === "persistent"); + if (mode === "persistent") { + return { uri: (await encodeLegacy(settings, secret)).trim(), usableUntil: null }; + } + const bucket = slot(clock()); + const effective = await effectivePassphrase(secret, mode, bucket); + const legacy = await encodeLegacy(settings, effective); + if (mode === "ephemeral" && slot(clock()) !== bucket) throw new Error("Window changed"); + return { + uri: legacy.trim(), + usableUntil: (bucket + 1) * WEEK_MS, + }; +} + +async function decode(uri, secret, clock, attempts = []) { + const payload = payloadOf(uri); + if (!payload.startsWith("%$")) { + return decodeLegacy(uri.trim(), secret); + } + const bucket = slot(clock()); + const authenticated = []; + for (const mode of ["ephemeral", "persistent"]) { + const effective = await effectivePassphrase(secret, mode, bucket); + attempts.push(mode); + try { + const value = await decodeLegacy(uri.trim(), effective); + if (value !== false) authenticated.push({ mode, value }); + } catch { + // Authentication failures are expected while trying the two candidates. + } + } + const currentBucket = slot(clock()); + const accepted = authenticated.filter(({ mode }) => mode === "persistent" || bucket === currentBucket); + if (accepted.length !== 1) throw new Error("Cannot open Setup URI"); + return accepted[0].value; +} + +const ephemeral = await encode("ephemeral", fixedClock(midweek)); +const persistent = await encode("persistent", fixedClock(midweek)); + +await test("Persistent opens in the unchanged legacy decoder with the entered passphrase", async () => { + assert.equal((await decodeLegacy(persistent.uri, passphrase)).couchDB_PASSWORD, settings.couchDB_PASSWORD); +}); + +await test("Web Crypto derivation matches fixed vectors computed through Node HMAC", async () => { + assert.equal( + await effectivePassphrase("test-passphrase", "ephemeral", 1234), + "b39361c51f0b7bd835554db1dffbc9a540fb30789aa62bf53e39e07d1073013b" + ); + assert.equal(await effectivePassphrase("test-passphrase", "persistent", 1234), "test-passphrase"); +}); + +await test("Ephemeral accepts the entire current UTC bucket, including before creation", async () => { + for (const now of [slot(midweek) * WEEK_MS, midweek, end - 1]) { + const attempts = []; + assert.equal( + (await decode(ephemeral.uri, passphrase, fixedClock(now), attempts)).couchDB_PASSWORD, + settings.couchDB_PASSWORD + ); + assert.deepEqual(attempts, ["ephemeral", "persistent"]); + } +}); + +await test("Ephemeral rejects the previous bucket, the exact end, and later buckets", async () => { + for (const now of [slot(midweek) * WEEK_MS - 1, end, end + WEEK_MS, end + 100 * WEEK_MS]) { + const attempts = []; + await assert.rejects(decode(ephemeral.uri, passphrase, fixedClock(now), attempts), /Cannot open/); + assert.deepEqual(attempts, ["ephemeral", "persistent"]); + } +}); + +await test("Persistent accepts distant supported timestamps using the same two attempts", async () => { + for (const now of [0, midweek, end, end + 100 * WEEK_MS]) { + const attempts = []; + assert.equal( + (await decode(persistent.uri, passphrase, fixedClock(now), attempts)).couchDB_PASSWORD, + settings.couchDB_PASSWORD + ); + assert.deepEqual(attempts, ["ephemeral", "persistent"]); + } +}); + +await test("Both modes reject an incorrect passphrase with the same final error", async () => { + for (const generated of [ephemeral, persistent]) { + await assert.rejects(decode(generated.uri, "incorrect", fixedClock(midweek)), /Cannot open Setup URI/); + } +}); + +await test("Generation metadata agrees with the fixed UTC boundary", () => { + assert.equal(ephemeral.usableUntil, end); + assert.equal(new Date(end).toISOString(), "2026-10-01T00:00:00.000Z"); + assert.equal(persistent.usableUntil, null); +}); + +await test("Both modes retain the legacy binary layout without a new marker or timestamp field", () => { + const binaryLength = (uri) => { + const payload = payloadOf(uri); + assert.ok(payload.startsWith("%$")); + return Buffer.from(payload.slice("%$".length), "base64").length; + }; + assert.equal(binaryLength(ephemeral.uri), binaryLength(persistent.uri)); +}); + +await test("Repeated generation has different ciphertext without changing the time limit", async () => { + const again = await encode("ephemeral", fixedClock(midweek)); + assert.notEqual(again.uri, ephemeral.uri); + assert.equal(again.usableUntil, ephemeral.usableUntil); +}); + +await test("Old decoder cannot open Ephemeral with the entered passphrase", async () => { + await assert.rejects(decodeLegacy(ephemeral.uri, passphrase)); +}); + +await test("Legacy URI remains readable without time binding", async () => { + const legacy = await encodeLegacy(settings, passphrase); + const attempts = []; + const value = await decode(legacy, passphrase, fixedClock(end + 100 * WEEK_MS), attempts); + assert.equal(value.couchDB_PASSWORD, settings.couchDB_PASSWORD); + assert.deepEqual(attempts, ["ephemeral", "persistent"]); +}); + +await test("Unsupported prefixes are rejected without time-bound trials", async () => { + for (const payload of ["tb1:" + payloadOf(ephemeral.uri), "tb2:", "", "unlimited"]) { + const attempts = []; + await assert.rejects(decode(wrap(payload), passphrase, fixedClock(midweek), attempts), /format/); + assert.deepEqual(attempts, []); + } +}); + +await test("Missing or truncated legacy-format ciphertext fails both candidates", async () => { + for (const payload of ["%$", "%$AA=="]) { + const attempts = []; + await assert.rejects(decode(wrap(payload), passphrase, fixedClock(midweek), attempts), /Cannot open/); + assert.deepEqual(attempts, ["ephemeral", "persistent"]); + } +}); + +await test("Persistent generation does not consult the clock", async () => { + const generated = await encode("persistent", () => { + throw new Error("The Persistent generator must not read time"); + }); + assert.equal(generated.usableUntil, null); + assert.equal((await decodeLegacy(generated.uri, passphrase)).couchDB_PASSWORD, settings.couchDB_PASSWORD); +}); + +await test("Changing authenticated ciphertext is rejected", async () => { + const payload = payloadOf(ephemeral.uri); + const encoded = payload.slice("%$".length); + const bytes = Buffer.from(encoded, "base64"); + bytes[bytes.length - 1] ^= 1; + await assert.rejects(decode(wrap("%$" + bytes.toString("base64")), passphrase, fixedClock(midweek)), /Cannot open/); +}); + +await test("Protocol query decoding and re-encoding preserve the new payload", async () => { + for (const generated of [ephemeral, persistent]) { + const incomingSettings = new URL(generated.uri).searchParams.get("settings"); + const reconstructed = configURIBase + encodeURIComponent(incomingSettings); + assert.equal( + (await decode(reconstructed, passphrase, fixedClock(midweek))).couchDB_PASSWORD, + settings.couchDB_PASSWORD + ); + } +}); + +await test("Generation crossing the boundary withholds an Ephemeral result", async () => { + let reads = 0; + await assert.rejects( + encode("ephemeral", () => (reads++ === 0 ? end - 1 : end)), + /Window changed/ + ); +}); + +await test("Import crossing the boundary withholds Ephemeral settings but accepts Persistent", async () => { + for (const [generated, succeeds] of [ + [ephemeral, false], + [persistent, true], + ]) { + let reads = 0; + const result = decode(generated.uri, passphrase, () => (reads++ === 0 ? end - 1 : end)); + if (succeeds) assert.equal((await result).couchDB_PASSWORD, settings.couchDB_PASSWORD); + else await assert.rejects(result, /Cannot open/); + } +}); + +await test("Explicit UTC offsets representing the same instant select the same bucket", async () => { + for (const date of ["2026-09-28T12:00:00Z", "2026-09-28T21:00:00+09:00", "2026-09-28T05:00:00-07:00"]) { + assert.equal( + (await decode(ephemeral.uri, passphrase, fixedClock(Date.parse(date)))).couchDB_PASSWORD, + settings.couchDB_PASSWORD + ); + } +}); + +await test("Passphrase transformation preserves Unicode and supports an empty low-level input", async () => { + for (const secret of ["", "合言葉🔑é", "e\u0301", " leading and trailing "]) { + const generated = await encode("ephemeral", fixedClock(midweek), secret); + assert.equal( + (await decode(generated.uri, secret, fixedClock(midweek))).couchDB_PASSWORD, + settings.couchDB_PASSWORD + ); + } + assert.notEqual( + await effectivePassphrase("é", "persistent", 0), + await effectivePassphrase("e\u0301", "persistent", 0) + ); +}); + +await test("Ephemeral derivation differs from the raw Persistent passphrase even for bucket zero", async () => { + assert.notEqual( + await effectivePassphrase(passphrase, "ephemeral", 0), + await effectivePassphrase(passphrase, "persistent", 0) + ); +}); + +await test("Clock rollback reproduces an old Ephemeral key", async () => { + await assert.rejects(decode(ephemeral.uri, passphrase, fixedClock(end)), /Cannot open/); + assert.equal( + (await decode(ephemeral.uri, passphrase, fixedClock(midweek))).couchDB_PASSWORD, + settings.couchDB_PASSWORD + ); +}); diff --git a/docs/setup_object_storage.md b/docs/setup_object_storage.md index 0244a766..4c67af1c 100644 --- a/docs/setup_object_storage.md +++ b/docs/setup_object_storage.md @@ -32,6 +32,7 @@ deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.co For providers which require them, set `force_path_style`, `use_custom_request_handler`, or `bucket_custom_headers` as described in the [setup utility reference](../utils/readme.md#object-storage). Store the generated Setup URI and Setup URI passphrase separately. The URI is encrypted, but it contains the Object Storage credentials. +The generator prints the exact end time of its default Ephemeral URI. Set `uri_mode=persistent` before running it if the URI must remain usable without a time condition. ## Set up the first device diff --git a/docs/setup_own_server.md b/docs/setup_own_server.md index 0eac36b2..05ad2bf4 100644 --- a/docs/setup_own_server.md +++ b/docs/setup_own_server.md @@ -192,10 +192,13 @@ deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.co The generator consumes the exact registry-pinned Commonlib release used by the provisioning utility. It creates a configured CouchDB remote profile, applies the current defaults for a new Vault, and encodes them with Commonlib's Setup URI contract. +By default, the URI is Ephemeral and can be opened only until the exact UTC time printed by the generator. This is the end of a fixed seven-day window, not seven days after creation. Set `uri_mode=persistent` before running the command if the URI needs no time condition or must be readable by an older client that supports the existing encrypted format. + You will then get the following output: ```bash Generated couchdb Setup URI. +Ephemeral: usable until 2026-10-01T00:00:00.000Z (UTC). Your passphrase for the Setup URI is: H7vX...a-random-32-character-value This passphrase is never shown again, so store it safely. obsidian://setuplivesync?settings=%5B%22tm2DpsOE74nJAryprZO2M93wF%2Fvg.......4b26ed33230729%22%5D diff --git a/docs/setup_p2p.md b/docs/setup_p2p.md index fb13a9c1..20b949d3 100644 --- a/docs/setup_p2p.md +++ b/docs/setup_p2p.md @@ -136,3 +136,4 @@ deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.co ``` The generated Setup URI contains the encrypted room, relay, and Vault settings. It deliberately omits the device-specific name. Store the URI and its passphrase separately. After importing it on the first device, continue from the initialisation step above, then generate a fresh Setup URI for an additional device from that working device. +The generator prints the exact end time of its default Ephemeral URI. Set `uri_mode=persistent` before running it if the URI must remain usable without a time condition. diff --git a/src/apps/cli/commands/runCommand.unit.spec.ts b/src/apps/cli/commands/runCommand.unit.spec.ts index f4d3a45a..61d253a1 100644 --- a/src/apps/cli/commands/runCommand.unit.spec.ts +++ b/src/apps/cli/commands/runCommand.unit.spec.ts @@ -419,6 +419,38 @@ describe("runCommand abnormal cases", () => { expect(appliedSettings.useIndexedDBAdapter).toBe(false); }); + it("setup opens Ephemeral in its window and leaves settings untouched afterwards", async () => { + const end = Date.parse("2026-10-01T00:00:00Z"); + const clock = vi.spyOn(Date, "now").mockReturnValue(end - 1_000); + const passphrase = "time-bound-passphrase"; + try { + const { uri, usableUntil } = await processSetting.encodeTimeBoundSetupURI( + { ...DEFAULT_SETTINGS, isConfigured: true, couchDB_DBNAME: "time-bound-vault" }, + passphrase + ); + expect(usableUntil).toBe(end); + + const inWindow = createCoreMock(); + inWindow.services.context.standardIo.prompt.mockResolvedValue(passphrase); + await runCommand(makeOptions("setup", [uri.trim()]), { ...context, core: inWindow }); + expect(inWindow.services.setting.applyExternalSettings).toHaveBeenCalledWith( + expect.objectContaining({ couchDB_DBNAME: "time-bound-vault" }), + true + ); + + clock.mockReturnValue(end); + const outOfWindow = createCoreMock(); + outOfWindow.services.context.standardIo.prompt.mockResolvedValue(passphrase); + await expect(runCommand(makeOptions("setup", [uri.trim()]), { ...context, core: outOfWindow })).rejects.toThrow( + "Cannot open Setup URI" + ); + expect(outOfWindow.services.setting.applyExternalSettings).not.toHaveBeenCalled(); + expect(outOfWindow.services.control.applySettings).not.toHaveBeenCalled(); + } finally { + clock.mockRestore(); + } + }); + it("setup imports managed TURN through the existing encrypted URI", async () => { const core = createCoreMock(); const profiles = { diff --git a/src/apps/webpeer/src/P2PCheck.svelte b/src/apps/webpeer/src/P2PCheck.svelte index 670b306f..151cc1c4 100644 --- a/src/apps/webpeer/src/P2PCheck.svelte +++ b/src/apps/webpeer/src/P2PCheck.svelte @@ -1,7 +1,8 @@