Recovery key hold, warning and operator reset, 2026-09-29¶
A recovery key registered recently no longer unlocks revoking an account's
last active device: the server holds that device for seven days after the key
is registered. A device whose own registration meets a different key says so
in a security notice and at the top of Show sync status and of its settings,
until its own key is registered. Whoever runs the server can clear the key
with obsyncd recovery reset plan|apply, with the server stopped. The reset
also rotates the setup token and arms one re-enrolment: the new token and a
restored vault key then re-enrol the owner on that account, while an account
with no key that nobody has reset still refuses. Nothing a device holds on its
own can arm or perform it. This is an author implementation
record, not an independent security verdict; the adversarial run against the
composed head belongs to its security review.
Builds¶
| Commit | obsyncd SHA-256 |
main.js SHA-256 |
|
|---|---|---|---|
| Before | afbf7e7 (1.1.4) |
028611b6267b30f047c97efe50a5a66335675d7149f1d45f0a28a8f8c6b00cb7 |
19d3202059551d72f53f3f3a0deaf3eb159971604c1d9fb481f756ac56c28d0c |
| After, runs 1 to 7 | the lane's working tree before its final wording | 539db65eac773fce856946b51a5274f5f7ed5807d3ab23fde0b8bb547b5a86e6 |
9804130507646186aaf7a8982cb40f186a034a24cc30673abdda0bd5d2cc114b |
| After, run 8 | the lane head | f3302e3b7f91eddd405e087d1568b09ef183a46bfdabd7c06396702b5a338aed |
a008a86a17042115808029264bdb57adbda709811908daf83eb413c7d9455a1f |
The server differs between the two "after" builds only in the wording
obsyncd recovery reset prints; the plugin differs in where the settings tab
shows the warning (run 8). Every run used a disposable loopback server, two
isolated desktop Obsidian 1.13.4 profiles on macOS with synthetic vaults, and
real clicks through the plugin's own dialogs. No note content, setup token,
pairing code or recovery word was recorded.
Runs¶
- Setup registers the key with its time. First setup on device A enrolled
the account with its recovery key in the setup frame; the device's first
start registered the same key again (
204, nothing changed). - The only device is held. Leave on A, the account's only active device,
within the hold: the dialog said, in the plugin's words, that the only
device syncing the vault is kept until the key is seven days old, and
offered Cancel and Leave on this device only. The server logged
event=device_revoke_refused decision=refused reason=recovery_too_new recovery_age_ms=74221 budget_ms=604800000and answered409 recovery_too_newin 13 ms. After Cancel, A was still paired and idle. - Every other revoke is unchanged. Device B was paired from A and received
A's note; Leave on B (not the last device) revoked it at once (
204,event=device_revoked). - Recovery with the vault's own key. B, left with its vault key, used Set
up or recover with the setup token:
event=account_recovered, a new active device on the same account, idle. - The operator reset. With the server running,
obsyncd recovery reset planrefused withreason=journal_locked, exit 1, and changed nothing. With the server stopped:planstated the key's registration time and the end of its hold and changed nothing (exit 0);plan --output jsonprinted one object withstateplannedandchangeclear;applycleared the key (decision=cleared, 20 ms, exit 0); a secondapply --output jsonansweredchangenone; a followingplansaid no key is registered. After the server started again, A's next start registered its key again (event=recovery_registered), with a new time. Neither step printed or logged the key. - Journal compatibility. On the journal this build wrote, the 1.1.4
binary's
checkverified 29 frames with none failed and 1.1.4 served it (ready in 137 ms, both 1.1.5 devices unaffected); the 1.1.5checkafterwards verified 32 frames with none failed. The 1.1.4 stop wrote no snapshot (decision=not_due), so the documented case of a 1.1.4 snapshot dropping the registration time was not reached live. - A 1.1.4 plugin meets the hold. With the 1.1.4 plugin on A, now the only device, Leave showed "The server refused to revoke this device:" followed by the server's own sentence about the seven days, and offered Leave on this device only.
- The warning, on a real device. The server's
409 recovery_mismatchanswer was simulated inside A's page (the request never reached the server, whose key was untouched). A showed the security notice, which stays until dismissed; the same text first in Show sync status and in a Security group under Get started in its settings, each with Open the guide; andrecovery decision=refused reason=recovery_mismatch warning=shownin its log. With the simulation removed, the next registration reached the real server, succeeded, and the warning left Show sync status, the settings tab and the screen.
Whole-app sweep¶
- Run 8 on the earlier build showed two defects, both fixed at the lane head and checked again: the warning's settings row, when hidden, left a stray divider above Server URL that 1.1.4 does not draw (the warning now has a group of its own); and the sticky notice stayed on screen after the warning had cleared (it is now taken down with the warning).
- While the warning stands, the status item stays the check mark, because sync itself is healthy; only the notice, Show sync status and the settings tab say it.
- Each Leave dialog, the settings tab and Show sync status carried no stale or contradictory text; the only notices were the ones described above.
- The plugin's console warnings during run 8 were exactly the refusals the
run asked for: the simulated
409 recovery_mismatchwith itswarning=shownline, and the held Leave's409 recovery_too_newwith itsunpair decision=refused reason=recovery_too_newline. No error line.
Re-enrolment after an operator reset¶
An account with no recovery key answers 409 recovery_unavailable to the setup
token, whatever proof comes with it, until the operator resets its recovery.
obsyncd recovery reset apply clears any key, rotates the setup token, and
arms one re-enrolment. The new token with a proof then registers the key that
proof derives, timed so the seven-day hold begins again, and enrols the device,
as an ordinary recovery does otherwise. The server cannot check that proof, so
the authority is the offline reset and the token it rotated; the proof only
chooses the key. The first key registered after the reset spends the arm,
whoever registers it. Nothing a device holds on its own can arm or perform it.
Both runs used a fresh disposable loopback server on the build below, isolated Obsidian 1.13.4 profiles each under its own HOME on macOS, synthetic vaults and real clicks through the plugin's own dialogs. No key, setup token, pairing code or note content was recorded; the token moved only through 0600 files.
obsyncd SHA-256 |
main.js SHA-256 |
|
|---|---|---|
| Lane head | e78d8d7a7ff8371b33b80c9e7bd5f9b5337db539adb3ea2af37de919304bb406 |
b549e1e05876f8bd856c69eb000fa632eac3575f07fc548072a34f26525b1bd2 |
The negative run used an earlier build of this work that differed only in the
wording of recovery reset and of one settings description, both changed
after that run's sweep.
- An account never reset refuses. An account was set up the way a client
before 1.1.3 did, with no recovery key (
201). The setup token with a well-formed proof then answered409 recovery_unavailableover the wire. A third profile, C, used Set up or recover twice: its first attempt made a new key and sent no proof (409 already_set_up, told to pair); its second sent that key's proof (409 recovery_unavailable), stayed unpaired, and showed "This server holds a vault with no recovery key registered, so these words cannot re-enrol this device on their own", naming pairing and the operator's reset. The log held noaccount_recovered,recovery_registeredorrecovery_reestablishedline, and the offlinerecovery reset planafterwards said no recovery key is registered. - The reset rotates and arms. On a fresh server, device A (the owner) set
up with its key registered and wrote a note; device B (a device the owner
does not recognise) paired from A. A then left the server on this device
only, keeping its vault key, and B was stopped. With the server stopped,
recovery reset planstated all three effects and changed nothing;recovery reset apply --output jsonansweredchangecleared,setup_tokenrotated,re_enrolmentarmed, exit 0, and the token file was gone. The next start minted a token different from the one before. - The old token is refused; the new one re-enrols once. On A, Set up or
recover with the token from before the reset showed "This server did not
accept that setup token" and left A unpaired. With the new token, A
re-enrolled in 2.4 s:
event=recovery_registered decision=registered, oneevent=recovery_reestablished decision=reestablishedline naming the account and the arm's time, thenevent=account_recoveredfor a new active device, idle and synced. - The re-enrolled device is live, and B is revoked. B, still active, came
back and pushed the note it had written while A was unpaired, which A had
never held; it reached A 14 ms later. From A, B was revoked
(
event=device_revoked decision=revoked,by_devicethe re-enrolled device). B then showed "This device was removed from your server. Your notes and vault key are safe here." with the attention status item, its local notes untouched. (B was first stopped before its push had left: the status item reads calm before a push starts, so the rig now waits on the note's file record instead.)
Whole-app sweep (re-enrolment)¶
- A: settings idle with no stale text; Show sync status named the re-enrolled device, the vault key present, two files tracked, and nothing recent.
- B: the removed-from-server status and the attention item, and nothing else.
- C: the refusal notice above, the setup token field empty after it.
- The Setup or recover description said recovery must have been registered before the last credential was lost, which the reset now contradicts; it now says the operator resets recovery first when the server has no key. The plan's sentence about arming was split in two. Both were changed after the negative run and are in the lane head above.
- A pairing dialog stayed open on A after the approval (Copy code and Copy link, no code shown) until the rig closed it; the screenshot guard refused to capture while it was open. Not investigated here.
- Each profile bound its own Obsidian CLI socket under its own HOME; teardown left no rig process, listener or state directory and found no setup token in any rig file.
Automated evidence¶
At the lane head: the full plugin suite (1861 tests), the Rust workspace suite
(152 and 487 tests), cargo fmt --check, cargo clippy --all-targets -- -D
warnings and the contract suites (845 tests) passed. All 44 server probes in
scripts/validation/account_recovery_mutations.py compiled and were killed,
among them the ones that drop the arm check, leave the arm unspent after a
registration, skip the token's rotation, rotate before the journal lock or in
a plan, and lose the arm in a journal frame, a snapshot or its reading. The
plugin mutants M3600 to M3621 are reproducible individually with
plugin/test/mutants/run.sh.