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,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential issuance service (spec 03 §8.1, 07 §5.2, plan P2-10).
|
|
3
|
+
*
|
|
4
|
+
* The PairingService calls issue() exactly once per approved enrollment.
|
|
5
|
+
* Everything secret is prepared in RAM first; then ONE synchronous SQLite
|
|
6
|
+
* transaction writes devices + pairings + tunnel_credentials + the audit row
|
|
7
|
+
* and only afterwards does the TokenIssue (with plaintext token) leave this
|
|
8
|
+
* process. node:sqlite is synchronous, so check/consume/commit cannot
|
|
9
|
+
* interleave in-process.
|
|
10
|
+
*
|
|
11
|
+
* - The device never chooses its registry ID: unique device_uid reuses the
|
|
12
|
+
* existing row, fresh identities get a stripped-UUID id.
|
|
13
|
+
* - Re-pair revokes EVERY credential of the old epoch (tombstones retained)
|
|
14
|
+
* and starts a new epoch at generation 1.
|
|
15
|
+
* - Receipt cache validation: a cached TokenIssue is only served again while
|
|
16
|
+
* its credential is still current/previous AND its epoch is still the
|
|
17
|
+
* active pairing. Revoked/re-paired → never return the stale secret.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
20
|
+
import type { NetworkRegistry } from "../../../../dpi-sdk/src/node/registry.js";
|
|
21
|
+
import type { Audit } from "./audit.js";
|
|
22
|
+
import { observeIdentity } from "./identity.js";
|
|
23
|
+
import { newTunnelToken } from "./tunnel-token.js";
|
|
24
|
+
import type { TokenIssue } from "../../../../dpi-sdk/src/protocol/pairing.js";
|
|
25
|
+
|
|
26
|
+
export interface IssueInput {
|
|
27
|
+
enrollmentId: string;
|
|
28
|
+
codeId: string;
|
|
29
|
+
deviceUid: string;
|
|
30
|
+
deviceName: string;
|
|
31
|
+
publicKeyPem: string;
|
|
32
|
+
keyFingerprint: string;
|
|
33
|
+
replaceKey: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* `observeIdentity().stamp` the operator approved against. Re-checked inside the issuance
|
|
36
|
+
* transaction: if the identity moved since (revoke, re-pair, key replacement), nothing is issued.
|
|
37
|
+
* PairingService always supplies it; omit only for trusted in-process seeding.
|
|
38
|
+
*/
|
|
39
|
+
expectedIdentity?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type IssueFailPoint = "preflight" | "after-prepare" | "after-commit";
|
|
43
|
+
|
|
44
|
+
const UUID_CANONICAL = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
45
|
+
|
|
46
|
+
export class CredentialService {
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly registry: NetworkRegistry,
|
|
49
|
+
private readonly audit: Audit,
|
|
50
|
+
private readonly hubOrigin: string,
|
|
51
|
+
private readonly hubId: string,
|
|
52
|
+
private readonly failAt?: IssueFailPoint,
|
|
53
|
+
) {}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve + validate identity BEFORE any mutation. Throws identity_conflict
|
|
57
|
+
* for duplicate UID rows or non-canonical UIDs (operator resolves manually).
|
|
58
|
+
*/
|
|
59
|
+
private preflight(deviceUid: string): { existingRowId?: string; newId: string } {
|
|
60
|
+
if (!UUID_CANONICAL.test(deviceUid))
|
|
61
|
+
throw Object.assign(new Error(`non-canonical deviceUid ${deviceUid}`), {
|
|
62
|
+
code: "identity_conflict",
|
|
63
|
+
});
|
|
64
|
+
const rows = this.registry.db
|
|
65
|
+
.prepare("SELECT id FROM devices WHERE device_uid=?")
|
|
66
|
+
.all(deviceUid) as { id: string }[];
|
|
67
|
+
if (rows.length > 1)
|
|
68
|
+
throw Object.assign(
|
|
69
|
+
new Error(`device_uid ${deviceUid} maps to ${rows.length} rows; operator must resolve`),
|
|
70
|
+
{ code: "identity_conflict" },
|
|
71
|
+
);
|
|
72
|
+
const newId = randomUUID().replace(/-/g, "");
|
|
73
|
+
if (this.registry.get(newId))
|
|
74
|
+
throw Object.assign(new Error(`registry id collision on ${newId}`), {
|
|
75
|
+
code: "identity_conflict",
|
|
76
|
+
});
|
|
77
|
+
return { existingRowId: rows[0]?.id, newId };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async issue(input: IssueInput): Promise<TokenIssue> {
|
|
81
|
+
const identity = this.preflight(input.deviceUid);
|
|
82
|
+
if (this.failAt === "preflight")
|
|
83
|
+
throw Object.assign(new Error("injected: preflight"), { code: "injected" });
|
|
84
|
+
|
|
85
|
+
// ---- prepare everything in RAM (secrets never touch the DB as plaintext)
|
|
86
|
+
const token = newTunnelToken();
|
|
87
|
+
const tokenHash = createHash("sha256").update(token).digest("hex");
|
|
88
|
+
const credentialId = randomUUID();
|
|
89
|
+
const pairingEpoch = randomUUID();
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const deviceId = identity.existingRowId ?? identity.newId;
|
|
92
|
+
|
|
93
|
+
if (this.failAt === "after-prepare")
|
|
94
|
+
throw Object.assign(new Error("injected: after-prepare"), { code: "injected" });
|
|
95
|
+
|
|
96
|
+
// ---- one synchronous transaction: identity + pairing + credential + audit
|
|
97
|
+
this.audit.withTransaction(() => {
|
|
98
|
+
// GA-05: judged inside the write lock, against what the operator actually approved.
|
|
99
|
+
const current = observeIdentity(this.registry, input.deviceUid);
|
|
100
|
+
if (input.expectedIdentity !== undefined && input.expectedIdentity !== current.stamp)
|
|
101
|
+
throw Object.assign(
|
|
102
|
+
new Error("device identity changed since approval; a new approval is required"),
|
|
103
|
+
{ code: "identity_changed" },
|
|
104
|
+
);
|
|
105
|
+
const replacedKeyFingerprint =
|
|
106
|
+
current.storedFingerprint && current.storedFingerprint !== input.keyFingerprint
|
|
107
|
+
? current.storedFingerprint
|
|
108
|
+
: undefined;
|
|
109
|
+
if (replacedKeyFingerprint && !input.replaceKey)
|
|
110
|
+
throw Object.assign(
|
|
111
|
+
new Error("a different key is on record for this device; replaceKey is required"),
|
|
112
|
+
{ code: "key_replacement_required" },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (identity.existingRowId) {
|
|
116
|
+
const row = this.registry.get(identity.existingRowId)!;
|
|
117
|
+
if (row.transport === "lan") {
|
|
118
|
+
// Converting a LAN row: identity stays, endpoint/token are cleared,
|
|
119
|
+
// and the audit row records the conversion for the operator.
|
|
120
|
+
this.registry.setTransportTunnel(identity.existingRowId);
|
|
121
|
+
} else {
|
|
122
|
+
// Re-pair over an existing tunnel row: retire the old epoch first.
|
|
123
|
+
this.registry.db
|
|
124
|
+
.prepare(
|
|
125
|
+
`UPDATE tunnel_credentials SET status='revoked', revoked_at=? WHERE device_id=? AND status IN ('current','previous','retired')`,
|
|
126
|
+
)
|
|
127
|
+
.run(now, identity.existingRowId);
|
|
128
|
+
this.registry.db
|
|
129
|
+
.prepare(
|
|
130
|
+
`UPDATE pairings SET status='revoked', updated_at=? WHERE device_id=? AND status='paired'`,
|
|
131
|
+
)
|
|
132
|
+
.run(now, identity.existingRowId);
|
|
133
|
+
}
|
|
134
|
+
this.registry.db
|
|
135
|
+
.prepare("UPDATE devices SET name=?, updated_at=? WHERE id=?")
|
|
136
|
+
.run(input.deviceName, now, identity.existingRowId);
|
|
137
|
+
} else {
|
|
138
|
+
this.registry.db
|
|
139
|
+
.prepare(
|
|
140
|
+
`INSERT INTO devices(id,name,kind,url,token,source,position,device_uid,last_status,created_at,updated_at,transport)
|
|
141
|
+
VALUES(?,?,'url',NULL,NULL,'ui',0,?,'checking',?,?,'tunnel')`,
|
|
142
|
+
)
|
|
143
|
+
.run(deviceId, input.deviceName || deviceId, input.deviceUid, now, now);
|
|
144
|
+
}
|
|
145
|
+
// pairings is one row per device (PK): upsert the CURRENT epoch.
|
|
146
|
+
this.registry.db
|
|
147
|
+
.prepare(
|
|
148
|
+
`INSERT INTO pairings(device_id,pairing_epoch,public_key,key_fingerprint,status,credential_generation,paired_at,created_at,updated_at)
|
|
149
|
+
VALUES(?,?,?,?,'paired',1,?,?,?)
|
|
150
|
+
ON CONFLICT(device_id) DO UPDATE SET pairing_epoch=excluded.pairing_epoch,
|
|
151
|
+
public_key=excluded.public_key, key_fingerprint=excluded.key_fingerprint,
|
|
152
|
+
status='paired', credential_generation=1, paired_at=excluded.paired_at, updated_at=excluded.updated_at,
|
|
153
|
+
rotation_id=NULL, rotation_target_credential_id=NULL, rotation_started_at=NULL, rotation_requested_at=NULL`,
|
|
154
|
+
)
|
|
155
|
+
.run(deviceId, pairingEpoch, input.publicKeyPem, input.keyFingerprint, now, now, now);
|
|
156
|
+
this.registry.db
|
|
157
|
+
.prepare(
|
|
158
|
+
`INSERT INTO tunnel_credentials(credential_id,device_id,pairing_epoch,generation,token_hash,status,created_at)
|
|
159
|
+
VALUES(?,?,?,1,?,'current',?)`,
|
|
160
|
+
)
|
|
161
|
+
.run(credentialId, deviceId, pairingEpoch, tokenHash, now);
|
|
162
|
+
this.audit.append({
|
|
163
|
+
actor: "hub",
|
|
164
|
+
action: "pair.success",
|
|
165
|
+
outcome: "ok",
|
|
166
|
+
detail: {
|
|
167
|
+
codeId: input.codeId,
|
|
168
|
+
enrollmentId: input.enrollmentId,
|
|
169
|
+
deviceId,
|
|
170
|
+
pairingEpoch,
|
|
171
|
+
credentialId,
|
|
172
|
+
keyFingerprint: input.keyFingerprint,
|
|
173
|
+
replaceKey: input.replaceKey,
|
|
174
|
+
...(replacedKeyFingerprint ? { replacedKeyFingerprint } : {}),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (this.failAt === "after-commit")
|
|
180
|
+
throw Object.assign(new Error("injected: after-commit"), { code: "injected" });
|
|
181
|
+
|
|
182
|
+
// Response precomputed above; returned only after the commit succeeded.
|
|
183
|
+
return {
|
|
184
|
+
type: "pair.token",
|
|
185
|
+
v: 1,
|
|
186
|
+
enrollmentId: input.enrollmentId,
|
|
187
|
+
deviceUid: input.deviceUid,
|
|
188
|
+
deviceId,
|
|
189
|
+
pairingEpoch,
|
|
190
|
+
credentialId,
|
|
191
|
+
generation: 1,
|
|
192
|
+
hubId: this.hubId,
|
|
193
|
+
hubOrigin: this.hubOrigin,
|
|
194
|
+
hubUrl: `${this.hubOrigin}/api/tunnel`,
|
|
195
|
+
tunnelToken: token,
|
|
196
|
+
issuedAt: now,
|
|
197
|
+
expiresAt: null,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Durable revoke (03 §8.3, P2-20): pairing paired→revoked, EVERY credential
|
|
203
|
+
* hash (current/previous/retired) → revoked with tombstones retained, one
|
|
204
|
+
* audit row, all in ONE transaction. The caller is responsible for cutting
|
|
205
|
+
* the live transport right after commit — authorization is already dead:
|
|
206
|
+
* the upgrade path consults these rows directly, so a revoked bearer is
|
|
207
|
+
* 401 within the same tick, notice ACK or not.
|
|
208
|
+
*/
|
|
209
|
+
revoke(deviceId: string, reason: "user" | "compromise" | "hub_migrated", actor: string): void {
|
|
210
|
+
const now = Date.now();
|
|
211
|
+
const pairing = this.registry.db
|
|
212
|
+
.prepare("SELECT pairing_epoch, status FROM pairings WHERE device_id=?")
|
|
213
|
+
.get(deviceId) as { pairing_epoch: string; status: string } | undefined;
|
|
214
|
+
if (!pairing || pairing.status !== "paired")
|
|
215
|
+
throw Object.assign(new Error(`No active pairing for ${deviceId}`), { status: 404 });
|
|
216
|
+
this.audit.withTransaction(() => {
|
|
217
|
+
this.registry.db
|
|
218
|
+
.prepare("UPDATE pairings SET status='revoked', updated_at=? WHERE device_id=?")
|
|
219
|
+
.run(now, deviceId);
|
|
220
|
+
this.registry.db
|
|
221
|
+
.prepare(
|
|
222
|
+
"UPDATE tunnel_credentials SET status='revoked', revoked_at=? WHERE device_id=? AND status IN ('current','previous','retired')",
|
|
223
|
+
)
|
|
224
|
+
.run(now, deviceId);
|
|
225
|
+
this.registry.db.prepare("DELETE FROM tunnel_state WHERE device_id=?").run(deviceId);
|
|
226
|
+
this.audit.append({
|
|
227
|
+
actor,
|
|
228
|
+
action: "pair.revoke",
|
|
229
|
+
outcome: "ok",
|
|
230
|
+
detail: { deviceId, pairingEpoch: pairing.pairing_epoch, reason },
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A cached TokenIssue may only be replayed while its credential is still
|
|
237
|
+
* current/previous AND its epoch is the active pairing. Never return a
|
|
238
|
+
* revoked or superseded secret just because the receipt TTL is still open.
|
|
239
|
+
*/
|
|
240
|
+
receiptStillValid(
|
|
241
|
+
tokenIssue: Pick<TokenIssue, "credentialId" | "pairingEpoch" | "deviceId">,
|
|
242
|
+
): boolean {
|
|
243
|
+
const cred = this.registry.db
|
|
244
|
+
.prepare("SELECT status FROM tunnel_credentials WHERE credential_id=?")
|
|
245
|
+
.get(tokenIssue.credentialId) as { status: string } | undefined;
|
|
246
|
+
if (!cred || !["current", "previous"].includes(cred.status)) return false;
|
|
247
|
+
const pairing = this.registry.db
|
|
248
|
+
.prepare("SELECT status, pairing_epoch FROM pairings WHERE device_id=?")
|
|
249
|
+
.get(tokenIssue.deviceId) as { status: string; pairing_epoch: string } | undefined;
|
|
250
|
+
return (
|
|
251
|
+
!!pairing && pairing.status === "paired" && pairing.pairing_epoch === tokenIssue.pairingEpoch
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Which failure to surface for a stale receipt. */
|
|
256
|
+
receiptFailure(
|
|
257
|
+
tokenIssue: Pick<TokenIssue, "pairingEpoch" | "deviceId">,
|
|
258
|
+
): "paired_elsewhere" | "invalid" {
|
|
259
|
+
const pairing = this.registry.db
|
|
260
|
+
.prepare("SELECT status, pairing_epoch FROM pairings WHERE device_id=?")
|
|
261
|
+
.get(tokenIssue.deviceId) as { status: string; pairing_epoch: string } | undefined;
|
|
262
|
+
if (pairing && pairing.pairing_epoch !== tokenIssue.pairingEpoch) return "paired_elsewhere";
|
|
263
|
+
return "invalid";
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RequestHandler } from "express";
|
|
2
|
+
import { viaTls } from "./client-ip.js";
|
|
3
|
+
|
|
4
|
+
const err = (code: string, message: string) => ({ error: { code, message } });
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The hub holds every device token and has no login of its own, so it only answers requests
|
|
8
|
+
* whose Host is one it serves (blocks DNS rebinding) from its own origin (blocks CSRF), and
|
|
9
|
+
* only accepts JSON writes (forces a CORS preflight that cross-origin pages cannot pass).
|
|
10
|
+
*/
|
|
11
|
+
export function guard(allowedHosts: Set<string>): RequestHandler {
|
|
12
|
+
return (req, res, next) => {
|
|
13
|
+
const host = (req.headers.host ?? "").toLowerCase();
|
|
14
|
+
if (!allowedHosts.has(host))
|
|
15
|
+
return void res.status(421).json(err("invalid", `Unknown Host header: ${host}`));
|
|
16
|
+
const origin = req.headers.origin;
|
|
17
|
+
if (origin) {
|
|
18
|
+
let originHost = "";
|
|
19
|
+
try {
|
|
20
|
+
originHost = new URL(origin).host.toLowerCase();
|
|
21
|
+
} catch {}
|
|
22
|
+
if (originHost !== host)
|
|
23
|
+
return void res
|
|
24
|
+
.status(403)
|
|
25
|
+
.json(err("unauthorized", "Cross-origin requests are not allowed"));
|
|
26
|
+
}
|
|
27
|
+
if (["POST", "PUT", "PATCH"].includes(req.method) && !req.is("application/json"))
|
|
28
|
+
return void res.status(415).json(err("invalid", "JSON required"));
|
|
29
|
+
next();
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Second line behind the TLS proxy (spec 05 §4.2): refuse whatever cannot show it came through it
|
|
35
|
+
* over TLS. A plain-HTTP request, a LAN peer that sets its own X-Forwarded-Proto and a local process
|
|
36
|
+
* that skipped the proxy all end here instead of being served as if they were secure.
|
|
37
|
+
*/
|
|
38
|
+
export function requireTls(): RequestHandler {
|
|
39
|
+
return (req, res, next) => {
|
|
40
|
+
if (viaTls(req)) return void next();
|
|
41
|
+
res.status(403).json(err("tls_required", "This hub is only served over HTTPS"));
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One error contract for the whole HTTP surface: `{ error: { code, message } }` with a stable
|
|
3
|
+
* code and a short message. Framework error pages (stack frames, module paths, absolute paths)
|
|
4
|
+
* never reach a client, and an unexpected fault is a generic 500 whose detail stays in the log.
|
|
5
|
+
*/
|
|
6
|
+
import type { ErrorRequestHandler, Request, RequestHandler, Response } from "express";
|
|
7
|
+
import { AuditUnavailableError, scrubSecrets } from "./audit.js";
|
|
8
|
+
|
|
9
|
+
export interface HttpFailure {
|
|
10
|
+
status: number;
|
|
11
|
+
code: string;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CODE_BY_STATUS: Record<number, string> = {
|
|
16
|
+
400: "invalid",
|
|
17
|
+
401: "unauthorized",
|
|
18
|
+
403: "forbidden",
|
|
19
|
+
404: "not_found",
|
|
20
|
+
405: "method_not_allowed",
|
|
21
|
+
409: "conflict",
|
|
22
|
+
410: "gone",
|
|
23
|
+
413: "payload_too_large",
|
|
24
|
+
415: "unsupported_media_type",
|
|
25
|
+
421: "misdirected",
|
|
26
|
+
429: "rate_limited",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const MESSAGE_BY_STATUS: Record<number, string> = {
|
|
30
|
+
400: "Bad request",
|
|
31
|
+
401: "Unauthorized",
|
|
32
|
+
403: "Forbidden",
|
|
33
|
+
404: "Not found",
|
|
34
|
+
405: "Method not allowed",
|
|
35
|
+
409: "Conflict",
|
|
36
|
+
410: "Gone",
|
|
37
|
+
413: "Request body is too large",
|
|
38
|
+
415: "Unsupported media type",
|
|
39
|
+
421: "Misdirected request",
|
|
40
|
+
429: "Too many requests; slow down",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const MESSAGE_MAX = 200;
|
|
44
|
+
const LOG_INTERVAL_MS = 10_000;
|
|
45
|
+
let loggedAt = 0;
|
|
46
|
+
|
|
47
|
+
interface Thrown {
|
|
48
|
+
status?: unknown;
|
|
49
|
+
statusCode?: unknown;
|
|
50
|
+
type?: unknown;
|
|
51
|
+
expose?: unknown;
|
|
52
|
+
message?: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const clientStatus = (n: unknown): number | undefined =>
|
|
56
|
+
typeof n === "number" && Number.isInteger(n) && n >= 400 && n <= 499 ? n : undefined;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Map anything thrown to a client-safe failure.
|
|
60
|
+
*
|
|
61
|
+
* - Audit storage refusal → 503 `audit_unavailable`.
|
|
62
|
+
* - Body-parser / router / static errors (they carry `type` or `expose`) → fixed text per cause or
|
|
63
|
+
* status, never the library's own message.
|
|
64
|
+
* - Errors our code raises on purpose with a 4xx `status` → their (short, scrubbed) message.
|
|
65
|
+
* - Anything else → `plainStatus`: a 4xx keeps the message (handlers that validate input by
|
|
66
|
+
* throwing); the default 500 hides it and logs it.
|
|
67
|
+
*/
|
|
68
|
+
export function describeFailure(e: unknown, plainStatus = 500): HttpFailure {
|
|
69
|
+
if (e instanceof AuditUnavailableError)
|
|
70
|
+
return {
|
|
71
|
+
status: 503,
|
|
72
|
+
code: "audit_unavailable",
|
|
73
|
+
message: "Audit storage is unavailable; the change was not applied",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const t = (e && typeof e === "object" ? e : {}) as Thrown;
|
|
77
|
+
switch (t.type) {
|
|
78
|
+
case "entity.parse.failed":
|
|
79
|
+
case "entity.verify.failed":
|
|
80
|
+
return { status: 400, code: "invalid_json", message: "Request body is not valid JSON" };
|
|
81
|
+
case "entity.too.large":
|
|
82
|
+
return { status: 413, code: "payload_too_large", message: MESSAGE_BY_STATUS[413]! };
|
|
83
|
+
case "charset.unsupported":
|
|
84
|
+
case "encoding.unsupported":
|
|
85
|
+
return {
|
|
86
|
+
status: 415,
|
|
87
|
+
code: "unsupported_media_type",
|
|
88
|
+
message: "Unsupported request encoding",
|
|
89
|
+
};
|
|
90
|
+
case "request.aborted":
|
|
91
|
+
case "request.size.invalid":
|
|
92
|
+
case "parameters.too.many":
|
|
93
|
+
return { status: 400, code: "invalid", message: "Request body could not be read" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const status = clientStatus(t.status) ?? clientStatus(t.statusCode);
|
|
97
|
+
// body-parser/send/http-errors set `type` or `expose`; the router flags an undecodable path param as a URIError.
|
|
98
|
+
const fromFramework =
|
|
99
|
+
typeof t.type === "string" || typeof t.expose === "boolean" || e instanceof URIError;
|
|
100
|
+
if (status && fromFramework)
|
|
101
|
+
return {
|
|
102
|
+
status,
|
|
103
|
+
code: CODE_BY_STATUS[status] ?? "invalid",
|
|
104
|
+
message: MESSAGE_BY_STATUS[status] ?? MESSAGE_BY_STATUS[400]!,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const own = status ?? clientStatus(plainStatus);
|
|
108
|
+
if (own) {
|
|
109
|
+
const text =
|
|
110
|
+
typeof t.message === "string" && t.message
|
|
111
|
+
? t.message
|
|
112
|
+
: (MESSAGE_BY_STATUS[own] ?? "Bad request");
|
|
113
|
+
const code = own === 404 ? "not_found" : (CODE_BY_STATUS[own] ?? "invalid");
|
|
114
|
+
return {
|
|
115
|
+
status: own,
|
|
116
|
+
code: own === 400 ? "invalid" : code,
|
|
117
|
+
message: scrubSecrets(text).slice(0, MESSAGE_MAX),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
logUnexpected(e);
|
|
122
|
+
return { status: 500, code: "internal", message: "Internal error" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function logUnexpected(e: unknown): void {
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
if (now - loggedAt < LOG_INTERVAL_MS) return;
|
|
128
|
+
loggedAt = now;
|
|
129
|
+
const text = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
|
130
|
+
console.error(`hub: unhandled request error: ${scrubSecrets(text).slice(0, MESSAGE_MAX)}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const body = (f: HttpFailure) => ({ error: { code: f.code, message: f.message } });
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Wrap a route handler so that whatever it throws is answered in the contract above. A handler
|
|
137
|
+
* that rejects on purpose carries a 4xx `status`; any other throw is our fault, so it is a 500.
|
|
138
|
+
*/
|
|
139
|
+
export const safe =
|
|
140
|
+
(fn: (req: Request, res: Response) => unknown): RequestHandler =>
|
|
141
|
+
async (req, res) => {
|
|
142
|
+
try {
|
|
143
|
+
await fn(req, res);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
if (res.headersSent) return;
|
|
146
|
+
const failure = describeFailure(e);
|
|
147
|
+
res.status(failure.status).json(body(failure));
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Last middleware: every error that reaches it is answered in the contract above. */
|
|
152
|
+
export const errorMiddleware: ErrorRequestHandler = (e, _req, res, next) => {
|
|
153
|
+
if (res.headersSent) return next(e); // too late for a body; Express closes the socket
|
|
154
|
+
const f = describeFailure(e);
|
|
155
|
+
res.status(f.status).json(body(f));
|
|
156
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry's view of one device UID, shared by the approval gate (PairingService) and the
|
|
3
|
+
* issuance transaction (CredentialService) so both judge "has this identity changed?" the same way.
|
|
4
|
+
*
|
|
5
|
+
* A revoked pairing keeps its key (spec 03 §8.3), so the stored key is read in ANY pairing state:
|
|
6
|
+
* a different key after a revoke is still a key replacement and needs the operator's replaceKey.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, createPublicKey } from "node:crypto";
|
|
9
|
+
import type { NetworkRegistry } from "../../../../dpi-sdk/src/node/registry.js";
|
|
10
|
+
|
|
11
|
+
export interface IdentityObservation {
|
|
12
|
+
/**
|
|
13
|
+
* Opaque digest of every registry fact an approval depends on (device row, pairing epoch,
|
|
14
|
+
* pairing status, stored key). It is compared verbatim again inside the issuance transaction:
|
|
15
|
+
* any change in between (revoke, re-pair, key replacement, a second enrollment) voids the approval.
|
|
16
|
+
*/
|
|
17
|
+
stamp: string;
|
|
18
|
+
/** Fingerprint of the key on record for this UID in any pairing state; absent when none exists. */
|
|
19
|
+
storedFingerprint?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Row {
|
|
23
|
+
id: string;
|
|
24
|
+
epoch: string | null;
|
|
25
|
+
status: string | null;
|
|
26
|
+
publicKey: string | null;
|
|
27
|
+
keyFingerprint: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** SPKI fingerprint recomputed from the stored PEM, so a formatting difference is not a key change. */
|
|
31
|
+
function fingerprintOfStoredKey(row: Row): string | undefined {
|
|
32
|
+
if (row.publicKey === null) return undefined;
|
|
33
|
+
try {
|
|
34
|
+
const der = createPublicKey(row.publicKey).export({ format: "der", type: "spki" }) as Buffer;
|
|
35
|
+
return `sha256:${createHash("sha256").update(der).digest("hex")}`;
|
|
36
|
+
} catch {
|
|
37
|
+
return (
|
|
38
|
+
row.keyFingerprint ??
|
|
39
|
+
`unparseable:${createHash("sha256").update(row.publicKey).digest("hex")}`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function observeIdentity(registry: NetworkRegistry, deviceUid: string): IdentityObservation {
|
|
45
|
+
const rows = registry.db
|
|
46
|
+
.prepare(
|
|
47
|
+
`SELECT d.id AS id, p.pairing_epoch AS epoch, p.status AS status, p.public_key AS publicKey,
|
|
48
|
+
p.key_fingerprint AS keyFingerprint
|
|
49
|
+
FROM devices d LEFT JOIN pairings p ON p.device_id = d.id
|
|
50
|
+
WHERE d.device_uid=? ORDER BY d.id`,
|
|
51
|
+
)
|
|
52
|
+
.all(deviceUid) as unknown as Row[];
|
|
53
|
+
const fingerprints = rows.map(fingerprintOfStoredKey);
|
|
54
|
+
const stamp = createHash("sha256")
|
|
55
|
+
.update(JSON.stringify(rows.map((r, i) => [r.id, r.epoch, r.status, fingerprints[i] ?? null])))
|
|
56
|
+
.digest("hex");
|
|
57
|
+
// Several rows for one UID is an operator-resolved conflict (CredentialService preflight): no single stored key.
|
|
58
|
+
const storedFingerprint = rows.length === 1 ? fingerprints[0] : undefined;
|
|
59
|
+
return { stamp, ...(storedFingerprint ? { storedFingerprint } : {}) };
|
|
60
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded per-key limiters (spec 05 §4.3): at most `maxKeys` keys, an idle TTL, and a full table
|
|
3
|
+
* refuses NEW keys. A live counter is never evicted to make room — doing so would hand an attacker
|
|
4
|
+
* who can mint many keys (IPv6 sources) a free quota reset for any key they want to attack.
|
|
5
|
+
*
|
|
6
|
+
* The only entries reclaimed early are the ones whose loss is invisible: a token bucket that has
|
|
7
|
+
* refilled to capacity is indistinguishable from a brand-new one.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const FULL_RETRY_MS = 60_000;
|
|
11
|
+
/** A full table is scanned at most this often, so a flood of new keys cannot turn each request into an O(n) walk. */
|
|
12
|
+
const RECLAIM_MIN_INTERVAL_MS = 1_000;
|
|
13
|
+
const WINDOW_ENTRIES_PER_KEY = 1_024;
|
|
14
|
+
|
|
15
|
+
export interface BucketPolicy {
|
|
16
|
+
capacity: number;
|
|
17
|
+
perMinute: number;
|
|
18
|
+
maxKeys: number;
|
|
19
|
+
/** A key untouched this long is forgotten. */
|
|
20
|
+
idleTtlMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Take = { ok: true } | { ok: false; reason: "limited" | "full"; retryAfterMs: number };
|
|
24
|
+
|
|
25
|
+
interface Bucket {
|
|
26
|
+
/** Tokens held right after the last ACCEPTED request. */
|
|
27
|
+
tokens: number;
|
|
28
|
+
/** Time of the last accepted request; refill is measured from here, never from a refusal. */
|
|
29
|
+
last: number;
|
|
30
|
+
touched: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class TokenBuckets {
|
|
34
|
+
private readonly map = new Map<string, Bucket>();
|
|
35
|
+
private lastReclaim = Number.NEGATIVE_INFINITY;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly policy: BucketPolicy,
|
|
39
|
+
private readonly now: () => number = Date.now,
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
size(): number {
|
|
43
|
+
return this.map.size;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
take(key: string): Take {
|
|
47
|
+
const t = this.now();
|
|
48
|
+
let b = this.map.get(key);
|
|
49
|
+
if (b && t - b.touched >= this.policy.idleTtlMs) {
|
|
50
|
+
this.map.delete(key);
|
|
51
|
+
b = undefined;
|
|
52
|
+
}
|
|
53
|
+
if (!b) {
|
|
54
|
+
if (this.map.size >= this.policy.maxKeys) this.reclaim(t);
|
|
55
|
+
if (this.map.size >= this.policy.maxKeys)
|
|
56
|
+
return { ok: false, reason: "full", retryAfterMs: FULL_RETRY_MS };
|
|
57
|
+
b = { tokens: this.policy.capacity, last: t, touched: t };
|
|
58
|
+
this.map.set(key, b);
|
|
59
|
+
}
|
|
60
|
+
const have = this.available(b, t);
|
|
61
|
+
b.touched = t;
|
|
62
|
+
if (have < 1) {
|
|
63
|
+
const retryAfterMs = Math.ceil(((1 - have) / this.policy.perMinute) * 60_000);
|
|
64
|
+
return { ok: false, reason: "limited", retryAfterMs: Math.max(1, retryAfterMs) };
|
|
65
|
+
}
|
|
66
|
+
b.tokens = have - 1;
|
|
67
|
+
b.last = t;
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Forget keys idle for the TTL (housekeeping timer; lookups also honour the TTL). */
|
|
72
|
+
sweep(): void {
|
|
73
|
+
const t = this.now();
|
|
74
|
+
for (const [key, b] of this.map)
|
|
75
|
+
if (t - b.touched >= this.policy.idleTtlMs) this.map.delete(key);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Tokens `b` holds at `t`. Pure: a refused request must not move `last`, or a client that keeps
|
|
80
|
+
* knocking would shave a rounding crumb off its own refill each time. Multiply before dividing so
|
|
81
|
+
* a whole refill interval comes out as exactly 1.
|
|
82
|
+
*/
|
|
83
|
+
private available(b: Bucket, t: number): number {
|
|
84
|
+
const elapsed = Math.max(0, t - b.last);
|
|
85
|
+
return Math.min(this.policy.capacity, b.tokens + (elapsed * this.policy.perMinute) / 60_000);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private reclaim(t: number): void {
|
|
89
|
+
if (t - this.lastReclaim < RECLAIM_MIN_INTERVAL_MS) return;
|
|
90
|
+
this.lastReclaim = t;
|
|
91
|
+
for (const [key, b] of this.map) {
|
|
92
|
+
const idle = t - b.touched >= this.policy.idleTtlMs;
|
|
93
|
+
const refilled = this.available(b, t) >= this.policy.capacity;
|
|
94
|
+
if (idle || refilled) this.map.delete(key);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface WindowPolicy {
|
|
100
|
+
windowMs: number;
|
|
101
|
+
maxKeys: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Sliding-window event counter per key, with the same bounds and the same full-table rule. */
|
|
105
|
+
export class SlidingWindows {
|
|
106
|
+
private readonly map = new Map<string, number[]>();
|
|
107
|
+
private lastReclaim = Number.NEGATIVE_INFINITY;
|
|
108
|
+
|
|
109
|
+
constructor(
|
|
110
|
+
private readonly policy: WindowPolicy,
|
|
111
|
+
private readonly now: () => number = Date.now,
|
|
112
|
+
) {}
|
|
113
|
+
|
|
114
|
+
size(): number {
|
|
115
|
+
return this.map.size;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Events for `key` inside the window. */
|
|
119
|
+
count(key: string): number {
|
|
120
|
+
const list = this.map.get(key);
|
|
121
|
+
if (!list) return 0;
|
|
122
|
+
const live = this.prune(key, list, this.now());
|
|
123
|
+
return live.length;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** False when `key` is new and the table is full: the event could not be tracked. */
|
|
127
|
+
record(key: string): boolean {
|
|
128
|
+
const t = this.now();
|
|
129
|
+
const known = this.map.get(key);
|
|
130
|
+
const list = known ? this.prune(key, known, t) : [];
|
|
131
|
+
if (!this.map.has(key)) {
|
|
132
|
+
if (this.map.size >= this.policy.maxKeys) this.reclaim(t);
|
|
133
|
+
if (this.map.size >= this.policy.maxKeys) return false;
|
|
134
|
+
}
|
|
135
|
+
// Past the cap the oldest entries are kept: the key stays over any sane threshold until they expire.
|
|
136
|
+
if (list.length < WINDOW_ENTRIES_PER_KEY) list.push(t);
|
|
137
|
+
this.map.set(key, list);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A key the table has no room to track (callers fail closed for it). */
|
|
142
|
+
saturatedFor(key: string): boolean {
|
|
143
|
+
if (this.map.has(key) || this.map.size < this.policy.maxKeys) return false;
|
|
144
|
+
this.reclaim(this.now());
|
|
145
|
+
return this.map.size >= this.policy.maxKeys;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
sweep(): void {
|
|
149
|
+
const t = this.now();
|
|
150
|
+
for (const [key, list] of this.map) this.prune(key, list, t);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private prune(key: string, list: number[], t: number): number[] {
|
|
154
|
+
const live = list.filter((ts) => t - ts < this.policy.windowMs);
|
|
155
|
+
if (live.length === 0) this.map.delete(key);
|
|
156
|
+
else if (live.length !== list.length) this.map.set(key, live);
|
|
157
|
+
return live;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private reclaim(t: number): void {
|
|
161
|
+
if (t - this.lastReclaim < RECLAIM_MIN_INTERVAL_MS) return;
|
|
162
|
+
this.lastReclaim = t;
|
|
163
|
+
for (const [key, list] of this.map) this.prune(key, list, t);
|
|
164
|
+
}
|
|
165
|
+
}
|