From d74e9ff6cbc2021329995b8eb298958056c51ff9 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 5 Jul 2026 09:42:37 +0000 Subject: [PATCH] feat(vault): VaultApi hub sync sub-client (ADR-052 D-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dart client for the zero-knowledge vault endpoints (hub store shipped in #299). Thin transport over the blind blob store — everything is opaque client-encrypted base64 (sealed by services/vault/vault_crypto.dart), the hub never sees plaintext. VaultApi (wired into the HubClient facade as `client.vault`): - pullVault / pushVault — pull the sealed bundle; push with optimistic concurrency (base_version; HubApiError 409 on stale -> pull, re-seal, retry). pullVault returns null on 404 so a fresh client knows to create. - getRecovery / setRecovery / deleteRecovery — the recovery envelope. - listDevices / putDevice / deleteDevice — per-device enrollment + wrapped keys. Mirrors the existing sub-client pattern (AttentionApi etc.); like its siblings it's a thin pass-through validated by flutter analyze + device testing (no live-server unit harness exists for sub-clients). Flutter SDK isn't available locally, so CI is the gate. Co-Authored-By: Claude Opus 4.8 --- lib/services/hub/hub_client.dart | 6 ++ lib/services/hub/vault_api.dart | 113 +++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 lib/services/hub/vault_api.dart diff --git a/lib/services/hub/hub_client.dart b/lib/services/hub/hub_client.dart index 58e8d4d3f..789fbced2 100644 --- a/lib/services/hub/hub_client.dart +++ b/lib/services/hub/hub_client.dart @@ -18,6 +18,7 @@ import 'sessions_api.dart'; import 'system_api.dart'; import 'tasks_api.dart'; import 'templates_api.dart'; +import 'vault_api.dart'; // HubConfig, HubApiError, and HubTransport now live in hub_transport.dart; // re-exported here so the many `import '.../hub_client.dart'` call sites @@ -106,6 +107,11 @@ class HubClient { /// Projects + their channels, channel events, principals, project docs. late final ProjectsApi projects = ProjectsApi(_t); + /// Zero-knowledge SSH key vault (ADR-052 D-4) — blind cross-device sync of + /// the client-encrypted connection+key bundle, its recovery envelope, and + /// per-device wrapped keys. + late final VaultApi vault = VaultApi(_t); + /// Optional read-through cache for list/get responses. Set by the /// provider after construction; forwarded to the transport so the /// sub-clients and the legacy methods share one cache. When null, the diff --git a/lib/services/hub/vault_api.dart b/lib/services/hub/vault_api.dart new file mode 100644 index 000000000..82c921196 --- /dev/null +++ b/lib/services/hub/vault_api.dart @@ -0,0 +1,113 @@ +import 'hub_transport.dart'; + +/// Zero-knowledge SSH key-vault sync (ADR-052 D-4). Thin transport over the +/// hub's blind blob store: everything here is opaque, client-encrypted base64 +/// (sealed by `services/vault/vault_crypto.dart`) — the hub never sees plaintext. +/// +/// Endpoints under `/v1/teams/{team}/vault`: +/// GET / pull the sealed bundle +/// PUT / push it (optimistic concurrency via base_version) +/// GET /recovery fetch the recovery envelope +/// PUT /recovery set/replace it +/// DELETE /recovery clear it +/// GET /devices list enrolled devices +/// PUT /devices/{device} enroll a device / distribute its wrapped key +/// DELETE /devices/{device} revoke a device +class VaultApi { + final HubTransport _t; + VaultApi(this._t); + + String get _base => '/v1/teams/${_t.cfg.teamId}/vault'; + + /// Pull the sealed vault bundle `{ciphertext, version, updated_at}`. + /// Returns null when the principal has none yet (404) so a fresh client + /// knows to create one. + Future?> pullVault() async { + try { + final out = await _t.get(_base); + return (out as Map).cast(); + } on HubApiError catch (e) { + if (e.status == 404) return null; + rethrow; + } + } + + /// Push the sealed bundle with optimistic concurrency. Pass the version you + /// last pulled as [baseVersion] (0 to create). Throws [HubApiError] with + /// status 409 on a stale/duplicate version — pull, re-seal, and retry. + /// Returns `{version, updated_at}`. + Future> pushVault( + String ciphertext, { + required int baseVersion, + }) async { + final out = await _t.put(_base, { + 'ciphertext': ciphertext, + 'base_version': baseVersion, + }); + return (out as Map).cast(); + } + + /// Fetch the recovery envelope `{recovery_envelope, recovery_hint?, updated_at?}`. + /// Returns null when the vault or the envelope is absent (404). + Future?> getRecovery() async { + try { + final out = await _t.get('$_base/recovery'); + return (out as Map).cast(); + } on HubApiError catch (e) { + if (e.status == 404) return null; + rethrow; + } + } + + /// Set/replace the recovery envelope. The vault must already exist + /// (else [HubApiError] 404). Returns `{updated_at}`. + Future> setRecovery( + String envelope, { + String? hint, + }) async { + final body = {'recovery_envelope': envelope}; + if (hint != null && hint.isNotEmpty) body['recovery_hint'] = hint; + final out = await _t.put('$_base/recovery', body); + return (out as Map).cast(); + } + + /// Clear the recovery envelope. + Future deleteRecovery() => _t.delete('$_base/recovery'); + + /// List enrolled devices — each + /// `{device_id, device_name?, public_key, wrapped_key?, created_at, updated_at}`. + Future>> listDevices() async { + final out = await _t.get('$_base/devices'); + if (out == null) return const []; + final devices = (out as Map)['devices']; + if (devices == null) return const []; + return (devices as List).cast>(); + } + + /// Enroll or update a device. A new device sends its [publicKey] + /// (`wrappedKey` empty); an already-enrolled device later sends [wrappedKey] + /// for that `deviceId` to distribute the vault key to it. Returns + /// `{device_id, updated_at}`. + Future> putDevice( + String deviceId, { + String? deviceName, + String? publicKey, + String? wrappedKey, + }) async { + final body = {}; + if (deviceName != null && deviceName.isNotEmpty) { + body['device_name'] = deviceName; + } + if (publicKey != null && publicKey.isNotEmpty) body['public_key'] = publicKey; + if (wrappedKey != null && wrappedKey.isNotEmpty) { + body['wrapped_key'] = wrappedKey; + } + final out = await _t.put('$_base/devices/$deviceId', body); + return (out as Map).cast(); + } + + /// Revoke a device (remove its envelope). The client should re-key the vault + /// afterward (ADR-052 D-4). + Future deleteDevice(String deviceId) => + _t.delete('$_base/devices/$deviceId'); +}