@m2canvas/connect 0.12.0 → 0.14.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/README.md +10 -27
- package/dist/bridge/args.js +30 -36
- package/dist/bridge/call-dispatch.js +36 -16
- package/dist/bridge/install-layout.js +2 -1
- package/dist/bridge/main.js +80 -92
- package/dist/bridge/processes.js +37 -6
- package/dist/bridge/protocol.js +11 -3
- package/dist/bridge/security.js +17 -27
- package/dist/bridge/server.js +49 -70
- package/dist/bridge/service.js +30 -8
- package/dist/bridge/session-token-io.js +41 -0
- package/dist/bridge/session-token.js +332 -0
- package/dist/bridge/ticket-socket.js +104 -0
- package/dist/bridge/ticket.js +70 -0
- package/dist/bridge/uninstall-call.js +2 -2
- package/dist/bridge/uninstall.js +1 -1
- package/dist/bridge/update.js +21 -1
- package/dist/cli.js +2 -1
- package/install/uninstall.sh +3 -2
- package/package.json +1 -1
- package/dist/bridge/handoff.js +0 -213
- package/dist/bridge/pair-call.js +0 -31
- package/dist/bridge/pairing.js +0 -349
package/dist/bridge/handoff.js
DELETED
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 把命令承诺交给正在运行的后台 bridge(change acp-bridge-one-time-install design D27、R11、review r3 R3-S1;`connect/local-bridge` 契约 2 delta)。
|
|
3
|
-
*
|
|
4
|
-
* 后台模式的 bridge 不出配对码,新页面只能靠启动命令里的承诺配对;原来承诺只能在启动时经 `--pair` 带进去,
|
|
5
|
-
* 换一个浏览器就得 `--replace` 重启 bridge,打断其他页面上正在跑的 agent。现在:
|
|
6
|
-
* - 后台模式的 bridge 监听 `~/.m2canvas/bridge-<port>.sock`;
|
|
7
|
-
* - 启动器拉起的新 `bridge --background --pair <c>` 撞上认定的 M2 bridge 时,把 c 写进 `~/.m2canvas/handoff-<编号>.pending`,
|
|
8
|
-
* 经 socket 只发这个编号;bridge 读出承诺、删掉文件、挂上,再回复。交出方随后以退出码 3 退出。
|
|
9
|
-
*
|
|
10
|
-
* 跨版本接口(启动器只随重装更新,bridge 会自动更新,交出方与接收方可能是不同版本;`connect/local-bridge` 契约 14 delta):
|
|
11
|
-
* - 请求一行(不含换行最多 256 字节):`{"type":"arm","nonce":"<32 位小写十六进制>"}\n`;承诺在 `<m2 目录>/handoff-<nonce>.pending`(普通文件、属于本账号);
|
|
12
|
-
* - 回复一行:`{"ok":true,"until":<毫秒时间戳>}` 或 `{"ok":false,"error":"…"}`,回复后断开。
|
|
13
|
-
* - 只读状态查询(change desktop-bridge-launcher D15 起;具体版本见 connect 的发布记录):请求 `{"type":"status","commitment":"<22 位 base64url>"}\n`,
|
|
14
|
-
* 回复 `{"ok":true,"state":"waiting","until":…}` / `{"ok":true,"state":"paired","origin":"…","at":…}` / `{"ok":true,"state":"unknown"}`,
|
|
15
|
-
* 可带 `"rejected":{"origin":"…","at":…}`(最近一次因 Origin 不在白名单被拒的连接)。旧版本对它回 `bad_request`,
|
|
16
|
-
* M2 Mac 客户端据此判定「不支持」。
|
|
17
|
-
* 只能加字段;要改语义就换请求的 type,并让接收方继续认旧的。
|
|
18
|
-
*
|
|
19
|
-
* 维护约束:
|
|
20
|
-
* - 门槛是「能写 `~/.m2canvas`」,而不只是「能连 socket」(R3-S1):agent 沙箱里的命令可能被放开了本机 socket,
|
|
21
|
-
* 但默认只能写工作目录与临时目录;只靠 socket 的话,它就能配上、再经 bridge 驱动沙箱外的 agent。能写这个目录的进程
|
|
22
|
-
* 本来就能配对(bridge 每次校验 token 都重读 `bridge-pairings.json`,往里加一条记录即可;在沙箱里、做不到 `--replace` 也一样),
|
|
23
|
-
* 所以不算新的攻击者类别。这条论证依赖「每次重读配对文件」:改成只在启动时读或缓存时,要重新评估这里的门槛。
|
|
24
|
-
* 不要退回「socket 上直接带承诺」,也不要放宽文件名(只认 32 位十六进制)。
|
|
25
|
-
* - `status` 只读、承诺随请求一起发,不要求能写 `~/.m2canvas`:它只透露「这个承诺配没配上、配给了哪个 origin、最近被拒的 origin」,
|
|
26
|
-
* 这些本来就写在 `~/.m2canvas/logs/bridge.log` 里,能连这个 socket 的本账号进程也能读到;不回 S、不回 token、不改任何状态,
|
|
27
|
-
* 也不能用来猜承诺(128 bit)。回复里要加别的东西时,先重做这条论证(design D15)。
|
|
28
|
-
* - socket 上不收 S、不回 token:拿 token 仍要页面在 hello 里出示 S,Origin 白名单照旧。
|
|
29
|
-
* - 只有同一个账号能连:`~/.m2canvas` 0700、socket 0600。不要把 socket 放到别的目录,也不要放宽权限。
|
|
30
|
-
* - 每个连接只处理一行、有长度上限、有从连上起算的绝对超时:这是本机进程能碰到的入口,不能让它挂住 bridge 或吃内存。
|
|
31
|
-
* - socket 路径受 macOS 的 sun_path 限制(104 字节含结尾 0):超出就不监听,只记日志(交接不可用,页面用 `--replace` 兜底)。
|
|
32
|
-
*/
|
|
33
|
-
import { randomBytes } from 'node:crypto';
|
|
34
|
-
import { chmodSync, lstatSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
35
|
-
import { createConnection, createServer } from 'node:net';
|
|
36
|
-
import { basename, dirname, join } from 'node:path';
|
|
37
|
-
import { isPairCommitment } from './pairing.js';
|
|
38
|
-
/** sockaddr_un.sun_path 是 104 字节,含结尾的 0 */
|
|
39
|
-
export const HANDOFF_MAX_PATH_BYTES = 103;
|
|
40
|
-
export const HANDOFF_TIMEOUT_MS = 3_000;
|
|
41
|
-
/** `{"type":"arm","nonce":"<32 字符>"}` 不到 60 字节,留足余量 */
|
|
42
|
-
const MAX_LINE_BYTES = 256;
|
|
43
|
-
const NONCE_RE = /^[0-9a-f]{32}$/;
|
|
44
|
-
/** 承诺文件只放 22 个字符(可能带换行) */
|
|
45
|
-
const MAX_PENDING_BYTES = 64;
|
|
46
|
-
export function handoffSocketPath(dir, port) {
|
|
47
|
-
return join(dir, `bridge-${port}.sock`);
|
|
48
|
-
}
|
|
49
|
-
function pendingPath(dir, nonce) {
|
|
50
|
-
return join(dir, `handoff-${nonce}.pending`);
|
|
51
|
-
}
|
|
52
|
-
/** 删不掉(例如被换成了目录)只当没删:这里的异常没人接,抛出会让 bridge 退出(契约 5,r3 复核 R3C-N1) */
|
|
53
|
-
function removeQuietly(path) {
|
|
54
|
-
try {
|
|
55
|
-
rmSync(path, { force: true });
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
// 留下的残留由卸载脚本或下一次同名交接处理
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
function errorText(error) {
|
|
62
|
-
return error instanceof Error ? error.message : String(error);
|
|
63
|
-
}
|
|
64
|
-
function parseRequest(line) {
|
|
65
|
-
try {
|
|
66
|
-
const request = JSON.parse(line);
|
|
67
|
-
if (typeof request !== 'object' || request === null)
|
|
68
|
-
return null;
|
|
69
|
-
const { type, nonce, commitment } = request;
|
|
70
|
-
if (type === 'arm' && typeof nonce === 'string' && NONCE_RE.test(nonce))
|
|
71
|
-
return { type, nonce };
|
|
72
|
-
if (type === 'status' && typeof commitment === 'string' && isPairCommitment(commitment))
|
|
73
|
-
return { type, commitment };
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
/** 读出交出方写的承诺并删掉文件;不是本账号的普通文件、内容不对都当没有(不跟随符号链接) */
|
|
81
|
-
function takePendingCommitment(dir, nonce) {
|
|
82
|
-
const path = pendingPath(dir, nonce);
|
|
83
|
-
// 编号的正则已经挡住了路径分隔符;这里再按路径结构核一次,正则被改松时也不会读到目录外(r3 复核 R3C-S1)
|
|
84
|
-
if (dirname(path) !== join(dir) || basename(path) !== `handoff-${nonce}.pending`)
|
|
85
|
-
return null;
|
|
86
|
-
try {
|
|
87
|
-
const info = lstatSync(path);
|
|
88
|
-
const uid = typeof process.getuid === 'function' ? process.getuid() : info.uid;
|
|
89
|
-
if (!info.isFile() || info.uid !== uid || info.size > MAX_PENDING_BYTES)
|
|
90
|
-
return null;
|
|
91
|
-
const commitment = readFileSync(path, 'utf8').trim();
|
|
92
|
-
return isPairCommitment(commitment) ? commitment : null;
|
|
93
|
-
}
|
|
94
|
-
catch {
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
finally {
|
|
98
|
-
removeQuietly(path);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* 后台模式的 bridge 在拿到端口之后调用:同名的 socket 残留(上次没清干净)先删掉 —— 端口在自己手里,这个端口的 socket 不可能属于别人。
|
|
103
|
-
* 返回 null = 没监听(路径太长或建不起来),只记日志,不影响 bridge 本身。
|
|
104
|
-
*/
|
|
105
|
-
export async function listenForHandoff(options) {
|
|
106
|
-
const { dir, port, arm, status, log } = options;
|
|
107
|
-
const path = handoffSocketPath(dir, port);
|
|
108
|
-
if (Buffer.byteLength(path) > HANDOFF_MAX_PATH_BYTES) {
|
|
109
|
-
log(`Pairing handoff is unavailable: ${path} is too long for a local socket. A new browser has to use the reconnect command instead.`);
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
const server = createServer((socket) => {
|
|
113
|
-
const deadline = setTimeout(() => socket.destroy(), HANDOFF_TIMEOUT_MS);
|
|
114
|
-
socket.on('close', () => clearTimeout(deadline));
|
|
115
|
-
socket.on('error', () => undefined);
|
|
116
|
-
let buffer = '';
|
|
117
|
-
let done = false;
|
|
118
|
-
socket.on('data', (chunk) => {
|
|
119
|
-
// 只处理第一行:之后再发什么都丢掉(不暂停读取,对方断开时这边也跟着关)
|
|
120
|
-
if (done)
|
|
121
|
-
return;
|
|
122
|
-
buffer += chunk.toString('utf8');
|
|
123
|
-
const end = buffer.indexOf('\n');
|
|
124
|
-
if (Buffer.byteLength(end < 0 ? buffer : buffer.slice(0, end)) > MAX_LINE_BYTES) {
|
|
125
|
-
socket.destroy();
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (end < 0)
|
|
129
|
-
return;
|
|
130
|
-
done = true;
|
|
131
|
-
const request = parseRequest(buffer.slice(0, end));
|
|
132
|
-
if (request?.type === 'status' && status) {
|
|
133
|
-
socket.end(`${JSON.stringify({ ok: true, ...status(request.commitment) })}\n`);
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
const nonce = request?.type === 'arm' ? request.nonce : null;
|
|
137
|
-
const commitment = nonce ? takePendingCommitment(dir, nonce) : null;
|
|
138
|
-
if (!commitment) {
|
|
139
|
-
socket.end(`${JSON.stringify({ ok: false, error: nonce ? 'not_found' : 'bad_request' })}\n`);
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
const until = arm(commitment);
|
|
143
|
-
log('Armed a pairing value received over the local handoff socket; the page that holds it can pair now.');
|
|
144
|
-
socket.end(`${JSON.stringify({ ok: true, until })}\n`);
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
try {
|
|
148
|
-
rmSync(path, { force: true });
|
|
149
|
-
await new Promise((resolve, reject) => {
|
|
150
|
-
server.once('error', reject);
|
|
151
|
-
server.listen(path, () => resolve());
|
|
152
|
-
});
|
|
153
|
-
chmodSync(path, 0o600);
|
|
154
|
-
}
|
|
155
|
-
catch (error) {
|
|
156
|
-
server.close();
|
|
157
|
-
log(`Pairing handoff is unavailable (${errorText(error)}). A new browser has to use the reconnect command instead.`);
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
return {
|
|
161
|
-
close() {
|
|
162
|
-
server.close();
|
|
163
|
-
rmSync(path, { force: true });
|
|
164
|
-
},
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* 新的 `bridge --background --pair <c>` 撞上认定的 M2 bridge 时调用:把承诺写进一次性文件,经 socket 发编号。
|
|
169
|
-
* true = 对方确认挂上了;没人在听、对方拒绝、超时都是 false(调用方照常以退出码 3 退出,只是新页面配不上)。文件无论如何都删掉。
|
|
170
|
-
*/
|
|
171
|
-
export function handOffCommitment(options) {
|
|
172
|
-
const { dir, port, commitment, timeoutMs = HANDOFF_TIMEOUT_MS } = options;
|
|
173
|
-
if (!isPairCommitment(commitment))
|
|
174
|
-
return Promise.resolve(false);
|
|
175
|
-
const nonce = randomBytes(16).toString('hex');
|
|
176
|
-
const file = pendingPath(dir, nonce);
|
|
177
|
-
try {
|
|
178
|
-
writeFileSync(file, commitment, { mode: 0o600, flag: 'wx' });
|
|
179
|
-
}
|
|
180
|
-
catch {
|
|
181
|
-
return Promise.resolve(false);
|
|
182
|
-
}
|
|
183
|
-
return new Promise((resolve) => {
|
|
184
|
-
let reply = '';
|
|
185
|
-
let settled = false;
|
|
186
|
-
const socket = createConnection(handoffSocketPath(dir, port));
|
|
187
|
-
const finish = (ok) => {
|
|
188
|
-
if (settled)
|
|
189
|
-
return;
|
|
190
|
-
settled = true;
|
|
191
|
-
clearTimeout(timer);
|
|
192
|
-
socket.destroy();
|
|
193
|
-
resolve(ok);
|
|
194
|
-
};
|
|
195
|
-
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
196
|
-
socket.on('error', () => finish(false));
|
|
197
|
-
socket.on('data', (chunk) => {
|
|
198
|
-
reply += chunk.toString('utf8');
|
|
199
|
-
if (reply.length > MAX_LINE_BYTES)
|
|
200
|
-
finish(false);
|
|
201
|
-
});
|
|
202
|
-
socket.on('end', () => {
|
|
203
|
-
try {
|
|
204
|
-
const answer = JSON.parse(reply.trim());
|
|
205
|
-
finish(typeof answer === 'object' && answer !== null && answer.ok === true);
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
finish(false);
|
|
209
|
-
}
|
|
210
|
-
});
|
|
211
|
-
socket.on('connect', () => socket.write(`${JSON.stringify({ type: 'arm', nonce })}\n`));
|
|
212
|
-
}).finally(() => removeQuietly(file));
|
|
213
|
-
}
|
package/dist/bridge/pair-call.js
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* bridge 方法 `pair`(页面输码换 token):校验参数、调用 PairingManager.pair、把失败映射成页面看得懂的错误码。
|
|
3
|
-
* 只负责「这次输码对不对」;配对成功后把连接标为已配对、记日志、回 cwd 由 server.ts 负责(连接状态只在那里改)。
|
|
4
|
-
*
|
|
5
|
-
* 错误码的语义是页面协议的一部分(`protocol.ts` 的 BridgeErrorCode,`connect/local-bridge` 契约 9):
|
|
6
|
-
* - pairing_invalid:错码,带剩余次数;
|
|
7
|
-
* - pairing_code_rotated:码过期或错太多次被换掉,新码已打印在终端;
|
|
8
|
-
* - pairing_locked:累计错码太多,只有重启 bridge 才解除;
|
|
9
|
-
* - pairing_code_unavailable:后台模式(`bridge --background`)根本不出码,页面要改走「重新连接」(change acp-bridge-one-time-install D17)。
|
|
10
|
-
* 不要把后两种并进 pairing_code_rotated:页面据此决定是让用户看终端,还是让 agent 重新拉起 bridge。
|
|
11
|
-
*/
|
|
12
|
-
import { BridgeFailure } from './errors.js';
|
|
13
|
-
/** 成功时返回新签发的 token;失败抛 BridgeFailure */
|
|
14
|
-
export function handlePairCall(pairing, origin, params) {
|
|
15
|
-
const code = params.code;
|
|
16
|
-
if (typeof code !== 'string')
|
|
17
|
-
throw new BridgeFailure('bad_message', 'pair needs a string "code"');
|
|
18
|
-
const result = pairing.pair(code, origin);
|
|
19
|
-
if (result.ok)
|
|
20
|
-
return result.token;
|
|
21
|
-
switch (result.code) {
|
|
22
|
-
case 'pairing_invalid':
|
|
23
|
-
throw new BridgeFailure('pairing_invalid', 'wrong pairing code', { attemptsLeft: result.attemptsLeft });
|
|
24
|
-
case 'pairing_locked':
|
|
25
|
-
throw new BridgeFailure('pairing_locked', 'too many wrong pairing codes; new pages cannot pair until the bridge is restarted');
|
|
26
|
-
case 'pairing_code_unavailable':
|
|
27
|
-
throw new BridgeFailure('pairing_code_unavailable', 'this bridge was started in the background and shows no pairing code; reconnect from M2 to pair');
|
|
28
|
-
case 'pairing_code_rotated':
|
|
29
|
-
throw new BridgeFailure('pairing_code_rotated', 'pairing code expired or used up; a new one is shown in the terminal');
|
|
30
|
-
}
|
|
31
|
-
}
|
package/dist/bridge/pairing.js
DELETED
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 配对:安全三条里的第三条。Origin 白名单挡住了其他网站,但挡不住两类来源 —— `--dev` 时本机其他跑在开发端口上的页面
|
|
3
|
-
* 和本机其他进程(可以伪造 Origin)。它们都拿不到只出现在终端里的配对码。
|
|
4
|
-
* 配对 = 授权该页面以用户的身份驱动本机 agent(包括执行命令),所以码和 token 都按这个级别保管。
|
|
5
|
-
*
|
|
6
|
-
* 两条途径,都要证明「终端用户同意了这个页面」(change acp-bridge-command-pairing):
|
|
7
|
-
* - 输码:终端打印一次性配对码 → 用户在页面输入 → 换取长期 token(页面存 localStorage)→ 之后每次 hello 带上。
|
|
8
|
-
* - 命令承诺:页面生成密钥 S,启动命令只带承诺 c = base64url(sha256(S) 的前 16 字节)(`bridge --pair c`);
|
|
9
|
-
* 页面在 hello 里出示 S,一致就换取同样的 token。「在自己终端里运行了这个页面给的命令」与「抄了终端里的码」证明的是同一件事。
|
|
10
|
-
* 命令会进 `ps` 与 shell 历史,所以命令里只能有承诺、不能有 S;承诺只在内存里、自挂上起 CODE_TTL_MS 有效、成功一次即作废。
|
|
11
|
-
*
|
|
12
|
-
* 维护约束:
|
|
13
|
-
* - 本地只存 token 的 sha256,不存明文;文件 0600、目录 0700。
|
|
14
|
-
* - token 绑定配对时的 Origin:开发域名配出的 token 不能拿到正式域名用。
|
|
15
|
-
* - 配对码约 40 bit,每个码最多错 MAX_CODE_FAILURES 次、CODE_TTL_MS 后过期,二者都会轮换并在终端重新打印;
|
|
16
|
-
* 自上次成功配对以来累计错 MAX_TOTAL_FAILURES 次就锁定(不论中间换过几个码、码是不是过期换的),只有重启 bridge 才解除
|
|
17
|
-
* (锁只在内存里)。锁定不影响已配对的页面。
|
|
18
|
-
* 不要为了「方便」放宽次数、延长有效期或加一个页面能触发的解锁:码是唯一挡住本机恶意页面的东西。
|
|
19
|
-
* 也不要改成「在终端按回车解锁」:那需要读终端,而 bridge 放到后台(`… bridge &`)时读终端会被系统挂起(review r2 实测)。
|
|
20
|
-
* - 比较用常量时间(timingSafeEqual),不要改回 ===。
|
|
21
|
-
* - 后台模式(`issueCodes: false`,`bridge --background`,change acp-bridge-one-time-install D17)不出码:没有终端,stdout 写的是日志文件,
|
|
22
|
-
* 出码就等于把码写盘。这时 `issueCode` / `ensureCode` 什么都不做,`pair` 一律 pairing_code_unavailable,配对只剩命令承诺与已有 token。
|
|
23
|
-
* 不要给后台模式另开出码通道(文件、系统对话框):「把码写进文件」已被否决过(`connect/local-bridge` 契约 2 / 13)。
|
|
24
|
-
*/
|
|
25
|
-
import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
26
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
-
import { dirname } from 'node:path';
|
|
28
|
-
export const CODE_TTL_MS = 10 * 60 * 1000;
|
|
29
|
-
export const MAX_CODE_FAILURES = 5;
|
|
30
|
-
export const TOKEN_IDLE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
31
|
-
export const TOKEN_IDLE_DAYS = 30;
|
|
32
|
-
export const MAX_PAIRINGS = 20;
|
|
33
|
-
/**
|
|
34
|
-
* 自上次成功配对以来累计错这么多次就锁定,重启 bridge 才解除。按累计而不是「因错码轮换的次数」计:
|
|
35
|
-
* 码过期换新时不清零 —— 否则每个码错 4 次再等它过期就能无限猜下去(review r3 S4)。
|
|
36
|
-
*/
|
|
37
|
-
export const MAX_TOTAL_FAILURES = 15;
|
|
38
|
-
/** 同一 Origin、同一个码在终端的重复提示间隔 */
|
|
39
|
-
const PROMPT_DEDUPE_MS = 60_000;
|
|
40
|
-
/** Crockford base32:去掉易混的 I、L、O、U */
|
|
41
|
-
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
42
|
-
const CODE_LENGTH = 8;
|
|
43
|
-
/** 页面密钥 S:32 字节随机数的 base64url(无填充) */
|
|
44
|
-
const PAIR_SECRET_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
45
|
-
/** 承诺:sha256(S) 前 16 字节的 base64url(无填充) */
|
|
46
|
-
const PAIR_COMMITMENT_RE = /^[A-Za-z0-9_-]{22}$/;
|
|
47
|
-
const COMMITMENT_BYTES = 16;
|
|
48
|
-
export function isPairCommitment(value) {
|
|
49
|
-
return PAIR_COMMITMENT_RE.test(value);
|
|
50
|
-
}
|
|
51
|
-
/** 承诺 = base64url(sha256(S 的 UTF-8) 的前 16 字节)。前端 `bridge-pair-secret.ts` 用 WebCrypto 算同一个值,改这里要同批改那边。 */
|
|
52
|
-
export function pairCommitmentOf(secret) {
|
|
53
|
-
return sha256(secret).subarray(0, COMMITMENT_BYTES).toString('base64url');
|
|
54
|
-
}
|
|
55
|
-
/** 用户输入归一:大小写、空格、连字符;按 Crockford 规则把 O 视同 0、I / L 视同 1 */
|
|
56
|
-
export function normalizePairingCode(input) {
|
|
57
|
-
return input
|
|
58
|
-
.toUpperCase()
|
|
59
|
-
.replace(/[\s-]/g, '')
|
|
60
|
-
.replace(/O/g, '0')
|
|
61
|
-
.replace(/[IL]/g, '1');
|
|
62
|
-
}
|
|
63
|
-
function formatCode(code) {
|
|
64
|
-
return `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
65
|
-
}
|
|
66
|
-
function sha256(value) {
|
|
67
|
-
return createHash('sha256').update(value).digest();
|
|
68
|
-
}
|
|
69
|
-
function sameSecret(a, b) {
|
|
70
|
-
const left = sha256(a);
|
|
71
|
-
const right = sha256(b);
|
|
72
|
-
return timingSafeEqual(left, right);
|
|
73
|
-
}
|
|
74
|
-
function errorText(error) {
|
|
75
|
-
return error instanceof Error ? error.message : String(error);
|
|
76
|
-
}
|
|
77
|
-
export class PairingManager {
|
|
78
|
-
active = null;
|
|
79
|
-
/** `--pair` 挂上的承诺(只在内存里:bridge 重启即失效,重新运行同一条命令会重新挂上) */
|
|
80
|
-
commitment = null;
|
|
81
|
-
/** 已用掉的承诺:同一个密钥再出现时告警(只在内存里,只记最近一个) */
|
|
82
|
-
consumed = null;
|
|
83
|
-
/** 自上次成功配对以来累计的错码次数(跨码、跨过期);配对成功时清零 */
|
|
84
|
-
failuresSinceSuccess = 0;
|
|
85
|
-
locked = false;
|
|
86
|
-
/** Origin → 上次在终端提示它的时间与当时的码,用于去重 */
|
|
87
|
-
lastPrompt = new Map();
|
|
88
|
-
store;
|
|
89
|
-
now;
|
|
90
|
-
issueCodes;
|
|
91
|
-
onCodeIssued;
|
|
92
|
-
onLocked;
|
|
93
|
-
onProblem;
|
|
94
|
-
onCommitmentReused;
|
|
95
|
-
constructor(options) {
|
|
96
|
-
this.store = options.store;
|
|
97
|
-
this.now = options.now ?? Date.now;
|
|
98
|
-
this.issueCodes = options.issueCodes ?? true;
|
|
99
|
-
this.onCodeIssued = options.onCodeIssued;
|
|
100
|
-
this.onLocked = options.onLocked ?? (() => undefined);
|
|
101
|
-
this.onProblem = options.onProblem ?? (() => undefined);
|
|
102
|
-
this.onCommitmentReused = options.onCommitmentReused ?? (() => undefined);
|
|
103
|
-
}
|
|
104
|
-
/** 生成新码(旧码立即失效)并通知调用方打印,返回显示形式;后台模式不出码,返回 null */
|
|
105
|
-
issueCode(reason, origin = null) {
|
|
106
|
-
if (!this.issueCodes)
|
|
107
|
-
return null;
|
|
108
|
-
let code = '';
|
|
109
|
-
for (let i = 0; i < CODE_LENGTH; i++)
|
|
110
|
-
code += ALPHABET[randomInt(ALPHABET.length)];
|
|
111
|
-
this.active = { code, issuedAt: this.now(), failures: 0 };
|
|
112
|
-
const display = formatCode(code);
|
|
113
|
-
if (origin)
|
|
114
|
-
this.lastPrompt.set(origin, { at: this.now(), code });
|
|
115
|
-
this.onCodeIssued({ display, reason, origin, expiresAt: this.active.issuedAt + CODE_TTL_MS });
|
|
116
|
-
return display;
|
|
117
|
-
}
|
|
118
|
-
hasValidCode() {
|
|
119
|
-
return this.active !== null && this.now() - this.active.issuedAt <= CODE_TTL_MS;
|
|
120
|
-
}
|
|
121
|
-
isLocked() {
|
|
122
|
-
return this.locked;
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* 未配对页面连上时调用。没有有效码就出新码;有有效码也把它连同发起方 Origin 再提示一次 ——
|
|
126
|
-
* 最常见的首次流程是「启动 bridge 后马上打开页面」,终端里只有一个不带 Origin 的启动码,
|
|
127
|
-
* 不再提示的话用户看不到是哪个页面在请求配对。同一 Origin、同一个码一分钟内只提示一次(页面重连不刷屏)。
|
|
128
|
-
* 锁定期间、后台模式下什么都不做。
|
|
129
|
-
*/
|
|
130
|
-
ensureCode(origin) {
|
|
131
|
-
if (this.locked || !this.issueCodes)
|
|
132
|
-
return;
|
|
133
|
-
if (!this.hasValidCode() || !this.active) {
|
|
134
|
-
this.issueCode('requested', origin);
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
const last = this.lastPrompt.get(origin);
|
|
138
|
-
if (last && last.code === this.active.code && this.now() - last.at < PROMPT_DEDUPE_MS)
|
|
139
|
-
return;
|
|
140
|
-
this.lastPrompt.set(origin, { at: this.now(), code: this.active.code });
|
|
141
|
-
this.onCodeIssued({ display: formatCode(this.active.code), reason: 'requested', origin, expiresAt: this.active.issuedAt + CODE_TTL_MS });
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* 配对。保存 token 失败时抛出(调用方回给页面 internal),此时码不被消耗、可以重试。
|
|
145
|
-
*
|
|
146
|
-
* 暴力尝试的上限:每个码最多错 MAX_CODE_FAILURES 次(然后换码),自上次成功以来累计错 MAX_TOTAL_FAILURES 次锁定,
|
|
147
|
-
* 锁定期间不再出码、不再比对,只有重启 bridge 才解除。没有累计上限,换码本身就是无限次尝试
|
|
148
|
-
* (review r1 实测 1.6 秒 20000 次),还会让陌生页面把用户的配对码一直刷掉。
|
|
149
|
-
*/
|
|
150
|
-
pair(input, origin) {
|
|
151
|
-
// 后台模式没有码可比:在锁定检查之前返回,也不计入错码次数(页面并没有猜码,只是不知道这个 bridge 不出码)
|
|
152
|
-
if (!this.issueCodes)
|
|
153
|
-
return { ok: false, code: 'pairing_code_unavailable' };
|
|
154
|
-
if (this.locked)
|
|
155
|
-
return { ok: false, code: 'pairing_locked' };
|
|
156
|
-
if (!this.active) {
|
|
157
|
-
// 码已被消费:请用户看终端里的新码
|
|
158
|
-
this.issueCode('requested', origin);
|
|
159
|
-
return { ok: false, code: 'pairing_code_rotated' };
|
|
160
|
-
}
|
|
161
|
-
if (!this.hasValidCode()) {
|
|
162
|
-
this.issueCode('expired', origin);
|
|
163
|
-
return { ok: false, code: 'pairing_code_rotated' };
|
|
164
|
-
}
|
|
165
|
-
const active = this.active;
|
|
166
|
-
if (sameSecret(normalizePairingCode(input), active.code)) {
|
|
167
|
-
const token = this.createToken(origin);
|
|
168
|
-
this.active = null;
|
|
169
|
-
this.failuresSinceSuccess = 0;
|
|
170
|
-
return { ok: true, token };
|
|
171
|
-
}
|
|
172
|
-
active.failures += 1;
|
|
173
|
-
this.failuresSinceSuccess += 1;
|
|
174
|
-
if (this.failuresSinceSuccess >= MAX_TOTAL_FAILURES) {
|
|
175
|
-
this.locked = true;
|
|
176
|
-
this.active = null;
|
|
177
|
-
this.onLocked(origin);
|
|
178
|
-
return { ok: false, code: 'pairing_locked' };
|
|
179
|
-
}
|
|
180
|
-
if (active.failures < MAX_CODE_FAILURES) {
|
|
181
|
-
const attemptsLeft = Math.min(MAX_CODE_FAILURES - active.failures, MAX_TOTAL_FAILURES - this.failuresSinceSuccess);
|
|
182
|
-
return { ok: false, code: 'pairing_invalid', attemptsLeft };
|
|
183
|
-
}
|
|
184
|
-
this.issueCode('too_many_failures', origin);
|
|
185
|
-
return { ok: false, code: 'pairing_code_rotated' };
|
|
186
|
-
}
|
|
187
|
-
/** 挂上启动命令里的承诺,返回它的过期时刻。格式不对时抛出:由 `--pair` 的参数解析先挡掉。 */
|
|
188
|
-
armCommitment(commitment) {
|
|
189
|
-
if (!isPairCommitment(commitment))
|
|
190
|
-
throw new Error('invalid pairing commitment');
|
|
191
|
-
const armedAt = this.now();
|
|
192
|
-
this.commitment = { value: Buffer.from(commitment, 'base64url'), armedAt };
|
|
193
|
-
return armedAt + CODE_TTL_MS;
|
|
194
|
-
}
|
|
195
|
-
/**
|
|
196
|
-
* 承诺的状态(change desktop-bridge-launcher D15):M2 Mac 客户端运行启动器交上承诺之后,用它查页面有没有真的配上。
|
|
197
|
-
* 只读,不改变任何状态;比较用常量时间。只看两个槽:挂着的(10 分钟内)与最近一次用掉的 —— 被更新的承诺替换、过期、
|
|
198
|
-
* bridge 重启过的都是 unknown,调用方按「还没确认」处理。
|
|
199
|
-
*/
|
|
200
|
-
commitmentStatus(commitment) {
|
|
201
|
-
if (!isPairCommitment(commitment))
|
|
202
|
-
return { state: 'unknown' };
|
|
203
|
-
const value = Buffer.from(commitment, 'base64url');
|
|
204
|
-
const armed = this.commitment;
|
|
205
|
-
if (armed && armed.value.length === value.length && timingSafeEqual(armed.value, value) && this.now() - armed.armedAt <= CODE_TTL_MS) {
|
|
206
|
-
return { state: 'waiting', until: armed.armedAt + CODE_TTL_MS };
|
|
207
|
-
}
|
|
208
|
-
const consumed = this.consumed;
|
|
209
|
-
if (consumed && consumed.value.length === value.length && timingSafeEqual(consumed.value, value)) {
|
|
210
|
-
return { state: 'paired', origin: consumed.origin, at: consumed.usedAt };
|
|
211
|
-
}
|
|
212
|
-
return { state: 'unknown' };
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* 页面在 hello 里出示 S。对不上不计入错码次数:S 有 32 字节、承诺有 128 bit,不存在可猜性,
|
|
216
|
-
* 计入只会让多标签页、旧 S 这类无害的不匹配把用户锁住。锁定期间一律不成立 —— 「锁定只能重启解除」不设例外。
|
|
217
|
-
* 签发 token 写盘失败时不抛出(hello 里抛出会以 1011 关闭,等待中的页面会反复撞上)、不消费承诺,交给调用方按未配对处理。
|
|
218
|
-
*/
|
|
219
|
-
pairWithSecret(secret, origin) {
|
|
220
|
-
const commitment = this.commitment;
|
|
221
|
-
// 重现检查在锁定检查之前:否则抢先配对的一方再用错码把 bridge 锁住,就能压掉这次告警(review r2 S1)
|
|
222
|
-
if (!commitment)
|
|
223
|
-
return this.checkReused(secret, origin);
|
|
224
|
-
if (this.locked)
|
|
225
|
-
return { ok: false, reason: 'locked' };
|
|
226
|
-
if (this.now() - commitment.armedAt > CODE_TTL_MS) {
|
|
227
|
-
this.commitment = null;
|
|
228
|
-
return { ok: false, reason: 'expired' };
|
|
229
|
-
}
|
|
230
|
-
if (!PAIR_SECRET_RE.test(secret))
|
|
231
|
-
return { ok: false, reason: 'malformed' };
|
|
232
|
-
if (!timingSafeEqual(sha256(secret).subarray(0, COMMITMENT_BYTES), commitment.value))
|
|
233
|
-
return { ok: false, reason: 'mismatch' };
|
|
234
|
-
let token;
|
|
235
|
-
try {
|
|
236
|
-
token = this.createToken(origin);
|
|
237
|
-
}
|
|
238
|
-
catch (error) {
|
|
239
|
-
this.onProblem(`Could not save the pairing (${errorText(error)}). This page will be asked for a pairing code instead.`);
|
|
240
|
-
return { ok: false, reason: 'store_failed' };
|
|
241
|
-
}
|
|
242
|
-
this.commitment = null;
|
|
243
|
-
this.consumed = { value: commitment.value, origin, usedAt: this.now(), warned: false };
|
|
244
|
-
this.failuresSinceSuccess = 0;
|
|
245
|
-
return { ok: true, token };
|
|
246
|
-
}
|
|
247
|
-
checkReused(secret, origin) {
|
|
248
|
-
const consumed = this.consumed;
|
|
249
|
-
if (!consumed || !PAIR_SECRET_RE.test(secret))
|
|
250
|
-
return { ok: false, reason: 'no_commitment' };
|
|
251
|
-
if (!timingSafeEqual(sha256(secret).subarray(0, COMMITMENT_BYTES), consumed.value))
|
|
252
|
-
return { ok: false, reason: 'no_commitment' };
|
|
253
|
-
if (!consumed.warned) {
|
|
254
|
-
consumed.warned = true;
|
|
255
|
-
this.onCommitmentReused({ origin, firstOrigin: consumed.origin, usedAt: consumed.usedAt });
|
|
256
|
-
}
|
|
257
|
-
return { ok: false, reason: 'reused' };
|
|
258
|
-
}
|
|
259
|
-
createToken(origin) {
|
|
260
|
-
const token = randomBytes(32).toString('base64url');
|
|
261
|
-
const nowIso = new Date(this.now()).toISOString();
|
|
262
|
-
const records = this.liveRecords();
|
|
263
|
-
records.push({ id: randomUUID(), tokenHash: sha256(token).toString('hex'), origin, createdAt: nowIso, lastUsedAt: nowIso });
|
|
264
|
-
// 超出上限时淘汰最久未用的
|
|
265
|
-
records.sort((a, b) => Date.parse(b.lastUsedAt) - Date.parse(a.lastUsedAt));
|
|
266
|
-
// 这里的写失败必须抛出:token 没存下来,页面拿到也用不了
|
|
267
|
-
this.store.save(records.slice(0, MAX_PAIRINGS));
|
|
268
|
-
return token;
|
|
269
|
-
}
|
|
270
|
-
/**
|
|
271
|
-
* 读出未过期的记录;顺带把过期的从存储里删掉(删不掉只告警:不影响本次判定)。
|
|
272
|
-
* 每次都从存储读(不缓存):交接 socket 的门槛论证依赖这一点(`handoff.ts` 文件头),改成缓存时要一起重新评估。
|
|
273
|
-
*/
|
|
274
|
-
liveRecords() {
|
|
275
|
-
const all = this.store.load();
|
|
276
|
-
const live = all.filter((record) => this.now() - Date.parse(record.lastUsedAt) <= TOKEN_IDLE_MS);
|
|
277
|
-
if (live.length !== all.length)
|
|
278
|
-
this.trySave(live);
|
|
279
|
-
return live;
|
|
280
|
-
}
|
|
281
|
-
trySave(records) {
|
|
282
|
-
try {
|
|
283
|
-
this.store.save(records);
|
|
284
|
-
}
|
|
285
|
-
catch (error) {
|
|
286
|
-
this.onProblem(`Could not update the pairing file (${errorText(error)}). Pairings still work for now but may not survive a restart.`);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
/**
|
|
290
|
-
* token 有效且与当前 Origin 匹配时返回 true,并刷新 lastUsedAt(闲置期从此刻重新计算)。
|
|
291
|
-
* 刷新写盘失败只告警、仍判有效:判定本身只依赖读到的记录,不能因为磁盘满让已配对的页面全部掉线(更不能让 bridge 崩溃)。
|
|
292
|
-
*/
|
|
293
|
-
verifyToken(token, origin) {
|
|
294
|
-
if (typeof token !== 'string' || token.length === 0)
|
|
295
|
-
return false;
|
|
296
|
-
const hash = sha256(token);
|
|
297
|
-
const records = this.liveRecords();
|
|
298
|
-
const match = records.find((record) => {
|
|
299
|
-
const stored = Buffer.from(record.tokenHash, 'hex');
|
|
300
|
-
return stored.length === hash.length && timingSafeEqual(stored, hash) && record.origin === origin;
|
|
301
|
-
});
|
|
302
|
-
if (!match)
|
|
303
|
-
return false;
|
|
304
|
-
match.lastUsedAt = new Date(this.now()).toISOString();
|
|
305
|
-
this.trySave(records);
|
|
306
|
-
return true;
|
|
307
|
-
}
|
|
308
|
-
unpairAll() {
|
|
309
|
-
this.store.clear();
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
function isRecord(value) {
|
|
313
|
-
if (typeof value !== 'object' || value === null)
|
|
314
|
-
return false;
|
|
315
|
-
const record = value;
|
|
316
|
-
return ['id', 'tokenHash', 'origin', 'createdAt', 'lastUsedAt'].every((key) => typeof record[key] === 'string');
|
|
317
|
-
}
|
|
318
|
-
/**
|
|
319
|
-
* `~/.m2canvas/bridge-pairings.json`。文件损坏按空处理(用户需要重新配对)并经 onProblem 报告,
|
|
320
|
-
* 不抛错:bridge 仍应能启动,只是之前的配对作废。
|
|
321
|
-
*/
|
|
322
|
-
export function createFilePairingStore(path, onProblem = () => undefined) {
|
|
323
|
-
return {
|
|
324
|
-
load() {
|
|
325
|
-
if (!existsSync(path))
|
|
326
|
-
return [];
|
|
327
|
-
try {
|
|
328
|
-
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
329
|
-
const list = typeof parsed === 'object' && parsed !== null ? parsed.pairings : undefined;
|
|
330
|
-
if (!Array.isArray(list))
|
|
331
|
-
throw new Error('missing "pairings" array');
|
|
332
|
-
return list.filter(isRecord);
|
|
333
|
-
}
|
|
334
|
-
catch (error) {
|
|
335
|
-
onProblem(`Ignoring unreadable pairing file ${path} (${error instanceof Error ? error.message : String(error)}). Pages will need to pair again.`);
|
|
336
|
-
return [];
|
|
337
|
-
}
|
|
338
|
-
},
|
|
339
|
-
save(records) {
|
|
340
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
341
|
-
chmodSync(dirname(path), 0o700);
|
|
342
|
-
writeFileSync(path, `${JSON.stringify({ version: 1, pairings: records }, null, 2)}\n`, { mode: 0o600 });
|
|
343
|
-
chmodSync(path, 0o600);
|
|
344
|
-
},
|
|
345
|
-
clear() {
|
|
346
|
-
rmSync(path, { force: true });
|
|
347
|
-
},
|
|
348
|
-
};
|
|
349
|
-
}
|