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,816 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub pairing enrollment service (spec 03 §4/§6) — code issue, proof,
|
|
3
|
+
* approval, cancel, status. ALL state is RAM with TTL/caps; the hub restart
|
|
4
|
+
* loses it on purpose (enrollment_lost). Nothing here persists credentials:
|
|
5
|
+
* issuance is injected (P2-10) so the state machine can be tested alone.
|
|
6
|
+
*
|
|
7
|
+
* Hard rules encoded here:
|
|
8
|
+
* - A code + a valid self-declared proof NEVER suffice for issuance; the
|
|
9
|
+
* operator's fingerprint-matched approval is the only gate (P-8).
|
|
10
|
+
* - The hub parses the SPKI itself and computes the fingerprint; declared
|
|
11
|
+
* name/UID/fingerprint are never trusted (P-14).
|
|
12
|
+
* - Nonce consume, signature counting and phase transitions are synchronous
|
|
13
|
+
* (no await between check and set).
|
|
14
|
+
* - TTL/rates/caps are hub policy (P2-03 limits); requests never override.
|
|
15
|
+
* - An approval is bound to the registry identity the operator saw (GA-05): issuance re-checks it
|
|
16
|
+
* inside its transaction, so a revoke, re-pair or key replacement in between voids the approval.
|
|
17
|
+
* - Per-source limiter state is bounded (10,000 sources, 15 min idle TTL) and a full table refuses
|
|
18
|
+
* new sources instead of forgetting counters that are being enforced (GA-06).
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
createHash,
|
|
22
|
+
createPublicKey,
|
|
23
|
+
randomBytes,
|
|
24
|
+
randomUUID,
|
|
25
|
+
verify as cryptoVerify,
|
|
26
|
+
} from "node:crypto";
|
|
27
|
+
import type { NetworkRegistry } from "../../../../dpi-sdk/src/node/registry.js";
|
|
28
|
+
import {
|
|
29
|
+
IP_BUCKET_IDLE_TTL_MS,
|
|
30
|
+
IP_BUCKET_MAX_KEYS,
|
|
31
|
+
PAIR_RAM_CAPS,
|
|
32
|
+
PAIR_RATE_LIMITS,
|
|
33
|
+
PAIR_SIGNATURE_FAIL_LIMIT,
|
|
34
|
+
PAIR_POLL_MS,
|
|
35
|
+
} from "../../../../dpi-sdk/src/protocol/limits.js";
|
|
36
|
+
import {
|
|
37
|
+
PairApproval as PairApprovalSchema,
|
|
38
|
+
PairCodeState as PairCodeStateSchema,
|
|
39
|
+
PairRequest as PairRequestSchema,
|
|
40
|
+
PairResponse as PairResponseSchema,
|
|
41
|
+
checkPairingDto,
|
|
42
|
+
pairingSigningPayload,
|
|
43
|
+
} from "../../../../dpi-sdk/src/protocol/pairing.js";
|
|
44
|
+
import type { PairCodeIssue, TokenIssue } from "../../../../dpi-sdk/src/protocol/pairing.js";
|
|
45
|
+
import type { Audit } from "./audit.js";
|
|
46
|
+
import { AuditUnavailableError } from "./audit.js";
|
|
47
|
+
import { ipKey } from "./client-ip.js";
|
|
48
|
+
import { observeIdentity } from "./identity.js";
|
|
49
|
+
import { SlidingWindows, TokenBuckets, type Take } from "./limiter.js";
|
|
50
|
+
import type { PairCodeState } from "../../../../dpi-sdk/src/protocol/pairing.js";
|
|
51
|
+
|
|
52
|
+
const CODE_TTL_MS = 300_000;
|
|
53
|
+
const NONCE_TTL_MS = 60_000;
|
|
54
|
+
const RECEIPT_GRACE_MS = 120_000;
|
|
55
|
+
const CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
56
|
+
|
|
57
|
+
export type PairingFailure = {
|
|
58
|
+
ok: false;
|
|
59
|
+
error: { code: string; message: string; status: number; retryAfterMs?: number };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
interface Enrollment {
|
|
63
|
+
enrollmentId: string;
|
|
64
|
+
codeId: string;
|
|
65
|
+
requestId: string;
|
|
66
|
+
code: string;
|
|
67
|
+
deviceUid: string;
|
|
68
|
+
deviceName: string;
|
|
69
|
+
publicKeyPem: string;
|
|
70
|
+
computedFingerprint: string;
|
|
71
|
+
declaredFingerprint: string;
|
|
72
|
+
nonce: string;
|
|
73
|
+
nonceIssuedAt: number;
|
|
74
|
+
nonceConsumed: boolean;
|
|
75
|
+
proofVerified: boolean;
|
|
76
|
+
verifiedAt?: number;
|
|
77
|
+
/** Last ACCEPTED valid poll (S-3). The proof submission itself is not a poll; paced-out polls do not move it. */
|
|
78
|
+
lastPollAt?: number;
|
|
79
|
+
badSignatures: number;
|
|
80
|
+
cancelled: boolean;
|
|
81
|
+
/** Terminal: issued credential later invalidated (revoke/re-pair). NEVER re-issue this enrollment. */
|
|
82
|
+
invalidated?: boolean;
|
|
83
|
+
/** `identity` is the observeIdentity() stamp the operator approved against (GA-05). */
|
|
84
|
+
approval?: { fingerprint: string; replaceKey: boolean; at: number; identity: string };
|
|
85
|
+
issued?: { tokenIssue: TokenIssue; at: number };
|
|
86
|
+
/** In-flight issuance (F03): concurrent polls share ONE promise and ONE credential. */
|
|
87
|
+
issuing?: Promise<TokenIssue>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface CodeEntry {
|
|
91
|
+
codeId: string;
|
|
92
|
+
code: string;
|
|
93
|
+
expiresAt: number;
|
|
94
|
+
cancelled: boolean;
|
|
95
|
+
enrollmentId?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface PairingDeps {
|
|
99
|
+
registry: NetworkRegistry;
|
|
100
|
+
audit: Audit;
|
|
101
|
+
hubOrigin: string;
|
|
102
|
+
hubId: string;
|
|
103
|
+
/**
|
|
104
|
+
* Issuance boundary (P2-10 CredentialService). Returns the committed TokenIssue. It must reject with
|
|
105
|
+
* `code: "identity_changed"` when `expectedIdentity` no longer matches the registry (GA-05).
|
|
106
|
+
*/
|
|
107
|
+
issue?: (enrollment: {
|
|
108
|
+
enrollmentId: string;
|
|
109
|
+
codeId: string;
|
|
110
|
+
deviceUid: string;
|
|
111
|
+
deviceName: string;
|
|
112
|
+
publicKeyPem: string;
|
|
113
|
+
keyFingerprint: string;
|
|
114
|
+
replaceKey: boolean;
|
|
115
|
+
expectedIdentity: string;
|
|
116
|
+
}) => Promise<TokenIssue>;
|
|
117
|
+
/** Cached-receipt validation (P2-10): replay only while the credential is live. */
|
|
118
|
+
receiptValidator?: (tokenIssue: TokenIssue) => true | { code: string };
|
|
119
|
+
/** Test-harness clock (P2-04 policy: constructor injection, never HTTP). */
|
|
120
|
+
now?: () => number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class PairingService {
|
|
124
|
+
private codes = new Map<string, CodeEntry>(); // by codeId
|
|
125
|
+
private byCode = new Map<string, string>(); // code → codeId
|
|
126
|
+
private enrollments = new Map<string, Enrollment>();
|
|
127
|
+
private readonly now: () => number;
|
|
128
|
+
private readonly createLimiter: TokenBuckets;
|
|
129
|
+
private readonly startLimiter: TokenBuckets;
|
|
130
|
+
private readonly verifyLimiter: TokenBuckets;
|
|
131
|
+
/** Sliding-window failure counter, keyed by IP ONLY (changing code/UID must not reset it). */
|
|
132
|
+
private readonly failWindows: SlidingWindows;
|
|
133
|
+
|
|
134
|
+
constructor(private readonly deps: PairingDeps) {
|
|
135
|
+
this.now = deps.now ?? Date.now;
|
|
136
|
+
const perIp = (rate: { capacity: number; perMinute: number }) =>
|
|
137
|
+
new TokenBuckets(
|
|
138
|
+
{ ...rate, maxKeys: IP_BUCKET_MAX_KEYS, idleTtlMs: IP_BUCKET_IDLE_TTL_MS },
|
|
139
|
+
this.now,
|
|
140
|
+
);
|
|
141
|
+
this.createLimiter = perIp(PAIR_RATE_LIMITS.codeCreate);
|
|
142
|
+
this.startLimiter = perIp(PAIR_RATE_LIMITS.start);
|
|
143
|
+
this.verifyLimiter = perIp(PAIR_RATE_LIMITS.verifyTotal);
|
|
144
|
+
this.failWindows = new SlidingWindows(
|
|
145
|
+
{
|
|
146
|
+
windowMs: PAIR_RATE_LIMITS.verifyFails.windowMinutes * 60_000,
|
|
147
|
+
maxKeys: IP_BUCKET_MAX_KEYS,
|
|
148
|
+
},
|
|
149
|
+
this.now,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Recorded on auth failures only; a valid poll never counts. */
|
|
154
|
+
private noteFailure(ip: string): void {
|
|
155
|
+
this.failWindows.record(ip);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* S-1: >= perWindow failures inside the window → fail closed for that IP. A source the full table
|
|
160
|
+
* cannot track is refused as well: forgetting an enforced counter would hand its owner a reset.
|
|
161
|
+
*/
|
|
162
|
+
private failureBlocked(ip: string): boolean {
|
|
163
|
+
return (
|
|
164
|
+
this.failWindows.saturatedFor(ip) ||
|
|
165
|
+
this.failWindows.count(ip) >= PAIR_RATE_LIMITS.verifyFails.perWindow
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private fail(
|
|
170
|
+
status: number,
|
|
171
|
+
code: string,
|
|
172
|
+
message: string,
|
|
173
|
+
retryAfterMs?: number,
|
|
174
|
+
): PairingFailure {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: { status, code, message, ...(retryAfterMs ? { retryAfterMs } : {}) },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private rateLimited(taken: Extract<Take, { ok: false }>, tooMany: string): PairingFailure {
|
|
182
|
+
return this.fail(
|
|
183
|
+
429,
|
|
184
|
+
"rate_limited",
|
|
185
|
+
taken.reason === "full" ? "Pairing is busy; retry later" : tooMany,
|
|
186
|
+
taken.retryAfterMs,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private ok<T>(value: T): { ok: true; value: T } {
|
|
191
|
+
return { ok: true, value };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** POST /api/pair/code (admin) — mint a fresh single-use code. */
|
|
195
|
+
createCode(address: string): { ok: true; value: PairCodeIssue } | PairingFailure {
|
|
196
|
+
const now = this.now();
|
|
197
|
+
const taken = this.createLimiter.take(ipKey(address));
|
|
198
|
+
if (!taken.ok) return this.rateLimited(taken, "Too many codes; slow down");
|
|
199
|
+
if (this.codes.size >= PAIR_RAM_CAPS.codes)
|
|
200
|
+
return this.fail(429, "rate_limited", "Pairing capacity reached; retry later", 60_000);
|
|
201
|
+
let code = "";
|
|
202
|
+
do {
|
|
203
|
+
code = [...randomBytes(8)].map((b) => CODE_ALPHABET[b % 32]).join("");
|
|
204
|
+
} while (this.byCode.has(code));
|
|
205
|
+
const entry: CodeEntry = {
|
|
206
|
+
codeId: randomUUID(),
|
|
207
|
+
code,
|
|
208
|
+
expiresAt: now + CODE_TTL_MS,
|
|
209
|
+
cancelled: false,
|
|
210
|
+
};
|
|
211
|
+
// Audit row first: a code that cannot be audited must not exist (and must not eat the code capacity).
|
|
212
|
+
this.deps.audit.append({
|
|
213
|
+
actor: "admin",
|
|
214
|
+
action: "pair.code_created",
|
|
215
|
+
outcome: "ok",
|
|
216
|
+
detail: { codeId: entry.codeId, expiresAt: entry.expiresAt },
|
|
217
|
+
});
|
|
218
|
+
this.codes.set(entry.codeId, entry);
|
|
219
|
+
this.byCode.set(code, entry.codeId);
|
|
220
|
+
return this.ok({
|
|
221
|
+
type: "pair.code",
|
|
222
|
+
v: 1,
|
|
223
|
+
codeId: entry.codeId,
|
|
224
|
+
code,
|
|
225
|
+
expiresAt: entry.expiresAt,
|
|
226
|
+
hubOrigin: this.deps.hubOrigin,
|
|
227
|
+
pairUrl: `${this.deps.hubOrigin}/pair?code=${code}&v=1`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Admin handoff lookup. Reuses a code; never creates an enrollment or approves it. */
|
|
232
|
+
resolveCode(body: unknown): { ok: true; value: PairCodeIssue } | PairingFailure {
|
|
233
|
+
const code = (body as { code?: unknown } | null)?.code;
|
|
234
|
+
if (
|
|
235
|
+
!body ||
|
|
236
|
+
typeof body !== "object" ||
|
|
237
|
+
Object.keys(body).length !== 1 ||
|
|
238
|
+
typeof code !== "string" ||
|
|
239
|
+
!/^[A-Z2-7]{8}$/.test(code)
|
|
240
|
+
)
|
|
241
|
+
return this.fail(400, "code_invalid", "Invalid pairing handoff");
|
|
242
|
+
const codeId = this.byCode.get(code);
|
|
243
|
+
const entry = codeId ? this.codes.get(codeId) : undefined;
|
|
244
|
+
if (!entry) return this.fail(404, "code_invalid", "Unknown pairing code");
|
|
245
|
+
const state = this.status(entry.codeId);
|
|
246
|
+
if (!state.ok) return state;
|
|
247
|
+
return this.ok({
|
|
248
|
+
type: "pair.code",
|
|
249
|
+
v: 1,
|
|
250
|
+
codeId: entry.codeId,
|
|
251
|
+
code: entry.code,
|
|
252
|
+
expiresAt: entry.expiresAt,
|
|
253
|
+
hubOrigin: this.deps.hubOrigin,
|
|
254
|
+
pairUrl: `${this.deps.hubOrigin}/pair?code=${entry.code}&v=1`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private liveCode(code: string, now: number): CodeEntry | "invalid" | "expired" | "cancelled" {
|
|
259
|
+
const codeId = this.byCode.get(code);
|
|
260
|
+
if (!codeId) return "invalid";
|
|
261
|
+
const entry = this.codes.get(codeId)!;
|
|
262
|
+
if (entry.cancelled) return "cancelled";
|
|
263
|
+
if (now >= entry.expiresAt && !entry.enrollmentId) return "expired";
|
|
264
|
+
return entry;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** POST /api/pair/start — issue a challenge bound to one immutable tuple. */
|
|
268
|
+
start(body: unknown, address: string): { ok: true; value: PairChallengeOut } | PairingFailure {
|
|
269
|
+
const now = this.now();
|
|
270
|
+
const ip = ipKey(address);
|
|
271
|
+
if (this.failureBlocked(ip))
|
|
272
|
+
return this.fail(429, "rate_limited", "Too many pairing failures; slow down", 60_000);
|
|
273
|
+
const taken = this.startLimiter.take(ip);
|
|
274
|
+
if (!taken.ok) return this.rateLimited(taken, "Too many pairing starts; slow down");
|
|
275
|
+
if (!checkPairingDto(PairRequestSchema, body)) {
|
|
276
|
+
this.noteFailure(ip);
|
|
277
|
+
return this.fail(400, "invalid", "pair.request body failed schema validation");
|
|
278
|
+
}
|
|
279
|
+
const req = body as {
|
|
280
|
+
requestId: string;
|
|
281
|
+
code: string;
|
|
282
|
+
deviceUid: string;
|
|
283
|
+
deviceName: string;
|
|
284
|
+
publicKey: string;
|
|
285
|
+
keyFingerprint: string;
|
|
286
|
+
clientTime: number;
|
|
287
|
+
};
|
|
288
|
+
const live = this.liveCode(req.code, now);
|
|
289
|
+
if (live === "invalid") {
|
|
290
|
+
this.noteFailure(ip); // code guessing
|
|
291
|
+
return this.fail(404, "code_invalid", "Unknown pairing code");
|
|
292
|
+
}
|
|
293
|
+
if (live === "expired") {
|
|
294
|
+
this.noteFailure(ip);
|
|
295
|
+
return this.fail(410, "code_expired", "Pairing code expired");
|
|
296
|
+
}
|
|
297
|
+
if (live === "cancelled")
|
|
298
|
+
return this.fail(410, "enrollment_cancelled", "Pairing code cancelled");
|
|
299
|
+
|
|
300
|
+
// Same requestId + same tuple → same live challenge (idempotent retry).
|
|
301
|
+
if (live.enrollmentId) {
|
|
302
|
+
const existing = this.enrollments.get(live.enrollmentId)!;
|
|
303
|
+
const sameTuple =
|
|
304
|
+
existing.requestId === req.requestId &&
|
|
305
|
+
existing.deviceUid === req.deviceUid &&
|
|
306
|
+
existing.deviceName === req.deviceName &&
|
|
307
|
+
existing.declaredFingerprint === req.keyFingerprint &&
|
|
308
|
+
existing.publicKeyPem === req.publicKey;
|
|
309
|
+
if (!sameTuple)
|
|
310
|
+
return this.fail(409, "code_in_use", "This code is reserved for another enrollment");
|
|
311
|
+
if (existing.cancelled) return this.fail(410, "enrollment_cancelled", "Enrollment cancelled");
|
|
312
|
+
if (now >= live.expiresAt) return this.fail(410, "code_expired", "Pairing code expired");
|
|
313
|
+
return this.ok({
|
|
314
|
+
type: "pair.challenge",
|
|
315
|
+
v: 1,
|
|
316
|
+
enrollmentId: existing.enrollmentId,
|
|
317
|
+
nonce: existing.nonce,
|
|
318
|
+
issuedAt: existing.nonceIssuedAt,
|
|
319
|
+
hubId: this.deps.hubId,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Parse the key ourselves; never trust the declared fingerprint/name/UID.
|
|
324
|
+
let fingerprint: string;
|
|
325
|
+
try {
|
|
326
|
+
const key = createPublicKey(req.publicKey);
|
|
327
|
+
if (key.asymmetricKeyType !== "ed25519") {
|
|
328
|
+
this.noteFailure(ip);
|
|
329
|
+
return this.fail(400, "invalid", "publicKey must be Ed25519");
|
|
330
|
+
}
|
|
331
|
+
const der = key.export({ format: "der", type: "spki" }) as Buffer;
|
|
332
|
+
fingerprint = `sha256:${createHash("sha256").update(der).digest("hex")}`;
|
|
333
|
+
} catch {
|
|
334
|
+
this.noteFailure(ip);
|
|
335
|
+
return this.fail(400, "invalid", "publicKey is not a parseable SPKI PEM");
|
|
336
|
+
}
|
|
337
|
+
if (fingerprint !== req.keyFingerprint) {
|
|
338
|
+
this.noteFailure(ip);
|
|
339
|
+
return this.fail(
|
|
340
|
+
409,
|
|
341
|
+
"fingerprint_mismatch",
|
|
342
|
+
"Declared fingerprint does not match the submitted public key",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (this.enrollments.size >= PAIR_RAM_CAPS.enrollments)
|
|
347
|
+
return this.fail(429, "rate_limited", "Enrollment capacity reached; retry later", 60_000);
|
|
348
|
+
|
|
349
|
+
const enrollment: Enrollment = {
|
|
350
|
+
enrollmentId: randomUUID(),
|
|
351
|
+
codeId: live.codeId,
|
|
352
|
+
requestId: req.requestId,
|
|
353
|
+
code: req.code,
|
|
354
|
+
deviceUid: req.deviceUid,
|
|
355
|
+
deviceName: req.deviceName,
|
|
356
|
+
publicKeyPem: req.publicKey,
|
|
357
|
+
computedFingerprint: fingerprint,
|
|
358
|
+
declaredFingerprint: req.keyFingerprint,
|
|
359
|
+
nonce: randomBytes(32).toString("base64url"),
|
|
360
|
+
nonceIssuedAt: now,
|
|
361
|
+
nonceConsumed: false,
|
|
362
|
+
proofVerified: false,
|
|
363
|
+
badSignatures: 0,
|
|
364
|
+
cancelled: false,
|
|
365
|
+
};
|
|
366
|
+
this.enrollments.set(enrollment.enrollmentId, enrollment);
|
|
367
|
+
live.enrollmentId = enrollment.enrollmentId;
|
|
368
|
+
return this.ok({
|
|
369
|
+
type: "pair.challenge",
|
|
370
|
+
v: 1,
|
|
371
|
+
enrollmentId: enrollment.enrollmentId,
|
|
372
|
+
nonce: enrollment.nonce,
|
|
373
|
+
issuedAt: enrollment.nonceIssuedAt,
|
|
374
|
+
hubId: this.deps.hubId,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** POST /api/pair/verify — proof, poll, and (post-approval) issuance. */
|
|
379
|
+
async verify(
|
|
380
|
+
body: unknown,
|
|
381
|
+
address: string,
|
|
382
|
+
): Promise<{ ok: true; value: PairPendingOut | TokenIssue; status: number } | PairingFailure> {
|
|
383
|
+
const now = this.now();
|
|
384
|
+
const ip = ipKey(address);
|
|
385
|
+
if (this.failureBlocked(ip))
|
|
386
|
+
return this.fail(429, "rate_limited", "Too many pairing failures; slow down", 60_000);
|
|
387
|
+
const taken = this.verifyLimiter.take(ip);
|
|
388
|
+
if (!taken.ok) return this.rateLimited(taken, "Too many verify requests; slow down");
|
|
389
|
+
if (!checkPairingDto(PairResponseSchema, body)) {
|
|
390
|
+
this.noteFailure(ip);
|
|
391
|
+
return this.fail(400, "invalid", "pair.response body failed schema validation");
|
|
392
|
+
}
|
|
393
|
+
const req = body as { enrollmentId: string; nonce: string; signature: string };
|
|
394
|
+
const enrollment = this.enrollments.get(req.enrollmentId);
|
|
395
|
+
if (!enrollment) {
|
|
396
|
+
this.noteFailure(ip);
|
|
397
|
+
return this.fail(
|
|
398
|
+
410,
|
|
399
|
+
"enrollment_lost",
|
|
400
|
+
"Enrollment not found (hub restart or new code required)",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
if (enrollment.cancelled) return this.fail(410, "enrollment_cancelled", "Enrollment cancelled");
|
|
404
|
+
const code = this.codes.get(enrollment.codeId)!;
|
|
405
|
+
|
|
406
|
+
// Verified receipt: pre-approval poll → 202; post-approval → (cached) TokenIssue.
|
|
407
|
+
if (enrollment.proofVerified) {
|
|
408
|
+
// F02: EVERY poll re-presents the original proof. The enrollmentId is
|
|
409
|
+
// never a bearer credential — the caller must hold the device private
|
|
410
|
+
// key (spec 03 §4.3: poll bằng cùng body, retry đúng proof).
|
|
411
|
+
if (req.nonce !== enrollment.nonce || !this.signatureOk(enrollment, req.signature)) {
|
|
412
|
+
this.noteFailure(ip);
|
|
413
|
+
this.deps.audit.denied("auth.denied", ip, {
|
|
414
|
+
stage: "pair.verify",
|
|
415
|
+
enrollmentId: enrollment.enrollmentId,
|
|
416
|
+
reason: "receipt_proof_mismatch",
|
|
417
|
+
});
|
|
418
|
+
return this.fail(401, "signature_invalid", "Proof does not match the verified enrollment");
|
|
419
|
+
}
|
|
420
|
+
// F01: invalidation is TERMINAL. A revoked/re-paired credential never
|
|
421
|
+
// leads to a re-issue of this enrollment; a NEW enrollment with a NEW
|
|
422
|
+
// operator approval is required.
|
|
423
|
+
if (enrollment.invalidated)
|
|
424
|
+
return this.fail(
|
|
425
|
+
410,
|
|
426
|
+
"reauth_required",
|
|
427
|
+
"Issued credential is no longer active; re-pair required",
|
|
428
|
+
);
|
|
429
|
+
// S-3: a valid poll is accepted at most once per PAIR_POLL_MS per enrollment. Judged only now that
|
|
430
|
+
// the proof checked out, so a caller without the device key can never burn the device's slot, and a
|
|
431
|
+
// paced-out poll is not an auth failure. A clock that stepped back never locks the device out.
|
|
432
|
+
if (enrollment.lastPollAt !== undefined) {
|
|
433
|
+
const elapsed = now - enrollment.lastPollAt;
|
|
434
|
+
if (elapsed >= 0 && elapsed < PAIR_POLL_MS)
|
|
435
|
+
return this.fail(
|
|
436
|
+
429,
|
|
437
|
+
"rate_limited",
|
|
438
|
+
"Polling too fast; wait before the next poll",
|
|
439
|
+
PAIR_POLL_MS - elapsed,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
enrollment.lastPollAt = now;
|
|
443
|
+
if (enrollment.issued) {
|
|
444
|
+
if (now > code.expiresAt + RECEIPT_GRACE_MS)
|
|
445
|
+
return this.fail(410, "code_expired", "Receipt grace window passed");
|
|
446
|
+
// Re-validate before returning the cached secret: revoked/re-paired
|
|
447
|
+
// credentials must never be replayed just because the TTL is open.
|
|
448
|
+
if (this.deps.receiptValidator) {
|
|
449
|
+
const verdict = this.deps.receiptValidator(enrollment.issued.tokenIssue);
|
|
450
|
+
if (verdict !== true) {
|
|
451
|
+
enrollment.issued = undefined; // drop the cached secret
|
|
452
|
+
enrollment.invalidated = true; // …and end this enrollment (F01)
|
|
453
|
+
return this.fail(
|
|
454
|
+
410,
|
|
455
|
+
verdict.code,
|
|
456
|
+
"Issued credential is no longer active; re-pair required",
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return { ok: true, value: enrollment.issued.tokenIssue, status: 200 };
|
|
461
|
+
}
|
|
462
|
+
if (now >= code.expiresAt)
|
|
463
|
+
return this.fail(410, "code_expired", "Pairing code expired before approval");
|
|
464
|
+
if (!enrollment.approval)
|
|
465
|
+
return {
|
|
466
|
+
ok: true,
|
|
467
|
+
value: {
|
|
468
|
+
type: "pair.pending",
|
|
469
|
+
v: 1,
|
|
470
|
+
enrollmentId: enrollment.enrollmentId,
|
|
471
|
+
state: "awaiting_approval",
|
|
472
|
+
expiresAt: code.expiresAt,
|
|
473
|
+
pollAfterMs: PAIR_POLL_MS,
|
|
474
|
+
},
|
|
475
|
+
status: 202,
|
|
476
|
+
};
|
|
477
|
+
// Approved + not yet issued. F03: issuance is serialized per enrollment
|
|
478
|
+
// — concurrent polls share ONE in-flight promise and get the SAME
|
|
479
|
+
// credential (no await between the check and the assignment).
|
|
480
|
+
if (!this.deps.issue) return this.fail(500, "invalid", "Issuance service unavailable");
|
|
481
|
+
const approval = enrollment.approval;
|
|
482
|
+
const inflight =
|
|
483
|
+
enrollment.issuing ??
|
|
484
|
+
this.deps
|
|
485
|
+
.issue({
|
|
486
|
+
enrollmentId: enrollment.enrollmentId,
|
|
487
|
+
codeId: enrollment.codeId,
|
|
488
|
+
deviceUid: enrollment.deviceUid,
|
|
489
|
+
deviceName: enrollment.deviceName,
|
|
490
|
+
publicKeyPem: enrollment.publicKeyPem,
|
|
491
|
+
keyFingerprint: enrollment.computedFingerprint,
|
|
492
|
+
replaceKey: approval.replaceKey,
|
|
493
|
+
expectedIdentity: approval.identity,
|
|
494
|
+
})
|
|
495
|
+
.then((tokenIssue) => {
|
|
496
|
+
// pair.success is audited INSIDE the issuance transaction (P2-10).
|
|
497
|
+
enrollment.issued = { tokenIssue, at: this.now() };
|
|
498
|
+
return tokenIssue;
|
|
499
|
+
});
|
|
500
|
+
enrollment.issuing = inflight;
|
|
501
|
+
try {
|
|
502
|
+
const tokenIssue = await inflight;
|
|
503
|
+
return { ok: true, value: tokenIssue, status: 200 };
|
|
504
|
+
} catch (e) {
|
|
505
|
+
const code = (e as { code?: unknown }).code;
|
|
506
|
+
if (code === "identity_changed" || code === "key_replacement_required") {
|
|
507
|
+
// GA-05: the registry moved since the operator approved (revoke, re-pair, key replacement,
|
|
508
|
+
// another enrollment). Nothing was issued; the approval is void and a new one, taken against
|
|
509
|
+
// the current state, is required.
|
|
510
|
+
if (enrollment.approval === approval) enrollment.approval = undefined;
|
|
511
|
+
return this.fail(
|
|
512
|
+
409,
|
|
513
|
+
"identity_changed",
|
|
514
|
+
"Device identity changed since approval; approve again",
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
if (e instanceof AuditUnavailableError)
|
|
518
|
+
return this.fail(
|
|
519
|
+
503,
|
|
520
|
+
"audit_unavailable",
|
|
521
|
+
"Audit storage is unavailable; the credential was not issued",
|
|
522
|
+
);
|
|
523
|
+
// Any other issuance fault is ours, not the caller's: fixed text, detail stays in the log.
|
|
524
|
+
return this.fail(500, "internal", "Issuance failed");
|
|
525
|
+
} finally {
|
|
526
|
+
if (enrollment.issuing === inflight) enrollment.issuing = undefined;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// First proof: nonce must be live; consume atomically (sync section).
|
|
531
|
+
if (enrollment.nonceConsumed) {
|
|
532
|
+
this.noteFailure(ip);
|
|
533
|
+
return this.fail(410, "challenge_expired", "Nonce already consumed");
|
|
534
|
+
}
|
|
535
|
+
if (req.nonce !== enrollment.nonce) {
|
|
536
|
+
this.noteFailure(ip);
|
|
537
|
+
return this.fail(400, "invalid", "Nonce mismatch");
|
|
538
|
+
}
|
|
539
|
+
if (now - enrollment.nonceIssuedAt > NONCE_TTL_MS) {
|
|
540
|
+
this.noteFailure(ip);
|
|
541
|
+
return this.fail(410, "challenge_expired", "Challenge expired; request a new code");
|
|
542
|
+
}
|
|
543
|
+
if (now >= code.expiresAt) return this.fail(410, "code_expired", "Pairing code expired");
|
|
544
|
+
|
|
545
|
+
const payload = pairingSigningPayload({
|
|
546
|
+
hubOrigin: this.deps.hubOrigin,
|
|
547
|
+
hubId: this.deps.hubId,
|
|
548
|
+
enrollmentId: enrollment.enrollmentId,
|
|
549
|
+
nonce: enrollment.nonce,
|
|
550
|
+
issuedAt: enrollment.nonceIssuedAt,
|
|
551
|
+
deviceUid: enrollment.deviceUid,
|
|
552
|
+
keyFingerprint: enrollment.computedFingerprint,
|
|
553
|
+
});
|
|
554
|
+
let valid = false;
|
|
555
|
+
try {
|
|
556
|
+
valid = cryptoVerify(
|
|
557
|
+
null,
|
|
558
|
+
Buffer.from(payload, "utf8"),
|
|
559
|
+
createPublicKey(enrollment.publicKeyPem),
|
|
560
|
+
Buffer.from(req.signature, "base64url"),
|
|
561
|
+
);
|
|
562
|
+
} catch {
|
|
563
|
+
valid = false;
|
|
564
|
+
}
|
|
565
|
+
if (!valid) {
|
|
566
|
+
enrollment.badSignatures += 1;
|
|
567
|
+
this.noteFailure(ip); // sliding-window IP counter (S-1): code/UID change does not reset
|
|
568
|
+
this.deps.audit.denied("auth.denied", ip, {
|
|
569
|
+
stage: "pair.verify",
|
|
570
|
+
enrollmentId: enrollment.enrollmentId,
|
|
571
|
+
});
|
|
572
|
+
if (enrollment.badSignatures >= PAIR_SIGNATURE_FAIL_LIMIT) {
|
|
573
|
+
enrollment.cancelled = true;
|
|
574
|
+
code.cancelled = true;
|
|
575
|
+
return this.fail(
|
|
576
|
+
410,
|
|
577
|
+
"enrollment_cancelled",
|
|
578
|
+
"Too many invalid signatures; enrollment cancelled",
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
return this.fail(401, "signature_invalid", "Signature verification failed");
|
|
582
|
+
}
|
|
583
|
+
// Consume + create receipt — no await between check and set.
|
|
584
|
+
enrollment.nonceConsumed = true;
|
|
585
|
+
enrollment.proofVerified = true;
|
|
586
|
+
enrollment.verifiedAt = now;
|
|
587
|
+
return {
|
|
588
|
+
ok: true,
|
|
589
|
+
value: {
|
|
590
|
+
type: "pair.pending",
|
|
591
|
+
v: 1,
|
|
592
|
+
enrollmentId: enrollment.enrollmentId,
|
|
593
|
+
state: "awaiting_approval",
|
|
594
|
+
expiresAt: code.expiresAt,
|
|
595
|
+
pollAfterMs: PAIR_POLL_MS,
|
|
596
|
+
},
|
|
597
|
+
status: 202,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Proof check shared by first-verify and every receipt poll (F02). */
|
|
602
|
+
private signatureOk(enrollment: Enrollment, signature: string): boolean {
|
|
603
|
+
const payload = pairingSigningPayload({
|
|
604
|
+
hubOrigin: this.deps.hubOrigin,
|
|
605
|
+
hubId: this.deps.hubId,
|
|
606
|
+
enrollmentId: enrollment.enrollmentId,
|
|
607
|
+
nonce: enrollment.nonce,
|
|
608
|
+
issuedAt: enrollment.nonceIssuedAt,
|
|
609
|
+
deviceUid: enrollment.deviceUid,
|
|
610
|
+
keyFingerprint: enrollment.computedFingerprint,
|
|
611
|
+
});
|
|
612
|
+
try {
|
|
613
|
+
return cryptoVerify(
|
|
614
|
+
null,
|
|
615
|
+
Buffer.from(payload, "utf8"),
|
|
616
|
+
createPublicKey(enrollment.publicKeyPem),
|
|
617
|
+
Buffer.from(signature, "base64url"),
|
|
618
|
+
);
|
|
619
|
+
} catch {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** POST /api/pair/codes/:codeId/approve (admin) — the ONLY issuance gate. */
|
|
625
|
+
approve(codeId: string, body: unknown): { ok: true; value: PairCodeState } | PairingFailure {
|
|
626
|
+
if (!checkPairingDto(PairApprovalSchema, body))
|
|
627
|
+
return this.fail(400, "invalid", "approval body failed schema validation");
|
|
628
|
+
const approval = body as {
|
|
629
|
+
enrollmentId: string;
|
|
630
|
+
deviceUid: string;
|
|
631
|
+
keyFingerprint: string;
|
|
632
|
+
replaceKey?: boolean;
|
|
633
|
+
};
|
|
634
|
+
const code = this.codes.get(codeId);
|
|
635
|
+
const now = this.now();
|
|
636
|
+
if (!code) return this.fail(404, "code_invalid", "Unknown code");
|
|
637
|
+
const enrollment = code.enrollmentId ? this.enrollments.get(code.enrollmentId) : undefined;
|
|
638
|
+
if (!enrollment) return this.fail(409, "invalid", "No enrollment to approve yet");
|
|
639
|
+
if (enrollment.cancelled || code.cancelled)
|
|
640
|
+
return this.fail(410, "enrollment_cancelled", "Enrollment cancelled");
|
|
641
|
+
if (!enrollment.proofVerified)
|
|
642
|
+
return this.fail(409, "invalid", "Proof not verified yet; nothing to approve");
|
|
643
|
+
if (now >= code.expiresAt) return this.fail(410, "code_expired", "Pairing code expired");
|
|
644
|
+
// Immutable tuple must match the enrollment exactly.
|
|
645
|
+
if (
|
|
646
|
+
approval.enrollmentId !== enrollment.enrollmentId ||
|
|
647
|
+
approval.deviceUid !== enrollment.deviceUid ||
|
|
648
|
+
approval.keyFingerprint !== enrollment.computedFingerprint
|
|
649
|
+
)
|
|
650
|
+
return this.fail(
|
|
651
|
+
409,
|
|
652
|
+
"fingerprint_mismatch",
|
|
653
|
+
"Approval tuple does not match the verified enrollment",
|
|
654
|
+
);
|
|
655
|
+
if (enrollment.approval) return this.fail(409, "invalid", "Already approved");
|
|
656
|
+
|
|
657
|
+
// A DIFFERENT key than the one on record — in ANY pairing state, a revoked pairing keeps its key —
|
|
658
|
+
// requires replaceKey=true (P-14).
|
|
659
|
+
const identity = observeIdentity(this.deps.registry, enrollment.deviceUid);
|
|
660
|
+
const keyChanged =
|
|
661
|
+
identity.storedFingerprint !== undefined &&
|
|
662
|
+
identity.storedFingerprint !== enrollment.computedFingerprint;
|
|
663
|
+
if (keyChanged && !approval.replaceKey)
|
|
664
|
+
return this.fail(
|
|
665
|
+
409,
|
|
666
|
+
"invalid",
|
|
667
|
+
"Device has a different key on record; re-confirm with replaceKey=true",
|
|
668
|
+
);
|
|
669
|
+
if (approval.replaceKey && !keyChanged)
|
|
670
|
+
return this.fail(409, "invalid", "replaceKey set but the key has not changed");
|
|
671
|
+
|
|
672
|
+
// F04: the audit row commits FIRST. A failed append throws here with the
|
|
673
|
+
// enrollment still un-approved — fail-closed means a storage outage can
|
|
674
|
+
// never leave a silently-live approval behind.
|
|
675
|
+
this.deps.audit.append({
|
|
676
|
+
actor: "admin",
|
|
677
|
+
action: "pair.approved",
|
|
678
|
+
outcome: "ok",
|
|
679
|
+
detail: {
|
|
680
|
+
codeId,
|
|
681
|
+
enrollmentId: enrollment.enrollmentId,
|
|
682
|
+
keyFingerprint: enrollment.computedFingerprint,
|
|
683
|
+
replaceKey: keyChanged,
|
|
684
|
+
...(keyChanged ? { replacedKeyFingerprint: identity.storedFingerprint } : {}),
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
enrollment.approval = {
|
|
688
|
+
fingerprint: enrollment.computedFingerprint,
|
|
689
|
+
replaceKey: keyChanged,
|
|
690
|
+
at: now,
|
|
691
|
+
identity: identity.stamp,
|
|
692
|
+
};
|
|
693
|
+
const s = this.status(codeId);
|
|
694
|
+
return s.ok ? this.ok(s.value) : s;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** POST /api/pair/codes/:codeId/cancel (admin). */
|
|
698
|
+
cancel(codeId: string): { ok: true; value: PairCodeState } | PairingFailure {
|
|
699
|
+
const code = this.codes.get(codeId);
|
|
700
|
+
if (!code) return this.fail(404, "code_invalid", "Unknown code");
|
|
701
|
+
const enrollment = code.enrollmentId ? this.enrollments.get(code.enrollmentId) : undefined;
|
|
702
|
+
if (enrollment?.issued)
|
|
703
|
+
return this.fail(409, "already_issued", "Credential already issued; revoke it instead");
|
|
704
|
+
// Audit row first (as for approve): a cancel that cannot be audited does not take effect.
|
|
705
|
+
this.deps.audit.append({
|
|
706
|
+
actor: "admin",
|
|
707
|
+
action: "pair.cancelled",
|
|
708
|
+
outcome: "ok",
|
|
709
|
+
detail: { codeId },
|
|
710
|
+
});
|
|
711
|
+
code.cancelled = true;
|
|
712
|
+
if (enrollment) {
|
|
713
|
+
enrollment.cancelled = true;
|
|
714
|
+
enrollment.nonceConsumed = true;
|
|
715
|
+
enrollment.invalidated = true;
|
|
716
|
+
}
|
|
717
|
+
const s = this.status(codeId);
|
|
718
|
+
return s.ok ? this.ok(s.value) : s;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** GET /api/pair/codes/:codeId (admin) — phase metadata, never a token. */
|
|
722
|
+
status(codeId: string): { ok: true; value: PairCodeState } | PairingFailure {
|
|
723
|
+
const now = this.now();
|
|
724
|
+
const code = this.codes.get(codeId);
|
|
725
|
+
if (!code) return this.fail(404, "code_invalid", "Unknown code");
|
|
726
|
+
const enrollment = code.enrollmentId ? this.enrollments.get(code.enrollmentId) : undefined;
|
|
727
|
+
const issued = enrollment?.issued;
|
|
728
|
+
const readableUntil = code.expiresAt + RECEIPT_GRACE_MS;
|
|
729
|
+
if (!issued && !code.cancelled && now >= code.expiresAt && now <= readableUntil) {
|
|
730
|
+
// Un-issued expired code: still reportable inside grace for the wizard.
|
|
731
|
+
}
|
|
732
|
+
const phase: PairCodeState["phase"] = issued
|
|
733
|
+
? "issued"
|
|
734
|
+
: code.cancelled || enrollment?.cancelled
|
|
735
|
+
? "cancelled"
|
|
736
|
+
: now >= code.expiresAt && !enrollment?.proofVerified
|
|
737
|
+
? "expired"
|
|
738
|
+
: now >= code.expiresAt
|
|
739
|
+
? "expired"
|
|
740
|
+
: enrollment?.approval
|
|
741
|
+
? "approved"
|
|
742
|
+
: enrollment?.proofVerified
|
|
743
|
+
? "awaiting_approval"
|
|
744
|
+
: enrollment
|
|
745
|
+
? "awaiting_proof"
|
|
746
|
+
: "open";
|
|
747
|
+
const state: PairCodeState = {
|
|
748
|
+
type: "pair.code-state",
|
|
749
|
+
v: 1,
|
|
750
|
+
codeId,
|
|
751
|
+
expiresAt: code.expiresAt,
|
|
752
|
+
phase,
|
|
753
|
+
...(enrollment
|
|
754
|
+
? {
|
|
755
|
+
enrollment: {
|
|
756
|
+
enrollmentId: enrollment.enrollmentId,
|
|
757
|
+
deviceUid: enrollment.deviceUid,
|
|
758
|
+
deviceName: enrollment.deviceName,
|
|
759
|
+
keyFingerprint: enrollment.computedFingerprint,
|
|
760
|
+
proofVerified: enrollment.proofVerified,
|
|
761
|
+
// F14: key change is computed from the CURRENT enrollment key
|
|
762
|
+
// vs the key on record (any pairing state) — available BEFORE
|
|
763
|
+
// approval, so the wizard can offer the replace-key confirmation in time.
|
|
764
|
+
keyChanged: (() => {
|
|
765
|
+
const stored = observeIdentity(
|
|
766
|
+
this.deps.registry,
|
|
767
|
+
enrollment.deviceUid,
|
|
768
|
+
).storedFingerprint;
|
|
769
|
+
return stored !== undefined && stored !== enrollment.computedFingerprint;
|
|
770
|
+
})(),
|
|
771
|
+
},
|
|
772
|
+
}
|
|
773
|
+
: {}),
|
|
774
|
+
...(issued ? { deviceId: issued.tokenIssue.deviceId } : {}),
|
|
775
|
+
};
|
|
776
|
+
return checkPairingDto(PairCodeStateSchema, state)
|
|
777
|
+
? { ok: true, value: state }
|
|
778
|
+
: this.fail(500, "internal", "status serialization failed");
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** Drop expired entries (hub housekeeping). F15: past expiry+grace the
|
|
782
|
+
* entry goes REGARDLESS of issued — receipts hold a plaintext token and
|
|
783
|
+
* grace is exactly how long the device may re-read it. Also forgets idle
|
|
784
|
+
* per-source limiter state (GA-06). */
|
|
785
|
+
sweep(): void {
|
|
786
|
+
const now = this.now();
|
|
787
|
+
this.createLimiter.sweep();
|
|
788
|
+
this.startLimiter.sweep();
|
|
789
|
+
this.verifyLimiter.sweep();
|
|
790
|
+
this.failWindows.sweep();
|
|
791
|
+
for (const [codeId, code] of this.codes) {
|
|
792
|
+
if (now > code.expiresAt + RECEIPT_GRACE_MS) {
|
|
793
|
+
this.byCode.delete(code.code);
|
|
794
|
+
this.codes.delete(codeId);
|
|
795
|
+
if (code.enrollmentId) this.enrollments.delete(code.enrollmentId);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export type PairChallengeOut = {
|
|
802
|
+
type: "pair.challenge";
|
|
803
|
+
v: 1;
|
|
804
|
+
enrollmentId: string;
|
|
805
|
+
nonce: string;
|
|
806
|
+
issuedAt: number;
|
|
807
|
+
hubId: string;
|
|
808
|
+
};
|
|
809
|
+
export type PairPendingOut = {
|
|
810
|
+
type: "pair.pending";
|
|
811
|
+
v: 1;
|
|
812
|
+
enrollmentId: string;
|
|
813
|
+
state: "awaiting_approval";
|
|
814
|
+
expiresAt: number;
|
|
815
|
+
pollAfterMs: number;
|
|
816
|
+
};
|