mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-04 16:32:31 +00:00
Prepare time-bound Setup URI integration for Commonlib release
Add Time-bound and Compatible generation to the Obsidian dialogue, browser peer check, and setup tools, with boundary and compatibility coverage. Show the fixed window end before sharing a Time-bound URI. This review branch depends on unpublished @vrtmrz/livesync-commonlib 0.1.32-next.0. The root npm pin and Deno lockfile remain unchanged until that candidate is available; a fresh install of this commit is not yet expected to build.
This commit is contained in:
@@ -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=<encodeURIComponent(encryptedSettings)>
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user