@mebular/fleet 0.0.0-stage → 0.1.0
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/dist/agentdir.d.ts +43 -0
- package/dist/agentdir.d.ts.map +1 -0
- package/dist/agentdir.js +107 -0
- package/dist/agentdir.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1135 -0
- package/dist/cli.js.map +1 -0
- package/dist/collab/chatter.d.ts +54 -0
- package/dist/collab/chatter.d.ts.map +1 -0
- package/dist/collab/chatter.js +94 -0
- package/dist/collab/chatter.js.map +1 -0
- package/dist/collab/dag.d.ts +27 -0
- package/dist/collab/dag.d.ts.map +1 -0
- package/dist/collab/dag.js +107 -0
- package/dist/collab/dag.js.map +1 -0
- package/dist/collab/negotiation.d.ts +60 -0
- package/dist/collab/negotiation.d.ts.map +1 -0
- package/dist/collab/negotiation.js +90 -0
- package/dist/collab/negotiation.js.map +1 -0
- package/dist/collab/tree.d.ts +39 -0
- package/dist/collab/tree.d.ts.map +1 -0
- package/dist/collab/tree.js +177 -0
- package/dist/collab/tree.js.map +1 -0
- package/dist/config.d.ts +123 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +206 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/join.d.ts +50 -0
- package/dist/join.d.ts.map +1 -0
- package/dist/join.js +167 -0
- package/dist/join.js.map +1 -0
- package/dist/jointoken.d.ts +144 -0
- package/dist/jointoken.d.ts.map +1 -0
- package/dist/jointoken.js +472 -0
- package/dist/jointoken.js.map +1 -0
- package/dist/model.d.ts +64 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +158 -0
- package/dist/model.js.map +1 -0
- package/dist/onboard.d.ts +90 -0
- package/dist/onboard.d.ts.map +1 -0
- package/dist/onboard.js +510 -0
- package/dist/onboard.js.map +1 -0
- package/dist/protocol/envelope.d.ts +94 -0
- package/dist/protocol/envelope.d.ts.map +1 -0
- package/dist/protocol/envelope.js +169 -0
- package/dist/protocol/envelope.js.map +1 -0
- package/dist/protocol/events.d.ts +51 -0
- package/dist/protocol/events.d.ts.map +1 -0
- package/dist/protocol/events.js +87 -0
- package/dist/protocol/events.js.map +1 -0
- package/dist/qr.d.ts +15 -0
- package/dist/qr.d.ts.map +1 -0
- package/dist/qr.js +52 -0
- package/dist/qr.js.map +1 -0
- package/dist/quickstart.d.ts +167 -0
- package/dist/quickstart.d.ts.map +1 -0
- package/dist/quickstart.js +482 -0
- package/dist/quickstart.js.map +1 -0
- package/dist/quota.d.ts +41 -0
- package/dist/quota.d.ts.map +1 -0
- package/dist/quota.js +84 -0
- package/dist/quota.js.map +1 -0
- package/dist/runtime/agent.d.ts +100 -0
- package/dist/runtime/agent.d.ts.map +1 -0
- package/dist/runtime/agent.js +260 -0
- package/dist/runtime/agent.js.map +1 -0
- package/dist/runtime/executor.d.ts +42 -0
- package/dist/runtime/executor.d.ts.map +1 -0
- package/dist/runtime/executor.js +87 -0
- package/dist/runtime/executor.js.map +1 -0
- package/dist/runtime/fairness.d.ts +25 -0
- package/dist/runtime/fairness.d.ts.map +1 -0
- package/dist/runtime/fairness.js +79 -0
- package/dist/runtime/fairness.js.map +1 -0
- package/dist/runtime/node.d.ts +91 -0
- package/dist/runtime/node.d.ts.map +1 -0
- package/dist/runtime/node.js +203 -0
- package/dist/runtime/node.js.map +1 -0
- package/dist/runtime/openchamber-http.d.ts +27 -0
- package/dist/runtime/openchamber-http.d.ts.map +1 -0
- package/dist/runtime/openchamber-http.js +118 -0
- package/dist/runtime/openchamber-http.js.map +1 -0
- package/dist/runtime/openchamber.d.ts +38 -0
- package/dist/runtime/openchamber.d.ts.map +1 -0
- package/dist/runtime/openchamber.js +43 -0
- package/dist/runtime/openchamber.js.map +1 -0
- package/dist/runtime/planner.d.ts +23 -0
- package/dist/runtime/planner.d.ts.map +1 -0
- package/dist/runtime/planner.js +17 -0
- package/dist/runtime/planner.js.map +1 -0
- package/dist/runtime/worker.d.ts +96 -0
- package/dist/runtime/worker.d.ts.map +1 -0
- package/dist/runtime/worker.js +296 -0
- package/dist/runtime/worker.js.map +1 -0
- package/dist/security.d.ts +462 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +1197 -0
- package/dist/security.js.map +1 -0
- package/dist/store/daemon-store.d.ts +33 -0
- package/dist/store/daemon-store.d.ts.map +1 -0
- package/dist/store/daemon-store.js +115 -0
- package/dist/store/daemon-store.js.map +1 -0
- package/dist/store/file-store.d.ts +22 -0
- package/dist/store/file-store.d.ts.map +1 -0
- package/dist/store/file-store.js +63 -0
- package/dist/store/file-store.js.map +1 -0
- package/dist/store/mebular-store.d.ts +22 -0
- package/dist/store/mebular-store.d.ts.map +1 -0
- package/dist/store/mebular-store.js +53 -0
- package/dist/store/mebular-store.js.map +1 -0
- package/dist/store/message-store.d.ts +26 -0
- package/dist/store/message-store.d.ts.map +1 -0
- package/dist/store/message-store.js +51 -0
- package/dist/store/message-store.js.map +1 -0
- package/dist/surface.d.ts +43 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +515 -0
- package/dist/surface.js.map +1 -0
- package/dist/transport/null.d.ts +7 -0
- package/dist/transport/null.d.ts.map +1 -0
- package/dist/transport/null.js +16 -0
- package/dist/transport/null.js.map +1 -0
- package/dist/transport/spool.d.ts +12 -0
- package/dist/transport/spool.d.ts.map +1 -0
- package/dist/transport/spool.js +54 -0
- package/dist/transport/spool.js.map +1 -0
- package/dist/transport/types.d.ts +21 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/types.js +6 -0
- package/dist/transport/types.js.map +1 -0
- package/dist/version.json +6 -0
- package/package.json +37 -4
- package/protocol/collab.example.json +23 -0
- package/protocol/daemon-home.example.json +66 -0
- package/protocol/envelope.example.json +12 -0
- package/protocol/state-machine.json +14 -0
- package/protocol/task-tree.example.json +72 -0
- package/src/agentdir.ts +138 -0
- package/src/cli.ts +1192 -0
- package/src/collab/chatter.ts +116 -0
- package/src/collab/dag.ts +124 -0
- package/src/collab/negotiation.ts +130 -0
- package/src/collab/tree.ts +184 -0
- package/src/config.ts +286 -0
- package/src/index.ts +39 -0
- package/src/join.ts +227 -0
- package/src/jointoken.ts +609 -0
- package/src/model.ts +203 -0
- package/src/onboard.ts +601 -0
- package/src/protocol/envelope.ts +228 -0
- package/src/protocol/events.ts +135 -0
- package/src/qr.ts +68 -0
- package/src/quickstart.ts +632 -0
- package/src/quota.ts +112 -0
- package/src/runtime/agent.ts +313 -0
- package/src/runtime/executor.ts +114 -0
- package/src/runtime/fairness.ts +90 -0
- package/src/runtime/node.ts +250 -0
- package/src/runtime/openchamber-http.ts +137 -0
- package/src/runtime/openchamber.ts +66 -0
- package/src/runtime/planner.ts +36 -0
- package/src/runtime/worker.ts +354 -0
- package/src/security.ts +1660 -0
- package/src/store/daemon-store.ts +144 -0
- package/src/store/file-store.ts +75 -0
- package/src/store/mebular-store.ts +66 -0
- package/src/store/message-store.ts +62 -0
- package/src/surface.ts +535 -0
- package/src/transport/null.ts +20 -0
- package/src/transport/spool.ts +62 -0
- package/src/transport/types.ts +27 -0
- package/README.md +0 -3
package/src/jointoken.ts
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
// 信任模型 v2(T2):加入令牌 + join 请求-签发(**embedded / test-only**)。
|
|
2
|
+
//
|
|
3
|
+
// W2 起:**生产 join 由守护托管**(`mebular serve` 的 `/mebular/join`,见 packages/mcp/src/jointoken.mjs);
|
|
4
|
+
// F-UNI:本文件是**唯一实现**——守护(`mebular serve` 的 join 服务)经 `@mebular/mcp` 薄 re-export 直接使用;
|
|
5
|
+
// `fleet` CLI 的 embedded 自托管路径(`fleet node --join-serve`)仅供测试/CI。
|
|
6
|
+
//
|
|
7
|
+
// 设计(含用户修正:去中心化):
|
|
8
|
+
// - **任意**在册设备都可作 inviter(无“指定主设备/CA”);令牌由**inviter 设备私钥**签名。
|
|
9
|
+
// - 令牌只含**授权信息**(inviter 身份/证书链、用户主公钥、命名空间、nonce、TTL、endpoint),
|
|
10
|
+
// **绝不含主密钥私钥**;主密钥可完全离线。
|
|
11
|
+
// - 过期(TTL)/ 一次性(nonce 已用)/ 被撕(显式撤销)三种吊销与**设备证书吊销**(`device_revoke` 级联)
|
|
12
|
+
// 分开:前者是准入凭据,后者是身份失效。
|
|
13
|
+
// - 服务端验令牌后,用 inviter 设备私钥为请求设备签发**委派证书**(1 跳;链上界由 core 强制)。
|
|
14
|
+
|
|
15
|
+
import { chmod, mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
16
|
+
import http, { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
17
|
+
import https from 'node:https';
|
|
18
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
19
|
+
import { dirname } from 'node:path';
|
|
20
|
+
import {
|
|
21
|
+
bytesToBase64,
|
|
22
|
+
bytesToHex,
|
|
23
|
+
hexToBytes,
|
|
24
|
+
base64ToBytes,
|
|
25
|
+
canonicalCertificateData,
|
|
26
|
+
verifyCertificateChain,
|
|
27
|
+
type DeviceCertificate,
|
|
28
|
+
type Mebular,
|
|
29
|
+
} from '@mebular/core';
|
|
30
|
+
|
|
31
|
+
/** 令牌(inviter 设备私钥签名;不含主密钥)。 */
|
|
32
|
+
export interface JoinToken {
|
|
33
|
+
v: 1;
|
|
34
|
+
kind: 'mebular-fleet-join-token';
|
|
35
|
+
inviterDeviceId: string;
|
|
36
|
+
/** inviter 设备公钥(hex) */
|
|
37
|
+
inviterPublicKey: string;
|
|
38
|
+
/** inviter 的证书链(叶→根),供请求方与验签方锚定到主密钥 */
|
|
39
|
+
inviterChain: DeviceCertificate[];
|
|
40
|
+
/** 用户主公钥(hex;公开信息,非秘密) */
|
|
41
|
+
masterPublicKey: string;
|
|
42
|
+
namespace: string;
|
|
43
|
+
nonce: string;
|
|
44
|
+
issuedAt: number;
|
|
45
|
+
expiresAt: number;
|
|
46
|
+
/** join 服务端点(http(s)://host:port) */
|
|
47
|
+
endpoint: string;
|
|
48
|
+
/**
|
|
49
|
+
* C2:邀请方可达的 P2P 地址(multiaddr,新设备 join 后写入地址簿)。
|
|
50
|
+
* 旧令牌可缺省;缺省时 join 侧不写 hints(行为与历史一致)。
|
|
51
|
+
*/
|
|
52
|
+
endpoints?: string[];
|
|
53
|
+
/** C2:邀请方建议的 relay seeds(可选) */
|
|
54
|
+
relaySeeds?: string[];
|
|
55
|
+
/**
|
|
56
|
+
* high 档首次订阅的会签证明(批准记录 + cosign)。新设备把它写进自己的
|
|
57
|
+
* `namespace_subscription`,证明随记录走、全网验收。
|
|
58
|
+
*/
|
|
59
|
+
securityApproval?: {
|
|
60
|
+
approvalId: string;
|
|
61
|
+
nonce: string;
|
|
62
|
+
initiator?: string;
|
|
63
|
+
cosign?: Record<string, unknown>;
|
|
64
|
+
};
|
|
65
|
+
/** C2:邀请方是否有公网可达地址(诊断提示用;仅信息,不参与授权) */
|
|
66
|
+
pubReachable?: boolean;
|
|
67
|
+
/** inviter 设备私钥对 canonical 数据的签名(base64) */
|
|
68
|
+
signature: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 令牌规范化签名内容(固定键序,排除 signature)。 */
|
|
72
|
+
export function canonicalJoinTokenData(token: Omit<JoinToken, 'signature'> & { signature?: string }): string {
|
|
73
|
+
// 注意:键序固定;C2 可选 hints 仅在存在时进入签名体 → 旧令牌(无 hints)签名不变,向后兼容。
|
|
74
|
+
return JSON.stringify({
|
|
75
|
+
v: token.v,
|
|
76
|
+
kind: token.kind,
|
|
77
|
+
inviterDeviceId: token.inviterDeviceId,
|
|
78
|
+
inviterPublicKey: token.inviterPublicKey,
|
|
79
|
+
inviterChain: token.inviterChain,
|
|
80
|
+
masterPublicKey: token.masterPublicKey,
|
|
81
|
+
namespace: token.namespace,
|
|
82
|
+
nonce: token.nonce,
|
|
83
|
+
issuedAt: token.issuedAt,
|
|
84
|
+
expiresAt: token.expiresAt,
|
|
85
|
+
endpoint: token.endpoint,
|
|
86
|
+
...(token.endpoints !== undefined ? { endpoints: token.endpoints } : {}),
|
|
87
|
+
...(token.relaySeeds !== undefined ? { relaySeeds: token.relaySeeds } : {}),
|
|
88
|
+
...(token.pubReachable !== undefined ? { pubReachable: token.pubReachable } : {}),
|
|
89
|
+
...(token.securityApproval !== undefined ? { securityApproval: token.securityApproval } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function encodeJoinToken(token: JoinToken): string {
|
|
94
|
+
return Buffer.from(JSON.stringify(token), 'utf-8').toString('base64');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function decodeJoinToken(text: string): JoinToken {
|
|
98
|
+
const trimmed = text.trim();
|
|
99
|
+
if (trimmed.length === 0) throw new Error('令牌为空');
|
|
100
|
+
const json = trimmed.startsWith('{') ? trimmed : Buffer.from(trimmed, 'base64').toString('utf-8');
|
|
101
|
+
let parsed: unknown;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(json);
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error('令牌无法解析(既非 base64 也非 JSON)');
|
|
106
|
+
}
|
|
107
|
+
const t = parsed as Record<string, unknown>;
|
|
108
|
+
if (t.v !== 1 || t.kind !== 'mebular-fleet-join-token') throw new Error('令牌版本/类型不受支持');
|
|
109
|
+
for (const key of ['inviterDeviceId', 'inviterPublicKey', 'masterPublicKey', 'namespace', 'nonce', 'endpoint', 'signature']) {
|
|
110
|
+
if (typeof t[key] !== 'string') throw new Error(`令牌缺少字段:${key}`);
|
|
111
|
+
}
|
|
112
|
+
if (typeof t.issuedAt !== 'number' || typeof t.expiresAt !== 'number') throw new Error('令牌时间字段非法');
|
|
113
|
+
if (!Array.isArray(t.inviterChain) || t.inviterChain.length === 0) throw new Error('令牌缺少 inviter 证书链');
|
|
114
|
+
// C2:可选 hints 的形状校验(缺省合法;非法即拒,避免半截数据流入地址簿)
|
|
115
|
+
if (t.endpoints !== undefined && (!Array.isArray(t.endpoints) || t.endpoints.some((e) => typeof e !== 'string'))) {
|
|
116
|
+
throw new Error('令牌 endpoints 形状非法(应为字符串数组)');
|
|
117
|
+
}
|
|
118
|
+
if (t.relaySeeds !== undefined && (!Array.isArray(t.relaySeeds) || t.relaySeeds.some((e) => typeof e !== 'string'))) {
|
|
119
|
+
throw new Error('令牌 relaySeeds 形状非法(应为字符串数组)');
|
|
120
|
+
}
|
|
121
|
+
if (t.pubReachable !== undefined && typeof t.pubReachable !== 'boolean') {
|
|
122
|
+
throw new Error('令牌 pubReachable 形状非法(应为布尔)');
|
|
123
|
+
}
|
|
124
|
+
if (t.securityApproval !== undefined) {
|
|
125
|
+
const approval = t.securityApproval as Record<string, unknown> | null;
|
|
126
|
+
if (approval === null || typeof approval !== 'object'
|
|
127
|
+
|| typeof approval.approvalId !== 'string' || typeof approval.nonce !== 'string') {
|
|
128
|
+
throw new Error('令牌 securityApproval 形状非法');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return t as unknown as JoinToken;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 脱敏描述:**不含签名**,供日志/报告。 */
|
|
135
|
+
export function describeJoinToken(token: JoinToken): Record<string, unknown> {
|
|
136
|
+
return {
|
|
137
|
+
v: token.v,
|
|
138
|
+
kind: token.kind,
|
|
139
|
+
inviterDeviceId: token.inviterDeviceId,
|
|
140
|
+
namespace: token.namespace,
|
|
141
|
+
nonce: token.nonce,
|
|
142
|
+
issuedAt: token.issuedAt,
|
|
143
|
+
expiresAt: token.expiresAt,
|
|
144
|
+
endpoint: token.endpoint,
|
|
145
|
+
chainLen: token.inviterChain.length,
|
|
146
|
+
hasMasterPrivateKey: false,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 令牌 nonce 状态文件(本地;0600)。 */
|
|
151
|
+
export interface JoinNonceState {
|
|
152
|
+
used: string[];
|
|
153
|
+
revoked: string[];
|
|
154
|
+
/**
|
|
155
|
+
* 当前唯一有效的邀请 nonce(旧状态文件可能没有)。
|
|
156
|
+
* 生成新令牌时把上一个 active 放入 revoked → 重新生成即让旧令牌失效。
|
|
157
|
+
*/
|
|
158
|
+
active?: string;
|
|
159
|
+
}
|
|
160
|
+
export function joinNonceStatePath(storagePath: string): string {
|
|
161
|
+
return `${storagePath}.join-tokens.json`;
|
|
162
|
+
}
|
|
163
|
+
export async function readJoinNonceState(storagePath: string): Promise<JoinNonceState> {
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(await readFile(joinNonceStatePath(storagePath), 'utf-8')) as Partial<JoinNonceState>;
|
|
166
|
+
return {
|
|
167
|
+
used: Array.isArray(parsed.used) ? parsed.used : [],
|
|
168
|
+
revoked: Array.isArray(parsed.revoked) ? parsed.revoked : [],
|
|
169
|
+
...(typeof parsed.active === 'string' ? { active: parsed.active } : {}),
|
|
170
|
+
};
|
|
171
|
+
} catch {
|
|
172
|
+
return { used: [], revoked: [] };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export async function writeJoinNonceState(storagePath: string, state: JoinNonceState): Promise<void> {
|
|
176
|
+
const path = joinNonceStatePath(storagePath);
|
|
177
|
+
await mkdir(dirname(path), { recursive: true });
|
|
178
|
+
// tmp + rename:进程在写盘中途退出也不会留下半截状态(used/revoked 丢失会重新放行旧令牌)。
|
|
179
|
+
const tmp = `${path}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
180
|
+
try {
|
|
181
|
+
await writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
182
|
+
await rename(tmp, path);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
await rm(tmp, { force: true }).catch(() => undefined);
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
await chmod(path, 0o600);
|
|
189
|
+
} catch {
|
|
190
|
+
// Windows 无 POSIX mode;忽略
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 激活新邀请 nonce:把上一个 active 放入 revoked,再落盘新 active。
|
|
196
|
+
* 生成与兑换共用同一把锁(同进程内存队列 + <state>.lock 跨进程),避免并发丢更新。
|
|
197
|
+
*/
|
|
198
|
+
export async function activateJoinInvite(storagePath: string, nonce: string): Promise<{ revoked: string | null }> {
|
|
199
|
+
if (typeof nonce !== 'string' || !/^[0-9a-f]{32}$/.test(nonce)) throw new Error('邀请 nonce 非法');
|
|
200
|
+
return withNonceLock(storagePath, async () => {
|
|
201
|
+
const state = await readJoinNonceState(storagePath);
|
|
202
|
+
const previous = state.active;
|
|
203
|
+
let revoked: string | null = null;
|
|
204
|
+
if (previous !== undefined && previous !== nonce && !state.used.includes(previous) && !state.revoked.includes(previous)) {
|
|
205
|
+
state.revoked.push(previous);
|
|
206
|
+
revoked = previous;
|
|
207
|
+
}
|
|
208
|
+
state.active = nonce;
|
|
209
|
+
await writeJoinNonceState(storagePath, state);
|
|
210
|
+
return { revoked };
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 签发令牌(inviter 设备私钥)。 */
|
|
215
|
+
export async function buildJoinToken(input: {
|
|
216
|
+
mebular: Mebular;
|
|
217
|
+
deviceId: string;
|
|
218
|
+
namespace: string;
|
|
219
|
+
endpoint: string;
|
|
220
|
+
ttlMs?: number;
|
|
221
|
+
now?: number;
|
|
222
|
+
nonce?: string;
|
|
223
|
+
/** C2:邀请方可达 P2P 地址(缺省自动从本机节点收集) */
|
|
224
|
+
endpoints?: string[];
|
|
225
|
+
relaySeeds?: string[];
|
|
226
|
+
pubReachable?: boolean;
|
|
227
|
+
/** high 档首次订阅会签(由控制台/CLI 的批准流程带入) */
|
|
228
|
+
securityApproval?: JoinToken['securityApproval'];
|
|
229
|
+
}): Promise<JoinToken> {
|
|
230
|
+
const identity = input.mebular.identity.getDeviceKey(input.deviceId);
|
|
231
|
+
if (!identity) throw new Error(`本机无设备密钥:${input.deviceId}`);
|
|
232
|
+
const chain = identity.certificateChain ?? (identity.certificate ? [identity.certificate] : []);
|
|
233
|
+
if (chain.length === 0) throw new Error('本机无有效证书链,无法签发令牌(先 onboard/join)');
|
|
234
|
+
const masterPub = input.mebular.identity.getUserMasterPublicKey();
|
|
235
|
+
if (!masterPub) throw new Error('本机无用户主公钥,无法签发令牌');
|
|
236
|
+
const now = input.now ?? Date.now();
|
|
237
|
+
const unsigned: Omit<JoinToken, 'signature'> = {
|
|
238
|
+
v: 1,
|
|
239
|
+
kind: 'mebular-fleet-join-token',
|
|
240
|
+
inviterDeviceId: input.deviceId,
|
|
241
|
+
inviterPublicKey: bytesToHex(identity.publicKey),
|
|
242
|
+
inviterChain: chain,
|
|
243
|
+
masterPublicKey: bytesToHex(masterPub),
|
|
244
|
+
namespace: input.namespace,
|
|
245
|
+
nonce: input.nonce ?? randomBytes(16).toString('hex'),
|
|
246
|
+
issuedAt: now,
|
|
247
|
+
expiresAt: now + (input.ttlMs ?? 900_000),
|
|
248
|
+
endpoint: input.endpoint,
|
|
249
|
+
};
|
|
250
|
+
// C2:自动收集本机可达 P2P 地址(缺省),显式传入优先;空则不写字段(与旧令牌等价)
|
|
251
|
+
const endpoints = input.endpoints ?? collectReachableEndpoints(input.mebular);
|
|
252
|
+
const relaySeeds = input.relaySeeds ?? collectRelaySeeds(input.mebular);
|
|
253
|
+
if (endpoints.length > 0) unsigned.endpoints = endpoints;
|
|
254
|
+
if (relaySeeds.length > 0) unsigned.relaySeeds = relaySeeds;
|
|
255
|
+
const pubReachable = input.pubReachable ?? endpoints.some((addr) => !isLoopbackAddress(addr));
|
|
256
|
+
if (endpoints.length > 0 || input.pubReachable !== undefined) unsigned.pubReachable = pubReachable;
|
|
257
|
+
if (input.securityApproval !== undefined) unsigned.securityApproval = input.securityApproval;
|
|
258
|
+
const signature = await globalThis.crypto.subtle.sign(
|
|
259
|
+
{ name: 'Ed25519' },
|
|
260
|
+
identity.privateKey,
|
|
261
|
+
new TextEncoder().encode(canonicalJoinTokenData(unsigned)),
|
|
262
|
+
);
|
|
263
|
+
return { ...unsigned, signature: bytesToBase64(new Uint8Array(signature)) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** C2:本机可达 P2P 地址(节点监听 + /p2p/<peerId>;无节点则空)。 */
|
|
267
|
+
export function collectReachableEndpoints(mebular: Mebular): string[] {
|
|
268
|
+
const node = mebular.node;
|
|
269
|
+
if (!node || !node.isRunning()) return [];
|
|
270
|
+
const peerId = node.peerId?.id;
|
|
271
|
+
const addrs = node.getLocalMultiaddrs?.() ?? [];
|
|
272
|
+
const out: string[] = [];
|
|
273
|
+
for (const addr of addrs) {
|
|
274
|
+
if (typeof addr !== 'string' || addr.includes('/p2p-circuit')) continue;
|
|
275
|
+
const withPeer = /\/p2p\//.test(addr) || !peerId ? addr : `${addr}/p2p/${peerId}`;
|
|
276
|
+
if (!out.includes(withPeer)) out.push(withPeer);
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* C2:可共享的 relay seeds(供新设备在无法直连时中转)。
|
|
283
|
+
* app 已把 `network.relaySeeds` 并入 `network.libp2p.relayServers`,此处经公开 getter 读取。
|
|
284
|
+
*/
|
|
285
|
+
export function collectRelaySeeds(mebular: Mebular): string[] {
|
|
286
|
+
const seeds = mebular.relayServers;
|
|
287
|
+
return Array.isArray(seeds) ? seeds.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) : [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** 地址是否为回环(用于 pubReachable 提示;判断失败按回环处理)。 */
|
|
291
|
+
export function isLoopbackAddress(address: string): boolean {
|
|
292
|
+
const host = /\/(?:ip4|ip6|dns4|dns6|dns)\/([^/]+)/.exec(address)?.[1] ?? '';
|
|
293
|
+
return ['127.0.0.1', '::1', 'localhost', ''].includes(host) || host.startsWith('127.');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface VerifyJoinTokenOptions {
|
|
297
|
+
now?: number;
|
|
298
|
+
used?: readonly string[];
|
|
299
|
+
revoked?: readonly string[];
|
|
300
|
+
/** 仅接受该 inviter(join 服务端 = 本机时使用) */
|
|
301
|
+
expectedInviter?: string;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export type JoinTokenFailure =
|
|
305
|
+
| 'signature'
|
|
306
|
+
| 'chain'
|
|
307
|
+
| 'inviter'
|
|
308
|
+
| 'expired'
|
|
309
|
+
| 'used'
|
|
310
|
+
| 'revoked';
|
|
311
|
+
|
|
312
|
+
/** 校验令牌:签名 → 链锚定主密钥 → inviter 匹配 → TTL → 一次性 → 撕票。 */
|
|
313
|
+
export async function verifyJoinToken(
|
|
314
|
+
token: JoinToken,
|
|
315
|
+
opts: VerifyJoinTokenOptions = {},
|
|
316
|
+
): Promise<{ ok: true } | { ok: false; reason: JoinTokenFailure }> {
|
|
317
|
+
// 1) 签名(inviter 设备公钥)
|
|
318
|
+
try {
|
|
319
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
320
|
+
'raw',
|
|
321
|
+
hexToBytes(token.inviterPublicKey).buffer as ArrayBuffer,
|
|
322
|
+
{ name: 'Ed25519' },
|
|
323
|
+
false,
|
|
324
|
+
['verify'],
|
|
325
|
+
);
|
|
326
|
+
const ok = await globalThis.crypto.subtle.verify(
|
|
327
|
+
{ name: 'Ed25519' },
|
|
328
|
+
key,
|
|
329
|
+
base64ToBytes(token.signature).buffer as ArrayBuffer,
|
|
330
|
+
new TextEncoder().encode(canonicalJoinTokenData(token)),
|
|
331
|
+
);
|
|
332
|
+
if (!ok) return { ok: false, reason: 'signature' };
|
|
333
|
+
} catch {
|
|
334
|
+
return { ok: false, reason: 'signature' };
|
|
335
|
+
}
|
|
336
|
+
// 2) inviter 证书链锚定主密钥
|
|
337
|
+
const chainOk = await verifyCertificateChain(token.inviterChain, hexToBytes(token.masterPublicKey), {
|
|
338
|
+
subjectDeviceId: token.inviterDeviceId,
|
|
339
|
+
});
|
|
340
|
+
if (!chainOk) return { ok: false, reason: 'chain' };
|
|
341
|
+
// 3) inviter 匹配
|
|
342
|
+
if (opts.expectedInviter !== undefined && token.inviterDeviceId !== opts.expectedInviter) {
|
|
343
|
+
return { ok: false, reason: 'inviter' };
|
|
344
|
+
}
|
|
345
|
+
// 4) TTL(inviter/服务端时钟为准)
|
|
346
|
+
const now = opts.now ?? Date.now();
|
|
347
|
+
if (!(now <= token.expiresAt)) return { ok: false, reason: 'expired' };
|
|
348
|
+
// 5) 一次性 / 撕票
|
|
349
|
+
if ((opts.used ?? []).includes(token.nonce)) return { ok: false, reason: 'used' };
|
|
350
|
+
if ((opts.revoked ?? []).includes(token.nonce)) return { ok: false, reason: 'revoked' };
|
|
351
|
+
return { ok: true };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** inviter 证书链的指纹(诊断/去重;非密钥材料)。 */
|
|
355
|
+
export function chainFingerprint(chain: readonly DeviceCertificate[]): string {
|
|
356
|
+
const leaf = chain[0];
|
|
357
|
+
const material = canonicalCertificateData(leaf!) + (chain[chain.length - 1]?.deviceId ?? '');
|
|
358
|
+
return `sha256:${createHash('sha256').update(material).digest('hex').slice(0, 12)}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---------- join 服务端(最小监听端点) ----------
|
|
362
|
+
|
|
363
|
+
export interface JoinServiceOptions {
|
|
364
|
+
mebular: Mebular;
|
|
365
|
+
deviceId: string;
|
|
366
|
+
storagePath: string;
|
|
367
|
+
/** 绑定地址(默认 0.0.0.0);令牌是 bearer,建议 LAN + 短 TTL */
|
|
368
|
+
bind?: string;
|
|
369
|
+
/** 端口(默认 0 = 随机;quickstart 默认 4002) */
|
|
370
|
+
port: number;
|
|
371
|
+
log?: (msg: string) => void;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface JoinService {
|
|
375
|
+
server: Server;
|
|
376
|
+
port: number;
|
|
377
|
+
close: () => Promise<void>;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function readBody(req: IncomingMessage, limitBytes = 256 * 1024): Promise<string> {
|
|
381
|
+
return new Promise((resolve, reject) => {
|
|
382
|
+
let data = '';
|
|
383
|
+
req.on('data', (chunk: Buffer) => {
|
|
384
|
+
data += chunk.toString('utf-8');
|
|
385
|
+
if (data.length > limitBytes) {
|
|
386
|
+
reject(new Error('请求体过大'));
|
|
387
|
+
req.destroy();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
req.on('end', () => resolve(data));
|
|
391
|
+
req.on('error', reject);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
396
|
+
const text = JSON.stringify(body);
|
|
397
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
398
|
+
res.end(text);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 启动 join 服务:`POST /mebular/join {token, deviceId, devicePublicKey}` → 验令牌后签发**委派证书**。
|
|
403
|
+
* `GET /mebular/ping` 用于就绪探测。**不接收、不返回任何主密钥材料**。
|
|
404
|
+
*/
|
|
405
|
+
/**
|
|
406
|
+
* 每 storagePath 的非一路串行锁:同进程内存队列 + 跨进程 `<state>.lock`。
|
|
407
|
+
* 令牌「先占用后签发」与「生成新令牌时撤销旧 active」共用此锁,避免并发丢更新。
|
|
408
|
+
*/
|
|
409
|
+
const nonceLocks = new Map<string, Promise<unknown>>();
|
|
410
|
+
|
|
411
|
+
/** 跨进程文件锁;持有时间应远小于 10s,陈旧锁(PID 已退出或超时)自动回收。 */
|
|
412
|
+
async function acquireNonceFileLock(storagePath: string): Promise<() => Promise<void>> {
|
|
413
|
+
const path = `${joinNonceStatePath(storagePath)}.lock`;
|
|
414
|
+
await mkdir(dirname(path), { recursive: true });
|
|
415
|
+
const deadline = Date.now() + 5_000;
|
|
416
|
+
for (;;) {
|
|
417
|
+
try {
|
|
418
|
+
const handle = await open(path, 'wx', 0o600);
|
|
419
|
+
try {
|
|
420
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
421
|
+
} finally {
|
|
422
|
+
await handle.close();
|
|
423
|
+
}
|
|
424
|
+
return async () => {
|
|
425
|
+
await unlink(path).catch(() => undefined);
|
|
426
|
+
};
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
429
|
+
}
|
|
430
|
+
let stale = false;
|
|
431
|
+
try {
|
|
432
|
+
const info = JSON.parse(await readFile(path, 'utf-8')) as { pid?: unknown };
|
|
433
|
+
if (typeof info.pid === 'number' && info.pid !== process.pid) {
|
|
434
|
+
try {
|
|
435
|
+
process.kill(info.pid, 0);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
if ((error as NodeJS.ErrnoException).code === 'ESRCH') stale = true;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const lockStat = await stat(path);
|
|
441
|
+
if (Date.now() - lockStat.mtimeMs > 10_000) stale = true;
|
|
442
|
+
} catch {
|
|
443
|
+
// 锁文件可能刚被释放或尚未写完:下一轮重试
|
|
444
|
+
}
|
|
445
|
+
if (stale) {
|
|
446
|
+
await unlink(path).catch(() => undefined);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (Date.now() >= deadline) throw new Error('加入令牌状态被其他进程占用,请稍后重试');
|
|
450
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function withNonceLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
455
|
+
const prev = nonceLocks.get(key) ?? Promise.resolve();
|
|
456
|
+
const run = prev.then(async () => {
|
|
457
|
+
const release = await acquireNonceFileLock(key);
|
|
458
|
+
try {
|
|
459
|
+
return await fn();
|
|
460
|
+
} finally {
|
|
461
|
+
await release();
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
nonceLocks.set(
|
|
465
|
+
key,
|
|
466
|
+
run.then(
|
|
467
|
+
() => undefined,
|
|
468
|
+
() => undefined,
|
|
469
|
+
),
|
|
470
|
+
);
|
|
471
|
+
return run;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export async function startJoinService(opts: JoinServiceOptions): Promise<JoinService> {
|
|
475
|
+
const bind = opts.bind ?? '0.0.0.0';
|
|
476
|
+
const server = createServer((req, res) => {
|
|
477
|
+
void (async () => {
|
|
478
|
+
try {
|
|
479
|
+
if (req.method === 'GET' && req.url === '/mebular/ping') {
|
|
480
|
+
sendJson(res, 200, { ok: true, deviceId: opts.deviceId });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (req.method !== 'POST' || req.url !== '/mebular/join') {
|
|
484
|
+
sendJson(res, 404, { ok: false, error: 'not found' });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const body = JSON.parse(await readBody(req)) as {
|
|
488
|
+
token?: string;
|
|
489
|
+
deviceId?: string;
|
|
490
|
+
devicePublicKey?: string;
|
|
491
|
+
};
|
|
492
|
+
if (typeof body.token !== 'string' || typeof body.deviceId !== 'string' || typeof body.devicePublicKey !== 'string') {
|
|
493
|
+
sendJson(res, 400, { ok: false, error: '缺少 token/deviceId/devicePublicKey' });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const token = decodeJoinToken(body.token);
|
|
497
|
+
const deviceId = body.deviceId;
|
|
498
|
+
const devicePublicKey = body.devicePublicKey;
|
|
499
|
+
if (!/^[0-9a-fA-F]{64}$/.test(devicePublicKey)) {
|
|
500
|
+
sendJson(res, 400, { ok: false, error: 'devicePublicKey 必须为 32 字节 hex' });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// F-2:令牌「一次性」在**单服务进程内**原子(先占用后签发),并发不再重复签发。
|
|
504
|
+
await withNonceLock(opts.storagePath, async () => {
|
|
505
|
+
const state = await readJoinNonceState(opts.storagePath);
|
|
506
|
+
const verdict = await verifyJoinToken(token, {
|
|
507
|
+
used: state.used,
|
|
508
|
+
revoked: state.revoked,
|
|
509
|
+
expectedInviter: opts.deviceId,
|
|
510
|
+
});
|
|
511
|
+
if (!verdict.ok) {
|
|
512
|
+
sendJson(res, 403, { ok: false, error: `令牌不可用:${verdict.reason}` });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
// **先占用**(落盘 used)再签发:签发失败即 fail-closed(令牌已消费,不二次签发)。
|
|
516
|
+
state.used.push(token.nonce);
|
|
517
|
+
if (state.active === token.nonce) delete state.active;
|
|
518
|
+
await writeJoinNonceState(opts.storagePath, state);
|
|
519
|
+
try {
|
|
520
|
+
const issued = await opts.mebular.identity.issueDelegatedCertificateFor(deviceId, devicePublicKey, opts.deviceId);
|
|
521
|
+
opts.log?.(`join: issued delegated cert for ${deviceId} (nonce consumed)`);
|
|
522
|
+
sendJson(res, 200, {
|
|
523
|
+
ok: true,
|
|
524
|
+
certificate: issued.certificate,
|
|
525
|
+
chain: issued.certificateChain,
|
|
526
|
+
namespace: token.namespace,
|
|
527
|
+
inviterDeviceId: token.inviterDeviceId,
|
|
528
|
+
inviterMultiaddrs: opts.mebular.node?.getLocalMultiaddrs() ?? [],
|
|
529
|
+
...(token.securityApproval !== undefined ? { securityApproval: token.securityApproval } : {}),
|
|
530
|
+
});
|
|
531
|
+
} catch (error) {
|
|
532
|
+
opts.log?.(`join: nonce ${token.nonce} consumed but issuance failed: ${(error as Error).message}`);
|
|
533
|
+
sendJson(res, 500, { ok: false, error: '签发失败(令牌已消费,fail-closed)' });
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
} catch (error) {
|
|
537
|
+
sendJson(res, 500, { ok: false, error: (error as Error).message });
|
|
538
|
+
}
|
|
539
|
+
})();
|
|
540
|
+
});
|
|
541
|
+
await new Promise<void>((resolve, reject) => {
|
|
542
|
+
server.once('error', reject);
|
|
543
|
+
server.listen({ host: bind === '0.0.0.0' ? undefined : bind, port: opts.port }, () => resolve());
|
|
544
|
+
});
|
|
545
|
+
const addr = server.address();
|
|
546
|
+
const port = typeof addr === 'object' && addr !== null ? addr.port : opts.port;
|
|
547
|
+
return {
|
|
548
|
+
server,
|
|
549
|
+
port,
|
|
550
|
+
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ---------- join 客户端 ----------
|
|
555
|
+
|
|
556
|
+
export interface JoinRequestResult {
|
|
557
|
+
certificate: DeviceCertificate;
|
|
558
|
+
chain: DeviceCertificate[];
|
|
559
|
+
namespace: string;
|
|
560
|
+
inviterDeviceId: string;
|
|
561
|
+
inviterMultiaddrs: string[];
|
|
562
|
+
securityApproval?: JoinToken['securityApproval'];
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** B 持令牌向 inviter 的 join 端点请求委派证书(纯 HTTP;令牌是 bearer)。 */
|
|
566
|
+
export async function requestJoin(input: {
|
|
567
|
+
endpoint: string;
|
|
568
|
+
token: string;
|
|
569
|
+
deviceId: string;
|
|
570
|
+
devicePublicKeyHex: string;
|
|
571
|
+
timeoutMs?: number;
|
|
572
|
+
}): Promise<JoinRequestResult> {
|
|
573
|
+
const url = new URL('/mebular/join', input.endpoint);
|
|
574
|
+
const payload = JSON.stringify({ token: input.token, deviceId: input.deviceId, devicePublicKey: input.devicePublicKeyHex });
|
|
575
|
+
return await new Promise<JoinRequestResult>((resolve, reject) => {
|
|
576
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
577
|
+
const req = transport.request(
|
|
578
|
+
url,
|
|
579
|
+
{ method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } },
|
|
580
|
+
(res: IncomingMessage) => {
|
|
581
|
+
let data = '';
|
|
582
|
+
res.on('data', (c: Buffer) => (data += c.toString('utf-8')));
|
|
583
|
+
res.on('end', () => {
|
|
584
|
+
try {
|
|
585
|
+
const parsed = JSON.parse(data) as { ok?: boolean; error?: string } & Partial<JoinRequestResult>;
|
|
586
|
+
if (res.statusCode !== 200 || parsed.ok !== true) {
|
|
587
|
+
reject(new Error(`join 失败(HTTP ${res.statusCode}):${parsed.error ?? '未知错误'}`));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
resolve({
|
|
591
|
+
certificate: parsed.certificate!,
|
|
592
|
+
chain: parsed.chain!,
|
|
593
|
+
namespace: parsed.namespace!,
|
|
594
|
+
inviterDeviceId: parsed.inviterDeviceId!,
|
|
595
|
+
inviterMultiaddrs: parsed.inviterMultiaddrs ?? [],
|
|
596
|
+
...(parsed.securityApproval !== undefined ? { securityApproval: parsed.securityApproval } : {}),
|
|
597
|
+
});
|
|
598
|
+
} catch (error) {
|
|
599
|
+
reject(new Error(`join 响应无法解析:${(error as Error).message}`));
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
},
|
|
603
|
+
);
|
|
604
|
+
req.setTimeout(input.timeoutMs ?? 15_000, () => req.destroy(new Error('join 请求超时')));
|
|
605
|
+
req.on('error', reject);
|
|
606
|
+
req.write(payload);
|
|
607
|
+
req.end();
|
|
608
|
+
});
|
|
609
|
+
}
|