feat(recovery): SSSS recovery key — bridges self-sign on startup #19

Merged
jmz merged 1 commit from feat/own-device-recovery into main 2026-06-11 09:16:20 +00:00
Owner

Stacked on #18 (base = feat/own-device-cross-signing). This is Option 1 — the durable fix maximus@nas preferred in jmz/pa#20.

What

Every bridge instance imports the cross-signing secret from server-side secret storage (SSSS) on startup and self-signs its own device. A new bridge host, or one whose store was lost, self-heals on next launch — no manual signing, and the secret survives any single device's store loss (the exact "no SSSS / unrecoverable secret" hole from jmz/pa#20).

How

  • enable-recovery [--passphrase] — one-time, run from the device that holds the cross-signing secret (currently the NAS). Requires a complete identity (cross_signing_status().is_complete()), then recovery().enable() stores the secret in SSSS and prints a recovery key.
  • config — new recovery_key field + resolve_recovery_key() that prefers the MATRIX_BRIDGE_RECOVERY_KEY env var so the key can be SOPS-injected and never lands in config.json plaintext.
  • startupensure_self_signed() runs after the first sync in start_sync() (the MCP server path): no-op if the self-signing secret is already present, else recovery().recover(key) imports it. Best-effort — warns and continues (degraded inbound) rather than aborting if no key is configured or import fails.

Relationship to #18

  • #18 (verify-own-devices): manual one-shot from the secret-holder to sign other own devices. Immediate unblock, no config/SSSS needed.
  • This PR: each device self-signs itself on startup once MATRIX_BRIDGE_RECOVERY_KEY is configured. Durable; the steady state.

They compose. #18 unblocks today; this makes it self-sustaining.

Verification

  • Not built locally (disk constraints on mba) — relying on CI cargo check --all. The new SDK surface (recovery(), Recovery::enable()/Enable::with_passphrase/wait_for_backups_to_upload, recover(), CrossSigningStatus::is_complete) is confirmed against docs.rs (matrix-sdk 0.14): with_passphrase(self, &'a str) -> Self, awaited Enable yields Result<String> (the recovery key).
  • Behavioral assumption to live-verify on the NAS: that recover() self-signs the current device on import (maximus@nas's option-1 premise). If a device imports the secret but doesn't auto-self-sign, a follow-up would add an explicit own-device sign after recover().
  • No new unit tests: resolve_recovery_key is env-dependent (flaky to test), the rest is SDK glue with no mock infra (consistent with the repo).

Rollout

  1. Merge #18, then this.
  2. On the NAS: matrix-bridge enable-recovery → capture the recovery key → SOPS.
  3. Set MATRIX_BRIDGE_RECOVERY_KEY on mba / rpi5 / nas bridge launch env.
  4. Restart bridges → each self-signs on startup.

Refs jmz/pa#20

**Stacked on #18** (base = `feat/own-device-cross-signing`). This is Option 1 — the durable fix maximus@nas preferred in jmz/pa#20. ## What Every bridge instance imports the cross-signing secret from **server-side secret storage (SSSS)** on startup and self-signs its own device. A new bridge host, or one whose store was lost, self-heals on next launch — no manual signing, and the secret survives any single device's store loss (the exact "no SSSS / unrecoverable secret" hole from jmz/pa#20). ## How - **`enable-recovery [--passphrase]`** — one-time, run from the device that holds the cross-signing secret (currently the NAS). Requires a complete identity (`cross_signing_status().is_complete()`), then `recovery().enable()` stores the secret in SSSS and prints a **recovery key**. - **config** — new `recovery_key` field + `resolve_recovery_key()` that **prefers the `MATRIX_BRIDGE_RECOVERY_KEY` env var** so the key can be SOPS-injected and never lands in `config.json` plaintext. - **startup** — `ensure_self_signed()` runs after the first sync in `start_sync()` (the MCP server path): no-op if the self-signing secret is already present, else `recovery().recover(key)` imports it. Best-effort — warns and continues (degraded inbound) rather than aborting if no key is configured or import fails. ## Relationship to #18 - **#18 (`verify-own-devices`)**: manual one-shot from the secret-holder to sign *other* own devices. Immediate unblock, no config/SSSS needed. - **This PR**: each device self-signs *itself* on startup once `MATRIX_BRIDGE_RECOVERY_KEY` is configured. Durable; the steady state. They compose. #18 unblocks today; this makes it self-sustaining. ## Verification - **Not built locally** (disk constraints on mba) — relying on CI `cargo check --all`. The new SDK surface (`recovery()`, `Recovery::enable()`/`Enable::with_passphrase`/`wait_for_backups_to_upload`, `recover()`, `CrossSigningStatus::is_complete`) is confirmed against docs.rs (matrix-sdk 0.14): `with_passphrase(self, &'a str) -> Self`, awaited `Enable` yields `Result<String>` (the recovery key). - **Behavioral assumption to live-verify on the NAS:** that `recover()` self-signs the *current* device on import (maximus@nas's option-1 premise). If a device imports the secret but doesn't auto-self-sign, a follow-up would add an explicit own-device sign after `recover()`. - No new unit tests: `resolve_recovery_key` is env-dependent (flaky to test), the rest is SDK glue with no mock infra (consistent with the repo). ## Rollout 1. Merge #18, then this. 2. On the NAS: `matrix-bridge enable-recovery` → capture the recovery key → SOPS. 3. Set `MATRIX_BRIDGE_RECOVERY_KEY` on mba / rpi5 / nas bridge launch env. 4. Restart bridges → each self-signs on startup. Refs jmz/pa#20
feat(recovery): SSSS recovery key so bridges self-sign on startup
All checks were successful
check / check (pull_request) Successful in 3m13s
check / check (push) Successful in 3m13s
f2c643ed88
verify-own-devices (#18) is a manual one-shot from the secret-holder; this
is the durable fix maximus@nas asked for. Every bridge instance imports the
cross-signing secret from server-side secret storage on startup and
self-signs, so a new host or a lost store self-heals without manual signing.

- `enable-recovery [--passphrase]`: one-time, run from the device holding
  the cross-signing secret. Stores the secret in SSSS (Recovery::enable) and
  prints a recovery key.
- config: `recovery_key` field + `resolve_recovery_key()` preferring the
  MATRIX_BRIDGE_RECOVERY_KEY env var (SOPS-injectable, kept out of
  config.json plaintext).
- startup: `ensure_self_signed()` after the first sync in start_sync —
  no-op if the self-signing secret is present, else recovery().recover()
  imports it. Best-effort: warns (doesn't abort) if no key is configured.

Closes the "no SSSS / unrecoverable secret" hole from jmz/pa#20. Stacked on
feat/own-device-cross-signing (#18).

Refs jmz/pa#20

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jmz changed target branch from feat/own-device-cross-signing to main 2026-06-11 09:16:19 +00:00
jmz merged commit f2c643ed88 into main 2026-06-11 09:16:20 +00:00
Sign in to join this conversation.
No description provided.