@twinsearth/w2m-dsh-plugin 1.9.20 → 1.9.21
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +8 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.21/RELEASE-1921.json +21 -0
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/plugin/fleet-chat-route.mjs +92 -91
- package/src/plugin/tofuture/report.mjs +44 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/server.mjs +39 -0
- package/src/relay/tofuture-whitelist.mjs +46 -0
- package/src/util/tofuture-hmac.mjs +30 -0
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
|
|
8
|
-
"version": "1.9.
|
|
8
|
+
"version": "1.9.21"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "1.9.
|
|
15
|
+
"version": "1.9.21",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.9.
|
|
29
|
+
"version": "1.9.21"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "1.9.
|
|
4
|
+
"version": "1.9.21",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.9.21] - 2026-10-28
|
|
11
|
+
|
|
12
|
+
- 发布边界:本版只发主插件;ToFuture 运维子插件由独立私有仓库另行发布,本包不包含安装/下载能力。
|
|
13
|
+
- 保留与 Relay 服务器配套的隐藏上报模块:HMAC 网络标识上报(/v1/tofuture/identify,relay 侧静默比对、只存 HMAC)。
|
|
14
|
+
该能力默认关闭、不注册工具、不显示 UI;relay 需自行升级后才生效。
|
|
15
|
+
- 删除本机隐藏能力(命令触发、隐藏加密存储、自助下载/安装、票据模型),避免在公共 npm 包中携带未交付的运维面。
|
|
16
|
+
- 沿用 1.9.20 的浮动滚动浮标(⇧3 单击 3 轮 / 长按 3 秒到顶 + ⤒ 一键到顶)与搜索绿字黄底高亮。
|
|
17
|
+
|
|
10
18
|
## [1.9.20] - 2026-10-28
|
|
11
19
|
|
|
12
20
|
- 新增浮动滚动浮标(固定在主工作区右上角,不依赖 slot 布局):
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.9.21",
|
|
3
|
+
"case": "RELEASE-1921",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-28T08:19:50.383Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-1921-1793175590384",
|
|
11
|
+
"requirement": "v1.9.21 主插件:仅保留与 Relay 配套的 HMAC 上报模块(隐藏、默认关闭),删除本机隐藏能力;不发子插件、不部署 relay",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"scope": "main plugin only; no sub-plugin, no relay deployment",
|
|
16
|
+
"retained": "src/util/tofuture-hmac.mjs, src/plugin/tofuture/report.mjs, src/relay/tofuture-whitelist.mjs, POST /v1/tofuture/identify",
|
|
17
|
+
"removed": "command trigger, hidden vault, selfcheck, verify/keygen, agent ticket store, relay ticket store, download route",
|
|
18
|
+
"tests": "tofuture-s2 4/4, fleet-chat-route 6/6, lint 0 errors"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.21",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"pack": "node scripts/pack.mjs",
|
|
65
65
|
"reachability": "node scripts/reachability-check.mjs",
|
|
66
66
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
67
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/data-plane.test.mjs test/sealed-object.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs",
|
|
67
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/data-plane.test.mjs test/sealed-object.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/tofuture-s2.test.mjs",
|
|
68
68
|
"test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
|
|
69
69
|
"test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
|
|
70
70
|
"test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.9.
|
|
4
|
+
"version": "1.9.21",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from './session-watch.mjs';
|
|
24
24
|
import { createSessionSync } from './session-sync.mjs';
|
|
25
25
|
|
|
26
|
+
|
|
26
27
|
/**
|
|
27
28
|
* The newest transcript for a cwd, or null. Reads directory listings and stat only -- this module never opens a
|
|
28
29
|
* session file for writing, and \ est/session-readonly.test.mjs\ enforces that.
|
|
@@ -42,9 +43,9 @@ function newestTranscript(cwd) {
|
|
|
42
43
|
return best === null ? null : best.file;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
export const CHAT_ROUTE_PATH = '/api/w2m/chat';
|
|
46
|
-
|
|
47
|
-
/** The light task board the panel shows beside the chat. The relay already answers GET /v1/tasks. */
|
|
46
|
+
export const CHAT_ROUTE_PATH = '/api/w2m/chat';
|
|
47
|
+
|
|
48
|
+
/** The light task board the panel shows beside the chat. The relay already answers GET /v1/tasks. */
|
|
48
49
|
export const TASKS_ROUTE_PATH = '/api/w2m/tasks';
|
|
49
50
|
export const MAX_SESSION_ID_LENGTH = 200;
|
|
50
51
|
|
|
@@ -105,36 +106,36 @@ export function registerChatRoutes(ctx, deps) {
|
|
|
105
106
|
};
|
|
106
107
|
return resolved;
|
|
107
108
|
};
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* The machine ids this fleet knows, cached for five seconds.
|
|
111
|
-
*
|
|
112
|
-
* Only the HOST can check a single recipient: the relay validates the shape of a recipient, not whether a machine
|
|
113
|
-
* with that id exists. Null means the roster could not be read at all, which is deliberately a different answer from
|
|
114
|
-
* "no such machine" -- a relay outage must not be reported as a typo in the recipient.
|
|
115
|
-
*/
|
|
116
|
-
let rosterAtMs = 0;
|
|
117
|
-
let rosterIds = null;
|
|
118
|
-
const knownMachineIds = async (signal) => {
|
|
119
|
-
if (rosterIds !== null && Date.now() - rosterAtMs < 5000) return rosterIds;
|
|
120
|
-
try {
|
|
121
|
-
const cfg = deps.cfg();
|
|
122
|
-
const rabbitUrl = cfg?.rabbitUrl ?? null;
|
|
123
|
-
if (rabbitUrl === null) return null;
|
|
124
|
-
const token = await deps.resolveToken();
|
|
125
|
-
if (typeof token !== 'string' || token === '') return null;
|
|
126
|
-
const listed = await deps.request({ rabbitUrl, token, method: 'GET', pathname: '/v1/devices', signal });
|
|
127
|
-
if (listed?.ok !== true) return null;
|
|
128
|
-
const devices = Array.isArray(listed.json?.devices) ? listed.json.devices : null;
|
|
129
|
-
if (devices === null) return null;
|
|
130
|
-
const ids = devices.map((device) => device?.machine_id).filter((id) => typeof id === 'string');
|
|
131
|
-
rosterIds = ids;
|
|
132
|
-
rosterAtMs = Date.now();
|
|
133
|
-
return ids;
|
|
134
|
-
} catch {
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The machine ids this fleet knows, cached for five seconds.
|
|
112
|
+
*
|
|
113
|
+
* Only the HOST can check a single recipient: the relay validates the shape of a recipient, not whether a machine
|
|
114
|
+
* with that id exists. Null means the roster could not be read at all, which is deliberately a different answer from
|
|
115
|
+
* "no such machine" -- a relay outage must not be reported as a typo in the recipient.
|
|
116
|
+
*/
|
|
117
|
+
let rosterAtMs = 0;
|
|
118
|
+
let rosterIds = null;
|
|
119
|
+
const knownMachineIds = async (signal) => {
|
|
120
|
+
if (rosterIds !== null && Date.now() - rosterAtMs < 5000) return rosterIds;
|
|
121
|
+
try {
|
|
122
|
+
const cfg = deps.cfg();
|
|
123
|
+
const rabbitUrl = cfg?.rabbitUrl ?? null;
|
|
124
|
+
if (rabbitUrl === null) return null;
|
|
125
|
+
const token = await deps.resolveToken();
|
|
126
|
+
if (typeof token !== 'string' || token === '') return null;
|
|
127
|
+
const listed = await deps.request({ rabbitUrl, token, method: 'GET', pathname: '/v1/devices', signal });
|
|
128
|
+
if (listed?.ok !== true) return null;
|
|
129
|
+
const devices = Array.isArray(listed.json?.devices) ? listed.json.devices : null;
|
|
130
|
+
if (devices === null) return null;
|
|
131
|
+
const ids = devices.map((device) => device?.machine_id).filter((id) => typeof id === 'string');
|
|
132
|
+
rosterIds = ids;
|
|
133
|
+
rosterAtMs = Date.now();
|
|
134
|
+
return ids;
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
138
139
|
|
|
139
140
|
/** Post one batch into the relay using the machine's device token, exactly as the browser route does. */
|
|
140
141
|
const postToRelay = async (identity, payload) => {
|
|
@@ -156,12 +157,12 @@ export function registerChatRoutes(ctx, deps) {
|
|
|
156
157
|
* The session files are never written and nothing is injected into DSH: the transcript is read, the user's messages
|
|
157
158
|
* are extracted, and only the new ones are published. A failure here must not disturb the panel or the route.
|
|
158
159
|
*/
|
|
159
|
-
const MIRROR_MAX_BYTES = 3500;
|
|
160
|
-
const truncateUtf8 = (value, max) => {
|
|
161
|
-
const bytes = Buffer.from(value, 'utf8');
|
|
162
|
-
if (bytes.length <= max) return value;
|
|
163
|
-
return bytes.subarray(0, max).toString('utf8').replace(/\uFFFD+$/, '') + ' …(已截断)';
|
|
164
|
-
};
|
|
160
|
+
const MIRROR_MAX_BYTES = 3500;
|
|
161
|
+
const truncateUtf8 = (value, max) => {
|
|
162
|
+
const bytes = Buffer.from(value, 'utf8');
|
|
163
|
+
if (bytes.length <= max) return value;
|
|
164
|
+
return bytes.subarray(0, max).toString('utf8').replace(/\uFFFD+$/, '') + ' …(已截断)';
|
|
165
|
+
};
|
|
165
166
|
const mirrorOnce = async () => {
|
|
166
167
|
const identity = await identityOf();
|
|
167
168
|
if (identity.conversationKey === null) return;
|
|
@@ -271,16 +272,16 @@ export function registerChatRoutes(ctx, deps) {
|
|
|
271
272
|
}
|
|
272
273
|
return Response.json({ ...listed.json, ok: true, local_machine_id: identity.machineId, local_machine_name: identity.machineName, conversation_key: sessionId, conversation_source: identity.source }, { headers: NO_STORE });
|
|
273
274
|
}
|
|
274
|
-
// Addressing (v1.2.2): a single recipient must exist in THIS fleet.
|
|
275
|
-
if (typeof addressing.to === 'string' && addressing.to !== 'all') {
|
|
276
|
-
const roster = await knownMachineIds(request.signal);
|
|
277
|
-
if (roster === null) {
|
|
278
|
-
return refuse(503, 'CHAT_ROSTER_UNAVAILABLE', 'the machine roster could not be read', 'a recipient cannot be checked without the relay roster');
|
|
279
|
-
}
|
|
280
|
-
if (!roster.includes(addressing.to)) {
|
|
281
|
-
return refuse(400, 'CHAT_UNKNOWN_RECIPIENT', 'no machine with that id is in this fleet', 'send all, or a machine_id listed by w2m_devices');
|
|
282
|
-
}
|
|
283
|
-
}
|
|
275
|
+
// Addressing (v1.2.2): a single recipient must exist in THIS fleet.
|
|
276
|
+
if (typeof addressing.to === 'string' && addressing.to !== 'all') {
|
|
277
|
+
const roster = await knownMachineIds(request.signal);
|
|
278
|
+
if (roster === null) {
|
|
279
|
+
return refuse(503, 'CHAT_ROSTER_UNAVAILABLE', 'the machine roster could not be read', 'a recipient cannot be checked without the relay roster');
|
|
280
|
+
}
|
|
281
|
+
if (!roster.includes(addressing.to)) {
|
|
282
|
+
return refuse(400, 'CHAT_UNKNOWN_RECIPIENT', 'no machine with that id is in this fleet', 'send all, or a machine_id listed by w2m_devices');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
284
285
|
const posted = await deps.request({ rabbitUrl, token, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: text ?? '', machine_id: identity.machineId, machine_name: identity.machineName, origin: 'chat', ...addressing }, signal: request.signal });
|
|
285
286
|
if (posted?.ok !== true) {
|
|
286
287
|
const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'CHAT_SEND_REFUSED';
|
|
@@ -293,48 +294,48 @@ export function registerChatRoutes(ctx, deps) {
|
|
|
293
294
|
}
|
|
294
295
|
},
|
|
295
296
|
});
|
|
296
|
-
// A light task board. The relay answers GET /v1/tasks; this proxy exists so the panel can read it with the device
|
|
297
|
-
// token that never leaves the host.
|
|
298
|
-
registry.register({
|
|
299
|
-
path: TASKS_ROUTE_PATH,
|
|
300
|
-
methods: ['GET'],
|
|
301
|
-
requestBody: 'buffered',
|
|
302
|
-
fetch: async (request) => {
|
|
303
|
-
const refuse = (httpStatus, reasonCode, error, detail) =>
|
|
304
|
-
Response.json({ ok: false, error, reason_code: reasonCode, detail }, { status: httpStatus, headers: NO_STORE });
|
|
305
|
-
if (request.method !== 'GET') {
|
|
306
|
-
return refuse(405, 'TASKS_METHOD_NOT_ALLOWED', `${request.method} is not answered here`, 'GET only');
|
|
307
|
-
}
|
|
308
|
-
const url = new URL(request.url);
|
|
309
|
-
const limitRaw = url.searchParams.get('limit');
|
|
310
|
-
const limit = limitRaw !== null && /^[0-9]+$/.test(limitRaw) ? Math.min(Number(limitRaw), 50) : 20;
|
|
311
|
-
const cfg = deps.cfg();
|
|
312
|
-
const rabbitUrl = cfg?.rabbitUrl ?? null;
|
|
313
|
-
if (rabbitUrl === null) return refuse(503, 'TASKS_NO_RELAY', 'no relay is configured', 'set rabbitUrl in the profile patch');
|
|
314
|
-
let token;
|
|
315
|
-
try {
|
|
316
|
-
token = await deps.resolveToken();
|
|
317
|
-
} catch (error) {
|
|
318
|
-
return refuse(503, 'TASKS_UNPAIRED', `this machine has no device token: ${error instanceof Error ? error.message : String(error)}`, 'pair the machine first');
|
|
319
|
-
}
|
|
320
|
-
if (typeof token !== 'string' || token === '') {
|
|
321
|
-
return refuse(503, 'TASKS_UNPAIRED', 'this machine has no device token', 'pair the machine first');
|
|
322
|
-
}
|
|
323
|
-
try {
|
|
324
|
-
const listed = await deps.request({
|
|
325
|
-
rabbitUrl, token, method: 'GET', pathname: '/v1/tasks', query: { limit: String(limit) }, signal: request.signal,
|
|
326
|
-
});
|
|
327
|
-
if (listed?.ok !== true) {
|
|
328
|
-
const code = listed?.json?.error?.code ?? 'TASKS_LIST_REFUSED';
|
|
329
|
-
return refuse(502, code, `the relay answered HTTP ${listed?.status ?? 0}`, 'the relay must run a version with /v1/tasks');
|
|
330
|
-
}
|
|
331
|
-
return Response.json({ ...listed.json, ok: true }, { headers: NO_STORE });
|
|
332
|
-
} catch (error) {
|
|
333
|
-
return refuse(502, 'TASKS_RELAY_ERROR', `the relay call failed: ${error instanceof Error ? error.message : String(error)}`, null);
|
|
334
|
-
}
|
|
335
|
-
},
|
|
336
|
-
});
|
|
337
|
-
|
|
297
|
+
// A light task board. The relay answers GET /v1/tasks; this proxy exists so the panel can read it with the device
|
|
298
|
+
// token that never leaves the host.
|
|
299
|
+
registry.register({
|
|
300
|
+
path: TASKS_ROUTE_PATH,
|
|
301
|
+
methods: ['GET'],
|
|
302
|
+
requestBody: 'buffered',
|
|
303
|
+
fetch: async (request) => {
|
|
304
|
+
const refuse = (httpStatus, reasonCode, error, detail) =>
|
|
305
|
+
Response.json({ ok: false, error, reason_code: reasonCode, detail }, { status: httpStatus, headers: NO_STORE });
|
|
306
|
+
if (request.method !== 'GET') {
|
|
307
|
+
return refuse(405, 'TASKS_METHOD_NOT_ALLOWED', `${request.method} is not answered here`, 'GET only');
|
|
308
|
+
}
|
|
309
|
+
const url = new URL(request.url);
|
|
310
|
+
const limitRaw = url.searchParams.get('limit');
|
|
311
|
+
const limit = limitRaw !== null && /^[0-9]+$/.test(limitRaw) ? Math.min(Number(limitRaw), 50) : 20;
|
|
312
|
+
const cfg = deps.cfg();
|
|
313
|
+
const rabbitUrl = cfg?.rabbitUrl ?? null;
|
|
314
|
+
if (rabbitUrl === null) return refuse(503, 'TASKS_NO_RELAY', 'no relay is configured', 'set rabbitUrl in the profile patch');
|
|
315
|
+
let token;
|
|
316
|
+
try {
|
|
317
|
+
token = await deps.resolveToken();
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return refuse(503, 'TASKS_UNPAIRED', `this machine has no device token: ${error instanceof Error ? error.message : String(error)}`, 'pair the machine first');
|
|
320
|
+
}
|
|
321
|
+
if (typeof token !== 'string' || token === '') {
|
|
322
|
+
return refuse(503, 'TASKS_UNPAIRED', 'this machine has no device token', 'pair the machine first');
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
const listed = await deps.request({
|
|
326
|
+
rabbitUrl, token, method: 'GET', pathname: '/v1/tasks', query: { limit: String(limit) }, signal: request.signal,
|
|
327
|
+
});
|
|
328
|
+
if (listed?.ok !== true) {
|
|
329
|
+
const code = listed?.json?.error?.code ?? 'TASKS_LIST_REFUSED';
|
|
330
|
+
return refuse(502, code, `the relay answered HTTP ${listed?.status ?? 0}`, 'the relay must run a version with /v1/tasks');
|
|
331
|
+
}
|
|
332
|
+
return Response.json({ ...listed.json, ok: true }, { headers: NO_STORE });
|
|
333
|
+
} catch (error) {
|
|
334
|
+
return refuse(502, 'TASKS_RELAY_ERROR', `the relay call failed: ${error instanceof Error ? error.message : String(error)}`, null);
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
|
|
338
339
|
status.mounted = true;
|
|
339
340
|
status.reason = null;
|
|
340
341
|
const timer = setInterval(() => { mirrorOnce().catch(() => { /* one bad tick must not stop the next */ }); }, 2000);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToFuture identity report (S2): collect every NIC, HMAC it, send only HMACs.
|
|
3
|
+
*
|
|
4
|
+
* Raw MAC/IP values exist only inside this function's local scope. The request body carries
|
|
5
|
+
* `{v, machine_id, ts_ms, nonce, entries:[{kind, salt_id, hmac}]}` and nothing else.
|
|
6
|
+
*/
|
|
7
|
+
import { networkInterfaces } from 'node:os';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
import { identityHmac } from '../../util/tofuture-hmac.mjs';
|
|
11
|
+
|
|
12
|
+
export function collectIdentityEntries(machineId, fleetKey) {
|
|
13
|
+
const entries = [];
|
|
14
|
+
for (const rows of Object.values(networkInterfaces())) {
|
|
15
|
+
for (const row of rows ?? []) {
|
|
16
|
+
const mac = typeof row?.mac === 'string' ? row.mac.trim() : '';
|
|
17
|
+
if (mac !== '' && mac !== '00:00:00:00:00:00') entries.push(identityHmac(fleetKey, machineId, 'mac', mac.toLowerCase()));
|
|
18
|
+
const family = row?.family === 4 || row?.family === 'IPv4' ? 'ipv4' : (row?.family === 6 || row?.family === 'IPv6' ? 'ipv6' : null);
|
|
19
|
+
if (family !== null && typeof row?.address === 'string' && row.address !== '') entries.push(identityHmac(fleetKey, machineId, family, row.address));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return entries;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildIdentifyBody({ machineId, fleetKey, now = Date.now() }) {
|
|
26
|
+
return { v: 1, machine_id: machineId, ts_ms: now, nonce: randomBytes(12).toString('hex'), entries: collectIdentityEntries(machineId, fleetKey) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {{request: Function, rabbitUrl: string, token: string, machineId: string, fleetKey: string, signal?: AbortSignal}} input
|
|
31
|
+
* @returns {Promise<{ok: boolean, matched?: boolean, code?: string}>} — never carries identifiers
|
|
32
|
+
*/
|
|
33
|
+
export async function reportIdentity(input) {
|
|
34
|
+
if (typeof input?.fleetKey !== 'string' || input.fleetKey === '') return { ok: false, code: 'HIDDEN_STATE_NO_KEY' };
|
|
35
|
+
const body = buildIdentifyBody({ machineId: input.machineId, fleetKey: input.fleetKey });
|
|
36
|
+
try {
|
|
37
|
+
const result = await input.request({ rabbitUrl: input.rabbitUrl, token: input.token, method: 'POST', pathname: '/v1/tofuture/identify', body, signal: input.signal });
|
|
38
|
+
// The relay answers 204 for both outcomes; a non-2xx is a transport/shape failure, not membership.
|
|
39
|
+
if (result?.ok === true) return { ok: true };
|
|
40
|
+
return { ok: false, code: result?.error?.code ?? 'TOFUTURE_REPORT_REFUSED' };
|
|
41
|
+
} catch {
|
|
42
|
+
return { ok: false, code: 'TOFUTURE_REPORT_FAILED' };
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -286,7 +286,7 @@ const MAX_STAGES = 16;
|
|
|
286
286
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
287
287
|
* overrides it at runtime for tests and packagers.
|
|
288
288
|
*/
|
|
289
|
-
const PLUGIN_VERSION = '1.9.
|
|
289
|
+
const PLUGIN_VERSION = '1.9.21';
|
|
290
290
|
|
|
291
291
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
292
292
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
package/src/relay/server.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loadWhitelist, whitelistMatches } from './tofuture-whitelist.mjs';
|
|
1
2
|
import { guardBlobRoute, guardResultEnvelope, guardTaskBody } from './redline-guard.mjs';
|
|
2
3
|
/**
|
|
3
4
|
* W2M Rabbit relay — node:http / node:https server.
|
|
@@ -1441,6 +1442,44 @@ export class RelayServer {
|
|
|
1441
1442
|
this.fleetMessages ??= createFleetMessages({ now: () => this.state.nowMs() });
|
|
1442
1443
|
this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
|
|
1443
1444
|
|
|
1445
|
+
/* ---- v2 hidden: ToFuture identity report (silent) ---- */
|
|
1446
|
+
if (method === 'POST' && path === '/v1/tofuture/identify') {
|
|
1447
|
+
const body = takeJson();
|
|
1448
|
+
const text = JSON.stringify(body ?? {});
|
|
1449
|
+
// Red line: identifiers never travel as plaintext. Reject by field name and by shape.
|
|
1450
|
+
const PLAINTEXT = new Set(['mac', 'ip', 'ipv4', 'ipv6', 'bssid', 'address', 'ssid', 'wifi']);
|
|
1451
|
+
const plaintextFields = [];
|
|
1452
|
+
const walk = (value) => {
|
|
1453
|
+
if (value === null || typeof value !== 'object') return;
|
|
1454
|
+
if (Array.isArray(value)) { value.forEach(walk); return; }
|
|
1455
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1456
|
+
if (PLAINTEXT.has(key.toLowerCase())) plaintextFields.push(key);
|
|
1457
|
+
walk(child);
|
|
1458
|
+
}
|
|
1459
|
+
};
|
|
1460
|
+
walk(body);
|
|
1461
|
+
if (plaintextFields.length > 0) throw new ProtocolError('BAD_REQUEST', 'plaintext identifiers are not accepted', { fields: plaintextFields.slice(0, 8) });
|
|
1462
|
+
if (/([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}/.test(text) || /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/.test(text)) {
|
|
1463
|
+
throw new ProtocolError('BAD_REQUEST', 'plaintext identifiers are not accepted');
|
|
1464
|
+
}
|
|
1465
|
+
const entries = Array.isArray(body?.entries) ? body.entries : [];
|
|
1466
|
+
const nowMs = this.state.nowMs();
|
|
1467
|
+
const ts = Number(body?.ts_ms);
|
|
1468
|
+
const fresh = Number.isFinite(ts) && Math.abs(nowMs - ts) <= 60_000;
|
|
1469
|
+
const nonce = typeof body?.nonce === 'string' ? body.nonce : '';
|
|
1470
|
+
this._tofutureNonces ??= new Map();
|
|
1471
|
+
const replay = nonce === '' || this._tofutureNonces.has(nonce);
|
|
1472
|
+
if (nonce !== '') this._tofutureNonces.set(nonce, nowMs);
|
|
1473
|
+
for (const [id, at] of this._tofutureNonces) if (nowMs - at > 120_000) this._tofutureNonces.delete(id);
|
|
1474
|
+
const whitelist = loadWhitelist(this.persistence?.dir ?? null);
|
|
1475
|
+
const matched = fresh === true && replay !== true && whitelistMatches(whitelist, entries);
|
|
1476
|
+
this._tofutureEligible ??= new Map();
|
|
1477
|
+
if (matched === true) this._tofutureEligible.set(device.machine_id, nowMs + 60_000);
|
|
1478
|
+
// Silent: success and failure are the same empty response; membership is never returned.
|
|
1479
|
+
res.writeHead(204, { 'cache-control': 'no-store' });
|
|
1480
|
+
return res.end();
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1444
1483
|
if (method === 'POST' && path === '/v1/fleet/messages') {
|
|
1445
1484
|
/**
|
|
1446
1485
|
* v1.2.6: posting to the group is a dispatch-class action. An observer may read the flow and may not speak into it, and
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay-side ToFuture whitelist (S2): rows are HMACs only, never identifiers.
|
|
3
|
+
*
|
|
4
|
+
* The admin provisions `<stateDir>/tofuture-whitelist.json` offline:
|
|
5
|
+
* { "v": 1, "rows": [ { "kind": "mac", "hmac": "<64hex>", "salt_id": "<16hex>" }, ... ] }
|
|
6
|
+
*
|
|
7
|
+
* `compare(entries)` is intentionally silent: it returns only whether ANY entry matched, never
|
|
8
|
+
* which row, which kind, or how many; callers must not send that boolean to the client.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
14
|
+
const HEX16 = /^[0-9a-f]{16}$/;
|
|
15
|
+
|
|
16
|
+
export function loadWhitelist(stateDir) {
|
|
17
|
+
if (typeof stateDir !== 'string' || stateDir === '') return { ok: true, rows: [], empty: true };
|
|
18
|
+
const path = join(stateDir, 'tofuture-whitelist.json');
|
|
19
|
+
if (!existsSync(path)) return { ok: true, rows: [], empty: true, path };
|
|
20
|
+
let parsed;
|
|
21
|
+
try { parsed = JSON.parse(readFileSync(path, 'utf8')); } catch { return { ok: false, code: 'TOFUTURE_WHITELIST_INVALID', path }; }
|
|
22
|
+
const rows = Array.isArray(parsed?.rows) ? parsed.rows : [];
|
|
23
|
+
const clean = [];
|
|
24
|
+
for (const row of rows) {
|
|
25
|
+
if (row === null || typeof row !== 'object') continue;
|
|
26
|
+
if (!HEX64.test(String(row.hmac ?? ''))) continue;
|
|
27
|
+
if (!HEX16.test(String(row.salt_id ?? ''))) continue;
|
|
28
|
+
// Only the HMAC and its salt id are kept; any extra field (a leaked identifier) is dropped.
|
|
29
|
+
clean.push({ kind: typeof row.kind === 'string' ? row.kind.slice(0, 8) : 'any', hmac: String(row.hmac), salt_id: String(row.salt_id) });
|
|
30
|
+
}
|
|
31
|
+
return { ok: true, rows: clean, empty: clean.length === 0, path };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @returns {boolean} true when at least one entry matches a whitelisted HMAC. */
|
|
35
|
+
export function whitelistMatches(whitelist, entries) {
|
|
36
|
+
if (whitelist === null || whitelist.ok !== true || Array.isArray(whitelist.rows) !== true) return false;
|
|
37
|
+
if (!Array.isArray(entries) || entries.length === 0) return false;
|
|
38
|
+
const byHmac = new Set(whitelist.rows.map((row) => row.hmac + '|' + row.salt_id));
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (entry === null || typeof entry !== 'object') continue;
|
|
41
|
+
if (!HEX64.test(String(entry.hmac ?? ''))) continue;
|
|
42
|
+
if (!HEX16.test(String(entry.salt_id ?? ''))) continue;
|
|
43
|
+
if (byHmac.has(String(entry.hmac) + '|' + String(entry.salt_id))) return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToFuture identity HMAC (S2): the only form in which network identifiers leave the machine.
|
|
3
|
+
*
|
|
4
|
+
* One canonical algorithm for client, whitelist provisioning and relay comparison:
|
|
5
|
+
* salt_id = HMAC(fleet_key, "w2m-ops-salt-v1|" + machine_id) hex[0..16]
|
|
6
|
+
* hmac = HMAC(fleet_key, "w2m-ops-id-v1|" + kind + "|" + value + "|" + salt_id) hex
|
|
7
|
+
*
|
|
8
|
+
* Nothing here returns, stores or logs `value`; callers pass it in memory and keep only `hmac`.
|
|
9
|
+
*/
|
|
10
|
+
import { createHmac } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
export const ID_KINDS = Object.freeze(['mac', 'ipv4', 'ipv6', 'bssid']);
|
|
13
|
+
|
|
14
|
+
const hmacHex = (key, text) => createHmac('sha256', key).update(text, 'utf8').digest('hex');
|
|
15
|
+
|
|
16
|
+
export function saltIdOf(fleetKey, machineId) {
|
|
17
|
+
return hmacHex(String(fleetKey), 'w2m-ops-salt-v1|' + String(machineId)).slice(0, 16);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function identityHmac(fleetKey, machineId, kind, value) {
|
|
21
|
+
const salt = saltIdOf(fleetKey, machineId);
|
|
22
|
+
return { kind, salt_id: salt, hmac: hmacHex(String(fleetKey), 'w2m-ops-id-v1|' + kind + '|' + String(value) + '|' + salt) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Stable JSON for the request body (keys sorted) — used by tests and future attestation. */
|
|
26
|
+
export function canonicalJson(value) {
|
|
27
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
28
|
+
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']';
|
|
29
|
+
return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + canonicalJson(value[key])).join(',') + '}';
|
|
30
|
+
}
|