my-durable 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/ai_docs/durable_pi/announcement.md +860 -0
- package/ai_docs/durable_pi/changelog.md +51 -0
- package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
- package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
- package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
- package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
- package/ai_docs/durable_pi/readme.md +617 -0
- package/ai_docs/durable_pi/sources.md +22 -0
- package/ai_docs/durable_pi/spec.md +4696 -0
- package/ai_docs/durable_pi/vacation-planner.md +57 -0
- package/ai_docs/pi/chord.md +268 -0
- package/ai_docs/pi/cli-integration.md +110 -0
- package/ai_docs/pi/cli.md +325 -0
- package/ai_docs/pi/codemode.md +211 -0
- package/ai_docs/pi/compaction.md +467 -0
- package/ai_docs/pi/configuration.md +51 -0
- package/ai_docs/pi/containerization.md +187 -0
- package/ai_docs/pi/custom-provider.md +177 -0
- package/ai_docs/pi/environment-variables.md +102 -0
- package/ai_docs/pi/extensions.md +278 -0
- package/ai_docs/pi/how-pi-works.md +53 -0
- package/ai_docs/pi/index.md +43 -0
- package/ai_docs/pi/json.md +230 -0
- package/ai_docs/pi/keybindings.md +198 -0
- package/ai_docs/pi/llama-cpp.md +118 -0
- package/ai_docs/pi/mcp.md +274 -0
- package/ai_docs/pi/message-types.md +265 -0
- package/ai_docs/pi/models.md +209 -0
- package/ai_docs/pi/overview.md +166 -0
- package/ai_docs/pi/packages.md +133 -0
- package/ai_docs/pi/pi-agent-core.md +568 -0
- package/ai_docs/pi/pi-ai.md +1942 -0
- package/ai_docs/pi/prompt-templates.md +63 -0
- package/ai_docs/pi/providers.md +204 -0
- package/ai_docs/pi/quickstart.md +140 -0
- package/ai_docs/pi/readme.md +99 -0
- package/ai_docs/pi/rpc-commands.md +864 -0
- package/ai_docs/pi/rpc-extension-ui.md +204 -0
- package/ai_docs/pi/rpc.md +197 -0
- package/ai_docs/pi/sdk.md +151 -0
- package/ai_docs/pi/security.md +103 -0
- package/ai_docs/pi/session-format.md +297 -0
- package/ai_docs/pi/sessions.md +70 -0
- package/ai_docs/pi/settings.md +173 -0
- package/ai_docs/pi/shell-aliases.md +97 -0
- package/ai_docs/pi/skills.md +97 -0
- package/ai_docs/pi/slash-commands.md +64 -0
- package/ai_docs/pi/sources.md +57 -0
- package/ai_docs/pi/terminal-setup.md +223 -0
- package/ai_docs/pi/termux.md +121 -0
- package/ai_docs/pi/themes.md +140 -0
- package/ai_docs/pi/tmux.md +59 -0
- package/ai_docs/pi/toolkit.md +168 -0
- package/ai_docs/pi/tui.md +129 -0
- package/ai_docs/pi/usage.md +98 -0
- package/ai_docs/pi/virtual-models.md +118 -0
- package/ai_docs/pi/windows.md +71 -0
- package/apps/dpi-backend/.nvmrc +1 -0
- package/apps/dpi-backend/API.md +99 -0
- package/apps/dpi-backend/README.md +119 -0
- package/apps/dpi-backend/deploy/backend.env.example +9 -0
- package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
- package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
- package/apps/dpi-backend/package.json +33 -0
- package/apps/dpi-backend/prompts/one_sentence.md +3 -0
- package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
- package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
- package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
- package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
- package/apps/dpi-backend/scripts/token.ts +57 -0
- package/apps/dpi-backend/src/addons.ts +139 -0
- package/apps/dpi-backend/src/auth.ts +20 -0
- package/apps/dpi-backend/src/config.ts +54 -0
- package/apps/dpi-backend/src/contract-check.ts +118 -0
- package/apps/dpi-backend/src/errors.ts +50 -0
- package/apps/dpi-backend/src/extensions.ts +510 -0
- package/apps/dpi-backend/src/faux.ts +80 -0
- package/apps/dpi-backend/src/history.ts +165 -0
- package/apps/dpi-backend/src/idempotency.ts +20 -0
- package/apps/dpi-backend/src/jev.ts +43 -0
- package/apps/dpi-backend/src/jobs.ts +790 -0
- package/apps/dpi-backend/src/main.ts +76 -0
- package/apps/dpi-backend/src/model-catalog.ts +180 -0
- package/apps/dpi-backend/src/pairing-files.ts +142 -0
- package/apps/dpi-backend/src/pairing.ts +459 -0
- package/apps/dpi-backend/src/routes.ts +217 -0
- package/apps/dpi-backend/src/shared-state.ts +85 -0
- package/apps/dpi-backend/src/store.ts +1240 -0
- package/apps/dpi-backend/src/streams.ts +256 -0
- package/apps/dpi-backend/src/tunnel.ts +955 -0
- package/apps/dpi-backend/tsconfig.json +13 -0
- package/apps/dpi-playground/README.md +83 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
- package/apps/dpi-playground/package.json +37 -0
- package/apps/dpi-sdk/README.md +74 -0
- package/apps/dpi-sdk/package.json +20 -0
- package/apps/dpi-sdk/scripts/net.ts +73 -0
- package/apps/dpi-sdk/src/api.ts +631 -0
- package/apps/dpi-sdk/src/app-state.ts +111 -0
- package/apps/dpi-sdk/src/client.ts +330 -0
- package/apps/dpi-sdk/src/errors.ts +16 -0
- package/apps/dpi-sdk/src/jobs.ts +99 -0
- package/apps/dpi-sdk/src/models.ts +472 -0
- package/apps/dpi-sdk/src/node/chunk.ts +488 -0
- package/apps/dpi-sdk/src/node/config.ts +175 -0
- package/apps/dpi-sdk/src/node/drivers.ts +402 -0
- package/apps/dpi-sdk/src/node/ops.ts +114 -0
- package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
- package/apps/dpi-sdk/src/node/registry.ts +390 -0
- package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
- package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
- package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
- package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
- package/apps/dpi-sdk/src/sse.ts +117 -0
- package/apps/dpi-sdk/src/transcript.ts +291 -0
- package/apps/dpi-sdk/tsconfig.json +13 -0
- package/apps/dpi-sdk/w +20 -0
- package/apps/dpi-tui/README.md +54 -0
- package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
- package/apps/dpi-tui/package.json +20 -0
- package/apps/dpi-tui/src/app.ts +433 -0
- package/apps/dpi-tui/src/commands.ts +151 -0
- package/apps/dpi-tui/src/main.ts +122 -0
- package/apps/dpi-tui/src/pairing.ts +287 -0
- package/apps/dpi-tui/src/pickers.ts +238 -0
- package/apps/dpi-tui/src/render.ts +164 -0
- package/apps/dpi-tui/src/theme.ts +57 -0
- package/apps/dpi-tui/tsconfig.json +14 -0
- package/bin/device.mjs +5 -0
- package/bin/hub.mjs +5 -0
- package/bin/tui.mjs +5 -0
- package/deploy/Caddyfile +120 -0
- package/docs/verification.md +102 -0
- package/dpi-network.example.yaml +62 -0
- package/images/01_durable_agent_network.svg +185 -0
- package/images/02_pi_made_durable.svg +204 -0
- package/images/03_restart_mid_tool.svg +168 -0
- package/images/07_one_api_every_client.svg +123 -0
- package/images/08_device_session_agent.svg +123 -0
- package/images/09_compact_keep_history.svg +143 -0
- package/images/10_shared_app_state.svg +177 -0
- package/images/11_durable_jobs_wait.svg +141 -0
- package/images/12_tools_change_mid_run.svg +231 -0
- package/images/13_hook_blocks_tool.svg +122 -0
- package/images/14_fork_at_any_entry.svg +119 -0
- package/images/15_web_client.png +0 -0
- package/images/16_terminal_client.svg +43 -0
- package/images/17_devices_are_data.svg +182 -0
- package/images/18_should_every_agent_be_durable.svg +118 -0
- package/images/19_sqlite_on_every_device.svg +155 -0
- package/images/20_admission_and_effects.svg +134 -0
- package/justfile +33 -0
- package/package.json +41 -4
- package/scripts/verify.mjs +210 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routine credential rotation, ACK and cache-loss recovery — P2-19
|
|
3
|
+
* (spec 03 §8.2, 04 §4).
|
|
4
|
+
*
|
|
5
|
+
* Lifecycle:
|
|
6
|
+
* start tx: current→previous, insert current G+1, persist
|
|
7
|
+
* pairings.rotation_id/rotation_target_credential_id/rotation_started_at,
|
|
8
|
+
* audit token.rotate_started; plaintext cached in RAM (bounded+TTL)
|
|
9
|
+
* ONLY for resend — never persisted.
|
|
10
|
+
* deliver control:token-rotate {token, epoch, rotationId, credentialId, generation}
|
|
11
|
+
* over the live tunnel.
|
|
12
|
+
* ack explicit control:token-rotate-ack with an EXACT
|
|
13
|
+
* (rotationId, credentialId, generation, epoch) match → tx:
|
|
14
|
+
* previous→retired, clear rotation fields, audit rotate_completed,
|
|
15
|
+
* cache drop.
|
|
16
|
+
* implicit a reconnect whose handshake matches the TARGET credential +
|
|
17
|
+
* generation completes the rotation exactly like an ack.
|
|
18
|
+
* resend a reconnect on the PREVIOUS credential with the cache intact
|
|
19
|
+
* resends the same rotation; with the cache lost/expired the
|
|
20
|
+
* UNCONFIRMED current is retired and the next generation is minted
|
|
21
|
+
* (previous stays previous — the device's live credential).
|
|
22
|
+
* remind a live connection whose rotation is still pending gets ONE
|
|
23
|
+
* redelivery shortly before the plaintext TTL ends (a failed first
|
|
24
|
+
* deliver() lands here too). The hub never regenerates blind for a
|
|
25
|
+
* connection that merely looks alive: minting G+2 without proof the
|
|
26
|
+
* device is on the previous credential could strand one that
|
|
27
|
+
* persisted G+1, and a wedged device would inflate a generation per
|
|
28
|
+
* TTL. After the TTL, only the reconnect path owns recovery.
|
|
29
|
+
*
|
|
30
|
+
* Stale/duplicate acks never retire or overwrite a newer generation. Timer
|
|
31
|
+
* eviction NEVER retires anything in the DB — only the RAM cache forgets.
|
|
32
|
+
*/
|
|
33
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
34
|
+
import type { NetworkRegistry } from "../../../../dpi-sdk/src/node/registry.js";
|
|
35
|
+
import type { TunnelFrame } from "../../../../dpi-sdk/src/protocol/tunnel.js";
|
|
36
|
+
import { ROTATION_INTERVAL_DAYS } from "../../../../dpi-sdk/src/protocol/limits.js";
|
|
37
|
+
import { Audit } from "./audit.js";
|
|
38
|
+
import { newTunnelToken } from "./tunnel-token.js";
|
|
39
|
+
import type { TunnelServer } from "./tunnel.js";
|
|
40
|
+
|
|
41
|
+
export interface RotationHooks {
|
|
42
|
+
now?(): number;
|
|
43
|
+
/** Cache TTL override (test policy); production 120 s. */
|
|
44
|
+
cacheTtlMs?: number;
|
|
45
|
+
/** RAM cache capacity (bounded). */
|
|
46
|
+
cacheCapacity?: number;
|
|
47
|
+
/** Publish committed metadata changes to open browser streams. */
|
|
48
|
+
onChange?(): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface CacheEntry {
|
|
52
|
+
deviceId: string;
|
|
53
|
+
token: string;
|
|
54
|
+
credentialId: string;
|
|
55
|
+
generation: number;
|
|
56
|
+
epoch: string;
|
|
57
|
+
expiresAt: number;
|
|
58
|
+
/** Ends the entry's life even if nobody ever asks for it again. */
|
|
59
|
+
timer: NodeJS.Timeout;
|
|
60
|
+
/** One last delivery attempt on a still-live connection, shortly before `timer` fires. */
|
|
61
|
+
reminder: NodeJS.Timeout;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Why a rotation was not started. */
|
|
65
|
+
export type RotationRefusal = "unknown_device" | "not_tunnel" | "no_pairing" | "in_flight";
|
|
66
|
+
|
|
67
|
+
export class RotationService {
|
|
68
|
+
private readonly cache = new Map<string, CacheEntry>();
|
|
69
|
+
/**
|
|
70
|
+
* rotationId → the connection the token was last sent over. An ACK only counts from that
|
|
71
|
+
* connection (spec 03 §8.2). Kept apart from the plaintext cache on purpose: a slow device that
|
|
72
|
+
* persisted the token must still be acknowledged after the plaintext has been forgotten. RAM only —
|
|
73
|
+
* a restarted hub has no connections to match, so the next handshake resends or regenerates.
|
|
74
|
+
*/
|
|
75
|
+
private readonly deliveries = new Map<string, string>();
|
|
76
|
+
private readonly now: () => number;
|
|
77
|
+
|
|
78
|
+
constructor(
|
|
79
|
+
private readonly registry: NetworkRegistry,
|
|
80
|
+
private readonly audit: Audit,
|
|
81
|
+
private readonly server: TunnelServer,
|
|
82
|
+
private readonly hooks: RotationHooks = {},
|
|
83
|
+
) {
|
|
84
|
+
this.now = hooks.now ?? Date.now;
|
|
85
|
+
// Reconnect-driven recovery + implicit ack + due-rotation start.
|
|
86
|
+
server.addOpenListener((conn) => this.onHandshake(conn));
|
|
87
|
+
server.addDeviceFrameListener((conn, frame) => {
|
|
88
|
+
if (frame.type === "control" && (frame as { action: string }).action === "token-rotate-ack")
|
|
89
|
+
this.onAck(conn, frame as unknown as Parameters<RotationService["onAck"]>[1]);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Bounded + TTL'd RAM cache; eviction forgets, it never touches the DB. Each entry carries its
|
|
95
|
+
* own timer so the plaintext is gone when the TTL ends, not whenever the next reconnect happens
|
|
96
|
+
* to look at it; the `expiresAt` check in `cacheGet` only backstops a wall clock that jumped.
|
|
97
|
+
* A second, earlier timer is the ONE reminder a still-pending rotation gets on a live
|
|
98
|
+
* connection before its plaintext is forgotten.
|
|
99
|
+
*/
|
|
100
|
+
private cachePut(
|
|
101
|
+
rotationId: string,
|
|
102
|
+
entry: Omit<CacheEntry, "expiresAt" | "timer" | "reminder">,
|
|
103
|
+
): void {
|
|
104
|
+
this.cacheForget(rotationId);
|
|
105
|
+
const cap = this.hooks.cacheCapacity ?? 256;
|
|
106
|
+
while (this.cache.size >= cap) {
|
|
107
|
+
const oldest = this.cache.keys().next().value;
|
|
108
|
+
if (oldest === undefined) break;
|
|
109
|
+
this.cacheForget(oldest);
|
|
110
|
+
}
|
|
111
|
+
const ttl = this.hooks.cacheTtlMs ?? 120_000;
|
|
112
|
+
const timer = setTimeout(() => this.cacheForget(rotationId), ttl);
|
|
113
|
+
timer.unref();
|
|
114
|
+
const reminder = setTimeout(
|
|
115
|
+
() => this.redeliverIfPending(rotationId),
|
|
116
|
+
Math.max(250, ttl - 5_000),
|
|
117
|
+
);
|
|
118
|
+
reminder.unref();
|
|
119
|
+
this.cache.set(rotationId, { ...entry, expiresAt: this.now() + ttl, timer, reminder });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private cacheGet(rotationId: string): CacheEntry | undefined {
|
|
123
|
+
const e = this.cache.get(rotationId);
|
|
124
|
+
if (!e) return undefined;
|
|
125
|
+
if (this.now() > e.expiresAt) {
|
|
126
|
+
this.cacheForget(rotationId); // forget only — never retire on a timer
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
return e;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private cacheForget(rotationId: string): void {
|
|
133
|
+
const e = this.cache.get(rotationId);
|
|
134
|
+
if (!e) return;
|
|
135
|
+
clearTimeout(e.timer);
|
|
136
|
+
clearTimeout(e.reminder);
|
|
137
|
+
this.cache.delete(rotationId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The pre-TTL reminder. Delivers again ONLY while the rotation is still the pairing's live one,
|
|
142
|
+
* the plaintext is still cached and the connection is still live — a no-op otherwise, and never
|
|
143
|
+
* more than once per minted token.
|
|
144
|
+
*/
|
|
145
|
+
private redeliverIfPending(rotationId: string): void {
|
|
146
|
+
const entry = this.cacheGet(rotationId);
|
|
147
|
+
if (!entry) return; // forgotten: only a reconnect may regenerate (never mint blind)
|
|
148
|
+
const row = this.registry.db
|
|
149
|
+
.prepare("SELECT rotation_id FROM pairings WHERE device_id=? AND status='paired'")
|
|
150
|
+
.get(entry.deviceId) as { rotation_id: string | null } | undefined;
|
|
151
|
+
if (row?.rotation_id !== rotationId) return; // acked, replaced or revoked meanwhile
|
|
152
|
+
if (!this.server.hasConnection(entry.deviceId)) return; // offline: the handshake path owns recovery
|
|
153
|
+
this.deliver(
|
|
154
|
+
entry.deviceId,
|
|
155
|
+
rotationId,
|
|
156
|
+
entry.credentialId,
|
|
157
|
+
entry.generation,
|
|
158
|
+
entry.epoch,
|
|
159
|
+
entry.token,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Hub shutdown: drop every cached plaintext and its timer. */
|
|
164
|
+
close(): void {
|
|
165
|
+
for (const id of [...this.cache.keys()]) this.cacheForget(id);
|
|
166
|
+
this.deliveries.clear();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
cacheSize(): number {
|
|
170
|
+
return this.cache.size;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Secret-free sentinel scan helper (tests): no plaintext outside RAM. */
|
|
174
|
+
plaintextCacheHas(rotationId: string): boolean {
|
|
175
|
+
return this.cache.has(rotationId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// -- start ----------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Rotate a device's tunnel credential. Offline devices only record
|
|
182
|
+
* rotation_requested_at — no plaintext is minted until a connection is live
|
|
183
|
+
* (the start happens on its next handshake).
|
|
184
|
+
*/
|
|
185
|
+
startRotation(
|
|
186
|
+
deviceId: string,
|
|
187
|
+
actor: string,
|
|
188
|
+
):
|
|
189
|
+
| { ok: true; mode: "online" | "requested" }
|
|
190
|
+
| { ok: false; code: RotationRefusal; error: string } {
|
|
191
|
+
const device = this.registry.get(deviceId);
|
|
192
|
+
if (!device) return { ok: false, code: "unknown_device", error: `No device ${deviceId}` };
|
|
193
|
+
if (device.transport !== "tunnel")
|
|
194
|
+
return { ok: false, code: "not_tunnel", error: `${deviceId} is not a tunnel device` };
|
|
195
|
+
if (!this.server.hasConnection(deviceId)) {
|
|
196
|
+
// "Requested while offline" is recorded against a live pairing only — an unpaired
|
|
197
|
+
// device has nothing to rotate, and an ok:true would be a lie about what happened.
|
|
198
|
+
const paired = this.registry.db
|
|
199
|
+
.prepare("SELECT 1 AS x FROM pairings WHERE device_id=? AND status='paired'")
|
|
200
|
+
.get(deviceId) as { x: 1 } | undefined;
|
|
201
|
+
if (!paired)
|
|
202
|
+
return { ok: false, code: "no_pairing", error: `${deviceId} has no active pairing` };
|
|
203
|
+
this.audit.withTransaction(() => {
|
|
204
|
+
this.registry.db
|
|
205
|
+
.prepare(
|
|
206
|
+
"UPDATE pairings SET rotation_requested_at=? WHERE device_id=? AND status='paired'",
|
|
207
|
+
)
|
|
208
|
+
.run(this.now(), deviceId);
|
|
209
|
+
this.audit.append({
|
|
210
|
+
actor,
|
|
211
|
+
action: "token.rotate_started",
|
|
212
|
+
outcome: "ok",
|
|
213
|
+
detail: { deviceId, mode: "requested_offline" },
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
this.hooks.onChange?.();
|
|
217
|
+
return { ok: true, mode: "requested" };
|
|
218
|
+
}
|
|
219
|
+
return this.rotateOnline(deviceId, actor);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Mint G+1, swap current→previous, remember the rotation, deliver. */
|
|
223
|
+
private rotateOnline(
|
|
224
|
+
deviceId: string,
|
|
225
|
+
actor: string,
|
|
226
|
+
): { ok: true; mode: "online" } | { ok: false; code: RotationRefusal; error: string } {
|
|
227
|
+
const pairing = this.registry.db
|
|
228
|
+
.prepare(
|
|
229
|
+
"SELECT pairing_epoch, credential_generation, rotation_id FROM pairings WHERE device_id=? AND status='paired'",
|
|
230
|
+
)
|
|
231
|
+
.get(deviceId) as
|
|
232
|
+
| { pairing_epoch: string; credential_generation: number; rotation_id: string | null }
|
|
233
|
+
| undefined;
|
|
234
|
+
if (!pairing)
|
|
235
|
+
return { ok: false, code: "no_pairing", error: `${deviceId} has no active pairing` };
|
|
236
|
+
if (pairing.rotation_id)
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
code: "in_flight",
|
|
240
|
+
error: `${deviceId} already has a rotation in flight`,
|
|
241
|
+
};
|
|
242
|
+
const generation = pairing.credential_generation + 1;
|
|
243
|
+
const credentialId = randomUUID();
|
|
244
|
+
const rotationId = randomUUID();
|
|
245
|
+
const token = newTunnelToken();
|
|
246
|
+
const hash = createHash("sha256").update(token, "utf8").digest("hex");
|
|
247
|
+
const now = this.now();
|
|
248
|
+
this.audit.withTransaction(() => {
|
|
249
|
+
this.registry.db
|
|
250
|
+
.prepare(
|
|
251
|
+
"UPDATE tunnel_credentials SET status='previous', retired_at=NULL WHERE device_id=? AND status='current'",
|
|
252
|
+
)
|
|
253
|
+
.run(deviceId);
|
|
254
|
+
this.registry.db
|
|
255
|
+
.prepare(
|
|
256
|
+
`INSERT INTO tunnel_credentials(credential_id,device_id,pairing_epoch,generation,token_hash,status,created_at)
|
|
257
|
+
VALUES(?,?,?,?,?,'current',?)`,
|
|
258
|
+
)
|
|
259
|
+
.run(credentialId, deviceId, pairing.pairing_epoch, generation, hash, now);
|
|
260
|
+
this.registry.db
|
|
261
|
+
.prepare(
|
|
262
|
+
"UPDATE pairings SET rotation_id=?, rotation_target_credential_id=?, rotation_started_at=?, credential_generation=?, rotation_requested_at=NULL, updated_at=? WHERE device_id=?",
|
|
263
|
+
)
|
|
264
|
+
.run(rotationId, credentialId, now, generation, now, deviceId);
|
|
265
|
+
this.audit.append({
|
|
266
|
+
actor,
|
|
267
|
+
action: "token.rotate_started",
|
|
268
|
+
outcome: "ok",
|
|
269
|
+
detail: { deviceId, rotationId, credentialId, generation },
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
this.cachePut(rotationId, {
|
|
273
|
+
deviceId,
|
|
274
|
+
token,
|
|
275
|
+
credentialId,
|
|
276
|
+
generation,
|
|
277
|
+
epoch: pairing.pairing_epoch,
|
|
278
|
+
});
|
|
279
|
+
this.hooks.onChange?.();
|
|
280
|
+
// A refused send is not fatal: the pre-TTL reminder (cachePut) redelivers while the
|
|
281
|
+
// connection stays live, and the reconnect path owns everything after that.
|
|
282
|
+
this.deliver(deviceId, rotationId, credentialId, generation, pairing.pairing_epoch, token);
|
|
283
|
+
return { ok: true, mode: "online" };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private deliver(
|
|
287
|
+
deviceId: string,
|
|
288
|
+
rotationId: string,
|
|
289
|
+
credentialId: string,
|
|
290
|
+
generation: number,
|
|
291
|
+
epoch: string,
|
|
292
|
+
token: string,
|
|
293
|
+
): boolean {
|
|
294
|
+
const connectionId = this.server.connectionIdOf(deviceId); // read first: a failed send may tear the connection down
|
|
295
|
+
const sent = this.server.send(deviceId, {
|
|
296
|
+
type: "control",
|
|
297
|
+
action: "token-rotate",
|
|
298
|
+
tunnelToken: token,
|
|
299
|
+
pairingEpoch: epoch,
|
|
300
|
+
rotationId,
|
|
301
|
+
credentialId,
|
|
302
|
+
generation,
|
|
303
|
+
});
|
|
304
|
+
if (sent && connectionId) this.rememberDelivery(rotationId, connectionId);
|
|
305
|
+
return sent;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private rememberDelivery(rotationId: string, connectionId: string): void {
|
|
309
|
+
this.deliveries.delete(rotationId); // re-insert at the young end
|
|
310
|
+
const cap = this.hooks.cacheCapacity ?? 256;
|
|
311
|
+
while (this.deliveries.size >= cap) {
|
|
312
|
+
const oldest = this.deliveries.keys().next().value;
|
|
313
|
+
if (oldest === undefined) break;
|
|
314
|
+
this.deliveries.delete(oldest);
|
|
315
|
+
}
|
|
316
|
+
this.deliveries.set(rotationId, connectionId);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// -- handshake-driven recovery ---------------------------------------------
|
|
320
|
+
|
|
321
|
+
/** Called after every valid identity handshake. */
|
|
322
|
+
private onHandshake(conn: { deviceId: string; credentialId: string; generation: number }): void {
|
|
323
|
+
const pairing = this.registry.db
|
|
324
|
+
.prepare(
|
|
325
|
+
"SELECT pairing_epoch, status, credential_generation, rotation_id, rotation_target_credential_id, rotation_started_at, rotation_requested_at, paired_at FROM pairings WHERE device_id=? AND status='paired'",
|
|
326
|
+
)
|
|
327
|
+
.get(conn.deviceId) as
|
|
328
|
+
| {
|
|
329
|
+
pairing_epoch: string;
|
|
330
|
+
credential_generation: number;
|
|
331
|
+
rotation_id: string | null;
|
|
332
|
+
rotation_target_credential_id: string | null;
|
|
333
|
+
rotation_started_at: number | null;
|
|
334
|
+
rotation_requested_at: number | null;
|
|
335
|
+
paired_at: number;
|
|
336
|
+
}
|
|
337
|
+
| undefined;
|
|
338
|
+
if (!pairing) return;
|
|
339
|
+
|
|
340
|
+
// F08: a re-pair must end any rotation intent left over from the OLD
|
|
341
|
+
// epoch. If the recorded target credential is not from the live pairing,
|
|
342
|
+
// the intent is stale: clear it (a UNIQUE crash lurks in its recovery —
|
|
343
|
+
// recoverFromCacheLoss would mint a second 'current') and start clean.
|
|
344
|
+
if (pairing.rotation_id && pairing.rotation_target_credential_id) {
|
|
345
|
+
const target = this.registry.db
|
|
346
|
+
.prepare("SELECT pairing_epoch FROM tunnel_credentials WHERE credential_id=?")
|
|
347
|
+
.get(pairing.rotation_target_credential_id) as { pairing_epoch: string } | undefined;
|
|
348
|
+
if (!target || target.pairing_epoch !== pairing.pairing_epoch) {
|
|
349
|
+
// One transaction: when the audit row cannot be written, the clear must not
|
|
350
|
+
// happen either (a failed append rolls the UPDATE back and the throw is
|
|
351
|
+
// contained by the open-listener loop; the next handshake retries).
|
|
352
|
+
this.audit.withTransaction(() => {
|
|
353
|
+
this.registry.db
|
|
354
|
+
.prepare(
|
|
355
|
+
"UPDATE pairings SET rotation_id=NULL, rotation_target_credential_id=NULL, rotation_started_at=NULL, updated_at=? WHERE device_id=?",
|
|
356
|
+
)
|
|
357
|
+
.run(this.now(), conn.deviceId);
|
|
358
|
+
this.audit.append({
|
|
359
|
+
actor: "hub",
|
|
360
|
+
action: "rotation.stale_cleared",
|
|
361
|
+
outcome: "ok",
|
|
362
|
+
detail: {
|
|
363
|
+
deviceId: conn.deviceId,
|
|
364
|
+
reason: "rotation target predates the current pairing epoch",
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
this.cacheForget(pairing.rotation_id);
|
|
369
|
+
this.deliveries.delete(pairing.rotation_id);
|
|
370
|
+
pairing.rotation_id = null;
|
|
371
|
+
pairing.rotation_target_credential_id = null;
|
|
372
|
+
pairing.rotation_started_at = null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Explicit rotation due (admin request recorded while offline, or the
|
|
377
|
+
// routine 90-day interval): start it now that a connection is live.
|
|
378
|
+
if (!pairing.rotation_id && this.rotationDue(conn.deviceId, pairing)) {
|
|
379
|
+
void this.startRotation(conn.deviceId, "hub");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (!pairing.rotation_id || !pairing.rotation_target_credential_id) return;
|
|
383
|
+
|
|
384
|
+
// IMPLICIT ACK: the device came back speaking the TARGET credential.
|
|
385
|
+
if (
|
|
386
|
+
conn.credentialId === pairing.rotation_target_credential_id &&
|
|
387
|
+
conn.generation === pairing.credential_generation
|
|
388
|
+
) {
|
|
389
|
+
this.completeRotation(
|
|
390
|
+
conn.deviceId,
|
|
391
|
+
pairing.rotation_id,
|
|
392
|
+
pairing.rotation_target_credential_id,
|
|
393
|
+
"implicit_ack",
|
|
394
|
+
);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Device still on the PREVIOUS credential: resend the cached token, or
|
|
399
|
+
// recover from cache loss by retiring the unconfirmed current and minting
|
|
400
|
+
// the next generation (previous stays previous — the device lives on it).
|
|
401
|
+
const cached = pairing.rotation_id ? this.cacheGet(pairing.rotation_id) : undefined;
|
|
402
|
+
if (cached) {
|
|
403
|
+
this.deliver(
|
|
404
|
+
conn.deviceId,
|
|
405
|
+
pairing.rotation_id,
|
|
406
|
+
cached.credentialId,
|
|
407
|
+
cached.generation,
|
|
408
|
+
cached.epoch,
|
|
409
|
+
cached.token,
|
|
410
|
+
);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (conn.credentialId !== pairing.rotation_target_credential_id) {
|
|
414
|
+
this.recoverFromCacheLoss(
|
|
415
|
+
conn.deviceId,
|
|
416
|
+
pairing.rotation_id,
|
|
417
|
+
pairing.rotation_target_credential_id,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The 90-day window is measured from the newest credential the device is known to hold: the
|
|
424
|
+
* pairing itself, or the rotation it last confirmed. Measuring from pairing alone would make
|
|
425
|
+
* every reconnect after day 90 rotate again. `confirmed_at` is written when a rotation completes;
|
|
426
|
+
* a credential minted before that column was maintained falls back to its creation time.
|
|
427
|
+
*/
|
|
428
|
+
private rotationDue(
|
|
429
|
+
deviceId: string,
|
|
430
|
+
pairing: { pairing_epoch: string; paired_at: number; rotation_requested_at: number | null },
|
|
431
|
+
): boolean {
|
|
432
|
+
if (pairing.rotation_requested_at !== null) return true;
|
|
433
|
+
const current = this.registry.db
|
|
434
|
+
.prepare(
|
|
435
|
+
"SELECT COALESCE(confirmed_at, created_at) AS held_since FROM tunnel_credentials WHERE device_id=? AND pairing_epoch=? AND status='current'",
|
|
436
|
+
)
|
|
437
|
+
.get(deviceId, pairing.pairing_epoch) as { held_since: number } | undefined;
|
|
438
|
+
const windowStart = Math.max(pairing.paired_at, current?.held_since ?? 0);
|
|
439
|
+
return this.now() - windowStart >= ROTATION_INTERVAL_DAYS * 86_400_000;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Cache lost/expired: retire the UNCONFIRMED current (it never reached the
|
|
444
|
+
* device), keep the previous (the credential the device still holds) and
|
|
445
|
+
* mint the next generation as the new target.
|
|
446
|
+
*/
|
|
447
|
+
private recoverFromCacheLoss(
|
|
448
|
+
deviceId: string,
|
|
449
|
+
oldRotationId: string | null,
|
|
450
|
+
unconfirmedCredentialId: string | null,
|
|
451
|
+
): void {
|
|
452
|
+
if (!oldRotationId || !unconfirmedCredentialId) return;
|
|
453
|
+
const pairing = this.registry.db
|
|
454
|
+
.prepare(
|
|
455
|
+
"SELECT pairing_epoch, credential_generation FROM pairings WHERE device_id=? AND status='paired'",
|
|
456
|
+
)
|
|
457
|
+
.get(deviceId) as { pairing_epoch: string; credential_generation: number } | undefined;
|
|
458
|
+
if (!pairing) return;
|
|
459
|
+
// Previous = generation G (device holds it); new target = G+2.
|
|
460
|
+
const generation = pairing.credential_generation + 1;
|
|
461
|
+
const credentialId = randomUUID();
|
|
462
|
+
const rotationId = randomUUID();
|
|
463
|
+
const token = newTunnelToken();
|
|
464
|
+
const hash = createHash("sha256").update(token, "utf8").digest("hex");
|
|
465
|
+
const now = this.now();
|
|
466
|
+
this.audit.withTransaction(() => {
|
|
467
|
+
// The unconfirmed current becomes retired (never reached the device).
|
|
468
|
+
this.registry.db
|
|
469
|
+
.prepare(
|
|
470
|
+
"UPDATE tunnel_credentials SET status='retired', retired_at=? WHERE credential_id=? AND status='current'",
|
|
471
|
+
)
|
|
472
|
+
.run(now, unconfirmedCredentialId);
|
|
473
|
+
this.registry.db
|
|
474
|
+
.prepare(
|
|
475
|
+
`INSERT INTO tunnel_credentials(credential_id,device_id,pairing_epoch,generation,token_hash,status,created_at)
|
|
476
|
+
VALUES(?,?,?,?,?,'current',?)`,
|
|
477
|
+
)
|
|
478
|
+
.run(credentialId, deviceId, pairing.pairing_epoch, generation, hash, now);
|
|
479
|
+
this.registry.db
|
|
480
|
+
.prepare(
|
|
481
|
+
"UPDATE pairings SET rotation_id=?, rotation_target_credential_id=?, rotation_started_at=?, credential_generation=?, updated_at=? WHERE device_id=?",
|
|
482
|
+
)
|
|
483
|
+
.run(rotationId, credentialId, now, generation, now, deviceId);
|
|
484
|
+
this.audit.append({
|
|
485
|
+
actor: "hub",
|
|
486
|
+
action: "token.rotate_regenerated",
|
|
487
|
+
outcome: "ok",
|
|
488
|
+
detail: {
|
|
489
|
+
deviceId,
|
|
490
|
+
oldRotationId: oldRotationId,
|
|
491
|
+
rotationId,
|
|
492
|
+
credentialId,
|
|
493
|
+
generation,
|
|
494
|
+
reason: "cache_loss",
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
this.cacheForget(oldRotationId);
|
|
499
|
+
this.deliveries.delete(oldRotationId);
|
|
500
|
+
this.cachePut(rotationId, {
|
|
501
|
+
deviceId,
|
|
502
|
+
token,
|
|
503
|
+
credentialId,
|
|
504
|
+
generation,
|
|
505
|
+
epoch: pairing.pairing_epoch,
|
|
506
|
+
});
|
|
507
|
+
this.hooks.onChange?.();
|
|
508
|
+
this.deliver(deviceId, rotationId, credentialId, generation, pairing.pairing_epoch, token);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// -- ack ---------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
private onAck(
|
|
514
|
+
conn: { deviceId: string; connectionId: string },
|
|
515
|
+
ack: Extract<TunnelFrame, { type: "control" }> & {
|
|
516
|
+
action: "token-rotate-ack";
|
|
517
|
+
rotationId: string;
|
|
518
|
+
credentialId: string;
|
|
519
|
+
generation: number;
|
|
520
|
+
pairingEpoch: string;
|
|
521
|
+
},
|
|
522
|
+
): void {
|
|
523
|
+
const pairing = this.registry.db
|
|
524
|
+
.prepare(
|
|
525
|
+
"SELECT pairing_epoch, credential_generation, rotation_id, rotation_target_credential_id FROM pairings WHERE device_id=? AND status='paired'",
|
|
526
|
+
)
|
|
527
|
+
.get(conn.deviceId) as
|
|
528
|
+
| {
|
|
529
|
+
pairing_epoch: string;
|
|
530
|
+
credential_generation: number;
|
|
531
|
+
rotation_id: string | null;
|
|
532
|
+
rotation_target_credential_id: string | null;
|
|
533
|
+
}
|
|
534
|
+
| undefined;
|
|
535
|
+
if (!pairing || !pairing.rotation_id) return; // stale/duplicate ack: ignore
|
|
536
|
+
// EXACT match required; anything else is ignored — never retire/overwrite.
|
|
537
|
+
if (
|
|
538
|
+
ack.pairingEpoch !== pairing.pairing_epoch ||
|
|
539
|
+
ack.rotationId !== pairing.rotation_id ||
|
|
540
|
+
ack.credentialId !== pairing.rotation_target_credential_id ||
|
|
541
|
+
ack.generation !== pairing.credential_generation
|
|
542
|
+
) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
// ...and it must come from the connection the token was sent over.
|
|
546
|
+
if (this.deliveries.get(ack.rotationId) !== conn.connectionId) return;
|
|
547
|
+
this.completeRotation(conn.deviceId, ack.rotationId, ack.credentialId, "ack");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** One completion tx: retire previous, clear rotation, audit, drop cache. */
|
|
551
|
+
private completeRotation(
|
|
552
|
+
deviceId: string,
|
|
553
|
+
rotationId: string,
|
|
554
|
+
targetCredentialId: string,
|
|
555
|
+
via: "ack" | "implicit_ack",
|
|
556
|
+
): void {
|
|
557
|
+
const now = this.now();
|
|
558
|
+
this.audit.withTransaction(() => {
|
|
559
|
+
this.registry.db
|
|
560
|
+
.prepare(
|
|
561
|
+
"UPDATE tunnel_credentials SET status='retired', retired_at=? WHERE device_id=? AND status='previous'",
|
|
562
|
+
)
|
|
563
|
+
.run(now, deviceId);
|
|
564
|
+
this.registry.db
|
|
565
|
+
.prepare("UPDATE tunnel_credentials SET confirmed_at=? WHERE credential_id=?")
|
|
566
|
+
.run(now, targetCredentialId);
|
|
567
|
+
this.registry.db
|
|
568
|
+
.prepare(
|
|
569
|
+
"UPDATE pairings SET rotation_id=NULL, rotation_target_credential_id=NULL, rotation_started_at=NULL, updated_at=? WHERE device_id=?",
|
|
570
|
+
)
|
|
571
|
+
.run(now, deviceId);
|
|
572
|
+
this.audit.append({
|
|
573
|
+
actor: "hub",
|
|
574
|
+
action: "token.rotate_completed",
|
|
575
|
+
outcome: "ok",
|
|
576
|
+
detail: { deviceId, rotationId, credentialId: targetCredentialId, via },
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
this.cacheForget(rotationId);
|
|
580
|
+
this.deliveries.delete(rotationId);
|
|
581
|
+
this.hooks.onChange?.();
|
|
582
|
+
}
|
|
583
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// At boot: load dpi-network.yaml (if any) into the registry. The UI never edits devices;
|
|
2
|
+
// `just net register <id>` sets their tokens.
|
|
3
|
+
import { tryLoadNetworkConfig, syncConfigToRegistry } from "../../../../dpi-sdk/src/node/config.js";
|
|
4
|
+
import type { NetworkRegistry } from "../../../../dpi-sdk/src/node/registry.js";
|
|
5
|
+
|
|
6
|
+
export function seedFromConfig(registry: NetworkRegistry, start: string) {
|
|
7
|
+
const config = tryLoadNetworkConfig(start);
|
|
8
|
+
if (!config) return undefined;
|
|
9
|
+
syncConfigToRegistry(config, registry);
|
|
10
|
+
return config.file;
|
|
11
|
+
}
|