@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.
Files changed (178) hide show
  1. package/dist/agentdir.d.ts +43 -0
  2. package/dist/agentdir.d.ts.map +1 -0
  3. package/dist/agentdir.js +107 -0
  4. package/dist/agentdir.js.map +1 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +1135 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/collab/chatter.d.ts +54 -0
  10. package/dist/collab/chatter.d.ts.map +1 -0
  11. package/dist/collab/chatter.js +94 -0
  12. package/dist/collab/chatter.js.map +1 -0
  13. package/dist/collab/dag.d.ts +27 -0
  14. package/dist/collab/dag.d.ts.map +1 -0
  15. package/dist/collab/dag.js +107 -0
  16. package/dist/collab/dag.js.map +1 -0
  17. package/dist/collab/negotiation.d.ts +60 -0
  18. package/dist/collab/negotiation.d.ts.map +1 -0
  19. package/dist/collab/negotiation.js +90 -0
  20. package/dist/collab/negotiation.js.map +1 -0
  21. package/dist/collab/tree.d.ts +39 -0
  22. package/dist/collab/tree.d.ts.map +1 -0
  23. package/dist/collab/tree.js +177 -0
  24. package/dist/collab/tree.js.map +1 -0
  25. package/dist/config.d.ts +123 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +206 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +40 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/join.d.ts +50 -0
  34. package/dist/join.d.ts.map +1 -0
  35. package/dist/join.js +167 -0
  36. package/dist/join.js.map +1 -0
  37. package/dist/jointoken.d.ts +144 -0
  38. package/dist/jointoken.d.ts.map +1 -0
  39. package/dist/jointoken.js +472 -0
  40. package/dist/jointoken.js.map +1 -0
  41. package/dist/model.d.ts +64 -0
  42. package/dist/model.d.ts.map +1 -0
  43. package/dist/model.js +158 -0
  44. package/dist/model.js.map +1 -0
  45. package/dist/onboard.d.ts +90 -0
  46. package/dist/onboard.d.ts.map +1 -0
  47. package/dist/onboard.js +510 -0
  48. package/dist/onboard.js.map +1 -0
  49. package/dist/protocol/envelope.d.ts +94 -0
  50. package/dist/protocol/envelope.d.ts.map +1 -0
  51. package/dist/protocol/envelope.js +169 -0
  52. package/dist/protocol/envelope.js.map +1 -0
  53. package/dist/protocol/events.d.ts +51 -0
  54. package/dist/protocol/events.d.ts.map +1 -0
  55. package/dist/protocol/events.js +87 -0
  56. package/dist/protocol/events.js.map +1 -0
  57. package/dist/qr.d.ts +15 -0
  58. package/dist/qr.d.ts.map +1 -0
  59. package/dist/qr.js +52 -0
  60. package/dist/qr.js.map +1 -0
  61. package/dist/quickstart.d.ts +167 -0
  62. package/dist/quickstart.d.ts.map +1 -0
  63. package/dist/quickstart.js +482 -0
  64. package/dist/quickstart.js.map +1 -0
  65. package/dist/quota.d.ts +41 -0
  66. package/dist/quota.d.ts.map +1 -0
  67. package/dist/quota.js +84 -0
  68. package/dist/quota.js.map +1 -0
  69. package/dist/runtime/agent.d.ts +100 -0
  70. package/dist/runtime/agent.d.ts.map +1 -0
  71. package/dist/runtime/agent.js +260 -0
  72. package/dist/runtime/agent.js.map +1 -0
  73. package/dist/runtime/executor.d.ts +42 -0
  74. package/dist/runtime/executor.d.ts.map +1 -0
  75. package/dist/runtime/executor.js +87 -0
  76. package/dist/runtime/executor.js.map +1 -0
  77. package/dist/runtime/fairness.d.ts +25 -0
  78. package/dist/runtime/fairness.d.ts.map +1 -0
  79. package/dist/runtime/fairness.js +79 -0
  80. package/dist/runtime/fairness.js.map +1 -0
  81. package/dist/runtime/node.d.ts +91 -0
  82. package/dist/runtime/node.d.ts.map +1 -0
  83. package/dist/runtime/node.js +203 -0
  84. package/dist/runtime/node.js.map +1 -0
  85. package/dist/runtime/openchamber-http.d.ts +27 -0
  86. package/dist/runtime/openchamber-http.d.ts.map +1 -0
  87. package/dist/runtime/openchamber-http.js +118 -0
  88. package/dist/runtime/openchamber-http.js.map +1 -0
  89. package/dist/runtime/openchamber.d.ts +38 -0
  90. package/dist/runtime/openchamber.d.ts.map +1 -0
  91. package/dist/runtime/openchamber.js +43 -0
  92. package/dist/runtime/openchamber.js.map +1 -0
  93. package/dist/runtime/planner.d.ts +23 -0
  94. package/dist/runtime/planner.d.ts.map +1 -0
  95. package/dist/runtime/planner.js +17 -0
  96. package/dist/runtime/planner.js.map +1 -0
  97. package/dist/runtime/worker.d.ts +96 -0
  98. package/dist/runtime/worker.d.ts.map +1 -0
  99. package/dist/runtime/worker.js +296 -0
  100. package/dist/runtime/worker.js.map +1 -0
  101. package/dist/security.d.ts +462 -0
  102. package/dist/security.d.ts.map +1 -0
  103. package/dist/security.js +1197 -0
  104. package/dist/security.js.map +1 -0
  105. package/dist/store/daemon-store.d.ts +33 -0
  106. package/dist/store/daemon-store.d.ts.map +1 -0
  107. package/dist/store/daemon-store.js +115 -0
  108. package/dist/store/daemon-store.js.map +1 -0
  109. package/dist/store/file-store.d.ts +22 -0
  110. package/dist/store/file-store.d.ts.map +1 -0
  111. package/dist/store/file-store.js +63 -0
  112. package/dist/store/file-store.js.map +1 -0
  113. package/dist/store/mebular-store.d.ts +22 -0
  114. package/dist/store/mebular-store.d.ts.map +1 -0
  115. package/dist/store/mebular-store.js +53 -0
  116. package/dist/store/mebular-store.js.map +1 -0
  117. package/dist/store/message-store.d.ts +26 -0
  118. package/dist/store/message-store.d.ts.map +1 -0
  119. package/dist/store/message-store.js +51 -0
  120. package/dist/store/message-store.js.map +1 -0
  121. package/dist/surface.d.ts +43 -0
  122. package/dist/surface.d.ts.map +1 -0
  123. package/dist/surface.js +515 -0
  124. package/dist/surface.js.map +1 -0
  125. package/dist/transport/null.d.ts +7 -0
  126. package/dist/transport/null.d.ts.map +1 -0
  127. package/dist/transport/null.js +16 -0
  128. package/dist/transport/null.js.map +1 -0
  129. package/dist/transport/spool.d.ts +12 -0
  130. package/dist/transport/spool.d.ts.map +1 -0
  131. package/dist/transport/spool.js +54 -0
  132. package/dist/transport/spool.js.map +1 -0
  133. package/dist/transport/types.d.ts +21 -0
  134. package/dist/transport/types.d.ts.map +1 -0
  135. package/dist/transport/types.js +6 -0
  136. package/dist/transport/types.js.map +1 -0
  137. package/dist/version.json +6 -0
  138. package/package.json +37 -4
  139. package/protocol/collab.example.json +23 -0
  140. package/protocol/daemon-home.example.json +66 -0
  141. package/protocol/envelope.example.json +12 -0
  142. package/protocol/state-machine.json +14 -0
  143. package/protocol/task-tree.example.json +72 -0
  144. package/src/agentdir.ts +138 -0
  145. package/src/cli.ts +1192 -0
  146. package/src/collab/chatter.ts +116 -0
  147. package/src/collab/dag.ts +124 -0
  148. package/src/collab/negotiation.ts +130 -0
  149. package/src/collab/tree.ts +184 -0
  150. package/src/config.ts +286 -0
  151. package/src/index.ts +39 -0
  152. package/src/join.ts +227 -0
  153. package/src/jointoken.ts +609 -0
  154. package/src/model.ts +203 -0
  155. package/src/onboard.ts +601 -0
  156. package/src/protocol/envelope.ts +228 -0
  157. package/src/protocol/events.ts +135 -0
  158. package/src/qr.ts +68 -0
  159. package/src/quickstart.ts +632 -0
  160. package/src/quota.ts +112 -0
  161. package/src/runtime/agent.ts +313 -0
  162. package/src/runtime/executor.ts +114 -0
  163. package/src/runtime/fairness.ts +90 -0
  164. package/src/runtime/node.ts +250 -0
  165. package/src/runtime/openchamber-http.ts +137 -0
  166. package/src/runtime/openchamber.ts +66 -0
  167. package/src/runtime/planner.ts +36 -0
  168. package/src/runtime/worker.ts +354 -0
  169. package/src/security.ts +1660 -0
  170. package/src/store/daemon-store.ts +144 -0
  171. package/src/store/file-store.ts +75 -0
  172. package/src/store/mebular-store.ts +66 -0
  173. package/src/store/message-store.ts +62 -0
  174. package/src/surface.ts +535 -0
  175. package/src/transport/null.ts +20 -0
  176. package/src/transport/spool.ts +62 -0
  177. package/src/transport/types.ts +27 -0
  178. package/README.md +0 -3
@@ -0,0 +1,1660 @@
1
+ // 安全三档(S2):TOTP(中档)、恢复码/break-glass、主密钥备份/恢复、写端点门禁。
2
+ //
3
+ // 口径(SECURITY-TIERS-SPEC v1.1):
4
+ // - TOTP:RFC 6238 / HMAC-SHA-1 / 6 位 / 30s / ±1 步;重放按 lastAcceptedStep;15 分钟 5 次失败锁定 15 分钟;
5
+ // 失败按 1/2/4/8…s(上限 30s)指数退避。
6
+ // - 种子:`<home>/security-totp.json`(0600,**绝不**进事件/日志/同步);校验状态 `<home>/security-totp.json.state`。
7
+ // - 恢复码:20B CSPRNG → Crockford base32 32 字符 + 2 校验字符;HKDF-SHA256 → Ed25519;
8
+ // 密钥材料绝不落盘(只在生成时一次性显示)。
9
+ // - 备份:scrypt(N=32768,r=8,p=1) + AES-256-GCM;文件 0600;默认只含主密钥材料。
10
+ // - 记录形状严格按 SPEC §1.1 / §3.1 / §3.6 / §2.6。S1 未合并时写记录仍按冻结形状落 `__policy__`,
11
+ // 读档位一律按 low(特性探测:缺 `getSecurityTier()` 不炸、不放宽)。
12
+
13
+ import {
14
+ createCipheriv,
15
+ createDecipheriv,
16
+ createHash,
17
+ createHmac,
18
+ createPrivateKey,
19
+ createPublicKey,
20
+ hkdfSync,
21
+ randomBytes,
22
+ randomUUID,
23
+ scryptSync,
24
+ sign as ed25519Sign,
25
+ timingSafeEqual,
26
+ verify as ed25519Verify,
27
+ } from 'node:crypto';
28
+ import { existsSync } from 'node:fs';
29
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
30
+ import { dirname, join } from 'node:path';
31
+ import { POLICY_NAMESPACE, type DeviceCertificate } from '@mebular/core';
32
+ import {
33
+ fleetConfigPath,
34
+ loadFleetConfig,
35
+ masterKeyFingerprint,
36
+ permissionsApplicable,
37
+ readMasterKeyFile,
38
+ type FleetAgentConfig,
39
+ } from './config.js';
40
+ import { onboardDevice } from './onboard.js';
41
+ import { renderTerminalQr, renderSvgQr } from './qr.js';
42
+
43
+ export const SECURITY_TIER_EVENT = 'security_tier';
44
+ export const SECURITY_APPROVAL_EVENT = 'security_approval';
45
+ export const SECURITY_RECOVERY_EVENT = 'security_recovery';
46
+ export const SECURITY_TOTP_EVENT = 'security_totp';
47
+
48
+ export const SECURITY_TOTP_FILE = 'security-totp.json';
49
+ export const SECURITY_TOTP_STATE_FILE = 'security-totp.json.state';
50
+
51
+ export const TOTP_STEP_MS = 30_000;
52
+ export const TOTP_WINDOW_STEPS = 1;
53
+ export const TOTP_MAX_FAILURES = 5;
54
+ export const TOTP_FAILURE_WINDOW_MS = 15 * 60_000;
55
+ export const TOTP_LOCK_MS = 15 * 60_000;
56
+ export const TOTP_BACKOFF_MAX_MS = 30_000;
57
+
58
+ export type SecurityLevel = 'low' | 'medium' | 'high';
59
+
60
+ export interface SecurityTierState {
61
+ level: SecurityLevel;
62
+ changedAt?: number;
63
+ changedBy?: string;
64
+ admitted: string[];
65
+ recoveryKeyId?: string;
66
+ rejections: Array<{ eventId: string; reason: string }>;
67
+ }
68
+
69
+ /** `getSecurityTier()` 缺失时的保守读档(低档),并带特性探测标记。 */
70
+ export interface SecurityStateView extends SecurityTierState {
71
+ coreTierAvailable: boolean;
72
+ }
73
+
74
+ export interface SecurityRejection {
75
+ eventId: string;
76
+ reason: string;
77
+ }
78
+
79
+ export interface CosignProof {
80
+ v: 1;
81
+ op: SecurityAction;
82
+ approver: string;
83
+ approverCertificateChain: DeviceCertificate[];
84
+ approvalId: string;
85
+ digest: string;
86
+ pairingCode: string;
87
+ issuedAt: number;
88
+ expiresAt: number;
89
+ signature: string;
90
+ }
91
+
92
+ export interface BreakGlassProof {
93
+ v: 1;
94
+ op: SecurityAction;
95
+ keyId: string;
96
+ digest: string;
97
+ nonce: string;
98
+ issuedAt: number;
99
+ expiresAt: number;
100
+ signature: string;
101
+ }
102
+
103
+ export interface SecurityTierRecord {
104
+ level: SecurityLevel;
105
+ nonce: string;
106
+ issuedAt: number;
107
+ note?: string;
108
+ cosign?: CosignProof;
109
+ breakGlass?: BreakGlassProof;
110
+ }
111
+
112
+ export interface SecurityApprovalRecord {
113
+ approvalId: string;
114
+ op: SecurityAction;
115
+ decision: 'request' | 'approve' | 'deny';
116
+ initiator: string;
117
+ target?: string;
118
+ namespace?: string;
119
+ level?: SecurityLevel;
120
+ nonce: string;
121
+ digest: string;
122
+ pairingCode: string;
123
+ cosign?: CosignProof;
124
+ expiresAt: number;
125
+ issuedAt: number;
126
+ note?: string;
127
+ }
128
+
129
+ export interface SecurityRecoveryRecord {
130
+ keyId: string;
131
+ publicKey: string;
132
+ createdAt: number;
133
+ mode: 'set' | 'rotate';
134
+ /** rotate 的动作 nonce(进入 digest;`set` 可缺省)。 */
135
+ nonce?: string;
136
+ note?: string;
137
+ cosign?: CosignProof;
138
+ breakGlass?: BreakGlassProof;
139
+ }
140
+
141
+ export interface SecurityTotpRecord {
142
+ device: string;
143
+ fingerprint: string;
144
+ epoch?: string;
145
+ issuedAt: number;
146
+ note?: string;
147
+ }
148
+
149
+ export type SecurityAction = 'kick' | 'join' | 'tier' | 'rotate' | 'restore';
150
+
151
+ export interface SecurityActionParams {
152
+ op: SecurityAction;
153
+ initiator: string;
154
+ subject?: string;
155
+ namespace?: string;
156
+ level?: SecurityLevel;
157
+ oldKeyId?: string;
158
+ newPublicKey?: string;
159
+ nonce: string;
160
+ }
161
+
162
+ export interface SecurityApp {
163
+ deviceId?: string;
164
+ eventLog?: {
165
+ append(event: Record<string, unknown>): Promise<{ id: string }>;
166
+ listEvents(filter?: Record<string, unknown>): Promise<Array<PolicyEvent>>;
167
+ };
168
+ getSecurityTier?: (...args: any[]) => Promise<unknown>;
169
+ setSecurityTier?: (...args: any[]) => Promise<unknown>;
170
+ getBlockedDevices?: (...args: any[]) => Promise<unknown>;
171
+ revokeDevice?: (...args: any[]) => Promise<unknown>;
172
+ restoreDevice?: (...args: any[]) => Promise<unknown>;
173
+ identity?: any;
174
+ }
175
+
176
+ export interface PolicyEvent {
177
+ id: string;
178
+ type: string;
179
+ author?: string;
180
+ timestamp?: number;
181
+ data?: Record<string, any>;
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // 通用 IO(0600)
186
+ // ---------------------------------------------------------------------------
187
+
188
+ async function ensureDir(path: string): Promise<void> {
189
+ await mkdir(path, { recursive: true });
190
+ if (permissionsApplicable()) await chmod(path, 0o700).catch(() => undefined);
191
+ }
192
+
193
+ /** 原子写 0600(先写临时文件再 rename;失败不留半成品)。 */
194
+ export async function writePrivateFile(path: string, data: string | Uint8Array): Promise<void> {
195
+ await ensureDir(dirname(path));
196
+ const tmp = `${path}.tmp-${process.pid}-${randomUUID()}`;
197
+ try {
198
+ await writeFile(tmp, data, { mode: 0o600 });
199
+ if (permissionsApplicable()) await chmod(tmp, 0o600);
200
+ await rename(tmp, path);
201
+ } catch (error) {
202
+ await rm(tmp, { force: true }).catch(() => undefined);
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ export async function readPrivateJson<T>(path: string): Promise<T | null> {
208
+ if (!existsSync(path)) return null;
209
+ return JSON.parse(await readFile(path, 'utf-8')) as T;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // base32 / Crockford
214
+ // ---------------------------------------------------------------------------
215
+
216
+ const RFC4648_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
217
+ const CROCKFORD_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
218
+
219
+ export function base32Encode(bytes: Uint8Array): string {
220
+ let bits = 0;
221
+ let value = 0;
222
+ let out = '';
223
+ for (const byte of bytes) {
224
+ value = (value << 8) | byte;
225
+ bits += 8;
226
+ while (bits >= 5) {
227
+ out += RFC4648_ALPHABET[(value >>> (bits - 5)) & 0x1f];
228
+ bits -= 5;
229
+ }
230
+ }
231
+ if (bits > 0) out += RFC4648_ALPHABET[(value << (5 - bits)) & 0x1f];
232
+ return out;
233
+ }
234
+
235
+ export function base32Decode(input: string): Uint8Array {
236
+ const normalized = String(input)
237
+ .toUpperCase()
238
+ .replace(/[\s-]+/g, '')
239
+ .replace(/=+$/g, '');
240
+ let bits = 0;
241
+ let value = 0;
242
+ const out: number[] = [];
243
+ for (const char of normalized) {
244
+ const index = RFC4648_ALPHABET.indexOf(char);
245
+ if (index < 0) throw new Error(`base32 非法字符:${char}`);
246
+ value = (value << 5) | index;
247
+ bits += 5;
248
+ if (bits >= 8) {
249
+ out.push((value >>> (bits - 8)) & 0xff);
250
+ bits -= 8;
251
+ }
252
+ }
253
+ return new Uint8Array(out);
254
+ }
255
+
256
+ function normalizeCrockford(input: string): string {
257
+ return String(input)
258
+ .toUpperCase()
259
+ .replace(/[\s-]+/g, '')
260
+ .replace(/[IL]/g, '1')
261
+ .replace(/O/g, '0')
262
+ .replace(/U/g, 'V');
263
+ }
264
+
265
+ export function crockfordEncode(bytes: Uint8Array): string {
266
+ let bits = 0;
267
+ let value = 0;
268
+ let out = '';
269
+ for (const byte of bytes) {
270
+ value = (value << 8) | byte;
271
+ bits += 8;
272
+ while (bits >= 5) {
273
+ out += CROCKFORD_ALPHABET[(value >>> (bits - 5)) & 0x1f];
274
+ bits -= 5;
275
+ }
276
+ }
277
+ if (bits > 0) out += CROCKFORD_ALPHABET[(value << (5 - bits)) & 0x1f];
278
+ return out;
279
+ }
280
+
281
+ export function crockfordDecode(input: string): Uint8Array {
282
+ const normalized = normalizeCrockford(input);
283
+ let bits = 0;
284
+ let value = 0;
285
+ const out: number[] = [];
286
+ for (const char of normalized) {
287
+ const index = CROCKFORD_ALPHABET.indexOf(char);
288
+ if (index < 0) throw new Error(`恢复码非法字符:${char}`);
289
+ value = (value << 5) | index;
290
+ bits += 5;
291
+ if (bits >= 8) {
292
+ out.push((value >>> (bits - 8)) & 0xff);
293
+ bits -= 8;
294
+ }
295
+ }
296
+ return new Uint8Array(out);
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // TOTP
301
+ // ---------------------------------------------------------------------------
302
+
303
+ export interface TotpSeedRecord {
304
+ v: 1;
305
+ secret: string;
306
+ createdAt: number;
307
+ fingerprint: string;
308
+ }
309
+
310
+ export interface TotpVerifyState {
311
+ v: 1;
312
+ failures: number;
313
+ windowStartedAt: number;
314
+ lockedUntil: number;
315
+ backoffUntil: number;
316
+ lastAcceptedStep: number;
317
+ lastAcceptedAt: number;
318
+ updatedAt: number;
319
+ }
320
+
321
+ export interface TotpStatus {
322
+ imported: boolean;
323
+ fingerprint?: string;
324
+ createdAt?: number;
325
+ failures: number;
326
+ lockedUntil: number;
327
+ backoffUntil: number;
328
+ lastAcceptedAt: number;
329
+ }
330
+
331
+ const EMPTY_TOTP_STATE = (): TotpVerifyState => ({
332
+ v: 1,
333
+ failures: 0,
334
+ windowStartedAt: 0,
335
+ lockedUntil: 0,
336
+ backoffUntil: 0,
337
+ lastAcceptedStep: -1,
338
+ lastAcceptedAt: 0,
339
+ updatedAt: 0,
340
+ });
341
+
342
+ export function totpFingerprint(secret: string): string {
343
+ return createHash('sha256').update(base32Decode(secret)).digest('hex').slice(0, 8);
344
+ }
345
+
346
+ export function generateTotpSecret(bytes = 20): string {
347
+ return base32Encode(randomBytes(bytes));
348
+ }
349
+
350
+ export function totpOtpauthUri(secret: string, deviceId: string, issuer = 'Mebular'): string {
351
+ const label = `${issuer}:${deviceId}`;
352
+ const params = new URLSearchParams({
353
+ secret,
354
+ issuer,
355
+ algorithm: 'SHA1',
356
+ digits: '6',
357
+ period: String(TOTP_STEP_MS / 1000),
358
+ });
359
+ return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
360
+ }
361
+
362
+ /** 解析纯 base32 / otpauth URI;返回 secret(base32)与可选元数据。 */
363
+ export function parseTotpInput(input: string): { secret: string; issuer?: string; label?: string } {
364
+ const raw = String(input ?? '').trim();
365
+ if (raw.length === 0) throw new Error('动态码种子为空');
366
+ if (raw.startsWith('otpauth://')) {
367
+ const url = new URL(raw);
368
+ if (url.host !== 'totp') throw new Error('仅支持 otpauth://totp/…');
369
+ const secret = (url.searchParams.get('secret') ?? '').toUpperCase().replace(/[\s-]+/g, '');
370
+ if (secret.length === 0) throw new Error('otpauth URI 缺少 secret');
371
+ const digits = url.searchParams.get('digits');
372
+ const period = url.searchParams.get('period');
373
+ const algorithm = url.searchParams.get('algorithm');
374
+ if (digits !== null && digits !== '6') throw new Error('仅支持 digits=6');
375
+ if (period !== null && period !== '30') throw new Error('仅支持 period=30');
376
+ if (algorithm !== null && algorithm.toUpperCase() !== 'SHA1') throw new Error('仅支持 algorithm=SHA1');
377
+ const issuer = url.searchParams.get('issuer') ?? undefined;
378
+ const label = decodeURIComponent(url.pathname.replace(/^\/+/, '')) || undefined;
379
+ return { secret, ...(issuer !== undefined ? { issuer } : {}), ...(label !== undefined ? { label } : {}) };
380
+ }
381
+ // 分组文本:允许空格/连字符/等号
382
+ return { secret: raw.toUpperCase().replace(/[\s-]+/g, '').replace(/=+$/g, '') };
383
+ }
384
+
385
+ export function totpCodeAt(secret: string, step: number): string {
386
+ const key = base32Decode(secret);
387
+ const counter = Buffer.alloc(8);
388
+ counter.writeBigUInt64BE(BigInt(step));
389
+ const hmac = createHmac('sha1', key).update(counter).digest();
390
+ const offset = hmac[hmac.length - 1]! & 0x0f;
391
+ const binary = ((hmac[offset]! & 0x7f) << 24)
392
+ | (hmac[offset + 1]! << 16)
393
+ | (hmac[offset + 2]! << 8)
394
+ | hmac[offset + 3]!;
395
+ return String(binary % 1_000_000).padStart(6, '0');
396
+ }
397
+
398
+ export async function readTotpSeed(dir: string): Promise<TotpSeedRecord | null> {
399
+ const record = await readPrivateJson<TotpSeedRecord>(join(dir, SECURITY_TOTP_FILE));
400
+ if (record === null) return null;
401
+ if (record.v !== 1 || typeof record.secret !== 'string' || typeof record.fingerprint !== 'string') {
402
+ throw new Error('security-totp.json 形状非法');
403
+ }
404
+ return record;
405
+ }
406
+
407
+ export async function writeTotpSeed(dir: string, secret: string, createdAt = Date.now()): Promise<TotpSeedRecord> {
408
+ const record: TotpSeedRecord = { v: 1, secret, createdAt, fingerprint: totpFingerprint(secret) };
409
+ await writePrivateFile(join(dir, SECURITY_TOTP_FILE), JSON.stringify(record, null, 2));
410
+ return record;
411
+ }
412
+
413
+ /** 导入种子;`expectedFingerprint` 不一致即拒绝(防导入错误/被替换)。 */
414
+ export async function importTotpSeed(
415
+ dir: string,
416
+ input: string,
417
+ options: { expectedFingerprint?: string } = {},
418
+ ): Promise<TotpSeedRecord> {
419
+ const { secret } = parseTotpInput(input);
420
+ try {
421
+ base32Decode(secret);
422
+ } catch {
423
+ throw new Error('动态码种子不是合法 base32');
424
+ }
425
+ const fingerprint = totpFingerprint(secret);
426
+ if (options.expectedFingerprint !== undefined
427
+ && options.expectedFingerprint.toLowerCase().replace(/^sha256:/, '') !== fingerprint) {
428
+ throw new Error(`指纹不一致:期望 ${options.expectedFingerprint},实际 ${fingerprint}`);
429
+ }
430
+ return writeTotpSeed(dir, secret);
431
+ }
432
+
433
+ async function readTotpState(path: string | undefined): Promise<TotpVerifyState> {
434
+ if (path === undefined) return EMPTY_TOTP_STATE();
435
+ const state = await readPrivateJson<TotpVerifyState>(path);
436
+ if (state === null) return EMPTY_TOTP_STATE();
437
+ return { ...EMPTY_TOTP_STATE(), ...state, v: 1 };
438
+ }
439
+
440
+ async function writeTotpState(path: string | undefined, state: TotpVerifyState): Promise<void> {
441
+ if (path === undefined) return;
442
+ await writePrivateFile(path, JSON.stringify(state, null, 2));
443
+ }
444
+
445
+ export function totpStatePath(dir: string): string {
446
+ return join(dir, SECURITY_TOTP_STATE_FILE);
447
+ }
448
+
449
+ export interface VerifyTotpOptions {
450
+ /** home 目录:状态落 `<home>/security-totp.json.state`。缺省不持久化(仅纯函数校验)。 */
451
+ dir?: string;
452
+ statePath?: string;
453
+ /** 测试注入:失败退避延时;默认真实 setTimeout。 */
454
+ sleep?: (ms: number) => Promise<void>;
455
+ maxFailures?: number;
456
+ windowMs?: number;
457
+ lockMs?: number;
458
+ }
459
+
460
+ export type TotpFailureReason = 'invalid' | 'replay' | 'locked' | 'malformed' | 'missing';
461
+
462
+ export interface VerifyTotpResult {
463
+ ok: boolean;
464
+ driftStep?: number;
465
+ reason?: TotpFailureReason;
466
+ retryAfterMs?: number;
467
+ lockedUntil?: number;
468
+ failures?: number;
469
+ }
470
+
471
+ const sleepDefault = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
472
+
473
+ /**
474
+ * 校验 TOTP(±1 步)。状态持久化时同时执行重放/限次/退避。
475
+ * 注意:`now` 是逻辑时间;测试可注入固定值 + 空 sleep。
476
+ */
477
+ export async function verifyTotp(
478
+ secret: string,
479
+ code: string,
480
+ now: number = Date.now(),
481
+ options: VerifyTotpOptions = {},
482
+ ): Promise<VerifyTotpResult> {
483
+ const statePath = options.statePath ?? (options.dir !== undefined ? totpStatePath(options.dir) : undefined);
484
+ const sleep = options.sleep ?? sleepDefault;
485
+ const maxFailures = options.maxFailures ?? TOTP_MAX_FAILURES;
486
+ const windowMs = options.windowMs ?? TOTP_FAILURE_WINDOW_MS;
487
+ const lockMs = options.lockMs ?? TOTP_LOCK_MS;
488
+ let clock = now;
489
+ const state = await readTotpState(statePath);
490
+
491
+ if (state.lockedUntil > 0 && clock >= state.lockedUntil) {
492
+ state.failures = 0;
493
+ state.windowStartedAt = 0;
494
+ state.lockedUntil = 0;
495
+ state.backoffUntil = 0;
496
+ }
497
+ if (state.lockedUntil > clock) {
498
+ return {
499
+ ok: false,
500
+ reason: 'locked',
501
+ retryAfterMs: state.lockedUntil - clock,
502
+ lockedUntil: state.lockedUntil,
503
+ failures: state.failures,
504
+ };
505
+ }
506
+ if (state.backoffUntil > clock) {
507
+ const waitMs = state.backoffUntil - clock;
508
+ await sleep(waitMs);
509
+ clock += waitMs;
510
+ state.backoffUntil = 0;
511
+ }
512
+
513
+ const normalized = String(code ?? '').replace(/[\s-]+/g, '');
514
+ const malformed = !/^\d{6}$/.test(normalized);
515
+ const step = Math.floor(clock / TOTP_STEP_MS);
516
+ let matchedStep: number | null = null;
517
+ if (!malformed) {
518
+ for (let delta = -TOTP_WINDOW_STEPS; delta <= TOTP_WINDOW_STEPS; delta += 1) {
519
+ const candidate = step + delta;
520
+ if (candidate <= state.lastAcceptedStep) continue;
521
+ const expected = totpCodeAt(secret, candidate);
522
+ if (expected.length === normalized.length && timingSafeEqual(Buffer.from(expected), Buffer.from(normalized))) {
523
+ matchedStep = candidate;
524
+ break;
525
+ }
526
+ }
527
+ }
528
+
529
+ if (matchedStep !== null) {
530
+ const driftStep = matchedStep - step;
531
+ state.failures = 0;
532
+ state.windowStartedAt = 0;
533
+ state.lockedUntil = 0;
534
+ state.backoffUntil = 0;
535
+ state.lastAcceptedStep = matchedStep;
536
+ state.lastAcceptedAt = clock;
537
+ state.updatedAt = clock;
538
+ await writeTotpState(statePath, state);
539
+ return { ok: true, driftStep, failures: 0 };
540
+ }
541
+
542
+ // 失败:重放单独归因(不影响限次口径)
543
+ if (!malformed && state.lastAcceptedStep >= 0) {
544
+ for (let delta = -TOTP_WINDOW_STEPS; delta <= TOTP_WINDOW_STEPS; delta += 1) {
545
+ const candidate = step + delta;
546
+ const expected = totpCodeAt(secret, candidate);
547
+ if (expected === normalized) {
548
+ state.updatedAt = clock;
549
+ await writeTotpState(statePath, state);
550
+ return { ok: false, reason: 'replay', failures: state.failures };
551
+ }
552
+ }
553
+ }
554
+
555
+ if (state.windowStartedAt === 0 || clock - state.windowStartedAt > windowMs) {
556
+ state.failures = 0;
557
+ state.windowStartedAt = clock;
558
+ }
559
+ state.failures += 1;
560
+ state.updatedAt = clock;
561
+ if (state.failures >= maxFailures) {
562
+ state.lockedUntil = clock + lockMs;
563
+ state.backoffUntil = 0;
564
+ await writeTotpState(statePath, state);
565
+ return {
566
+ ok: false,
567
+ reason: 'locked',
568
+ retryAfterMs: lockMs,
569
+ lockedUntil: state.lockedUntil,
570
+ failures: state.failures,
571
+ };
572
+ }
573
+ const backoffMs = Math.min(TOTP_BACKOFF_MAX_MS, 1_000 * 2 ** (state.failures - 1));
574
+ state.backoffUntil = clock + backoffMs;
575
+ await writeTotpState(statePath, state);
576
+ await sleep(backoffMs);
577
+ return {
578
+ ok: false,
579
+ reason: malformed ? 'malformed' : 'invalid',
580
+ retryAfterMs: backoffMs,
581
+ failures: state.failures,
582
+ };
583
+ }
584
+
585
+ export async function readTotpStatus(dir: string): Promise<TotpStatus> {
586
+ let seed: TotpSeedRecord | null = null;
587
+ try {
588
+ seed = await readTotpSeed(dir);
589
+ } catch {
590
+ seed = null;
591
+ }
592
+ const state = await readTotpState(totpStatePath(dir)).catch(() => EMPTY_TOTP_STATE());
593
+ return {
594
+ imported: seed !== null,
595
+ ...(seed !== null ? { fingerprint: seed.fingerprint, createdAt: seed.createdAt } : {}),
596
+ failures: state.failures,
597
+ lockedUntil: state.lockedUntil,
598
+ backoffUntil: state.backoffUntil,
599
+ lastAcceptedAt: state.lastAcceptedAt,
600
+ };
601
+ }
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // 恢复码 / break-glass
605
+ // ---------------------------------------------------------------------------
606
+
607
+ const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
608
+
609
+ export interface RecoveryCodeMaterial {
610
+ code: string;
611
+ keyId: string;
612
+ publicKeyHex: string;
613
+ createdAt: number;
614
+ }
615
+
616
+ export interface DerivedRecoveryKey {
617
+ privateKey: ReturnType<typeof createPrivateKey>;
618
+ publicKey: ReturnType<typeof createPublicKey>;
619
+ publicKeyHex: string;
620
+ keyId: string;
621
+ }
622
+
623
+ function checkChars(secret: Uint8Array): string {
624
+ const digest = createHash('sha256').update(secret).digest();
625
+ const value = digest.readUInt16BE(0) & 0x03ff;
626
+ return CROCKFORD_ALPHABET[value >> 5]! + CROCKFORD_ALPHABET[value & 0x1f]!;
627
+ }
628
+
629
+ /** 20B secret → 32 字符 Crockford + 2 校验字符。 */
630
+ export function encodeRecoveryCode(secret: Uint8Array): string {
631
+ if (secret.length !== 20) throw new Error('恢复码 secret 必须为 20 字节');
632
+ return crockfordEncode(secret) + checkChars(secret);
633
+ }
634
+
635
+ export function decodeRecoveryCode(code: string): Uint8Array {
636
+ const compact = normalizeCrockford(code);
637
+ if (compact.length !== 34) throw new Error('恢复码长度非法(需要 34 个字符)');
638
+ const secret = crockfordDecode(compact.slice(0, 32));
639
+ if (secret.length !== 20) throw new Error('恢复码数据长度非法');
640
+ if (checkChars(secret) !== compact.slice(32)) throw new Error('恢复码校验失败');
641
+ return secret;
642
+ }
643
+
644
+ /** 显示形态:8 组 ×4 + 2 位校验单独成组。 */
645
+ export function formatRecoveryCode(code: string): string {
646
+ const compact = normalizeCrockford(code);
647
+ const groups: string[] = [];
648
+ for (let i = 0; i < compact.length; i += 4) groups.push(compact.slice(i, i + 4));
649
+ return groups.join('-');
650
+ }
651
+
652
+ export function deriveRecoveryKey(code: string): DerivedRecoveryKey {
653
+ const secret = decodeRecoveryCode(code);
654
+ const seed = Buffer.from(hkdfSync('sha256', secret, Buffer.alloc(0), 'mebular-breakglass-v1', 32));
655
+ const privateKey = createPrivateKey({
656
+ key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]),
657
+ format: 'der',
658
+ type: 'pkcs8',
659
+ });
660
+ const publicKey = createPublicKey(privateKey);
661
+ const spki = publicKey.export({ format: 'der', type: 'spki' });
662
+ const publicKeyRaw = spki.subarray(spki.length - 32);
663
+ const keyId = createHash('sha256').update(publicKeyRaw).digest('hex').slice(0, 16);
664
+ return { privateKey, publicKey, publicKeyHex: publicKeyRaw.toString('hex'), keyId };
665
+ }
666
+
667
+ /** 生成恢复码(secret 不落盘;调用方只显示一次)。 */
668
+ export function generateRecoveryCode(now = Date.now()): RecoveryCodeMaterial {
669
+ const secret = randomBytes(20);
670
+ const code = encodeRecoveryCode(secret);
671
+ const derived = deriveRecoveryKey(code);
672
+ return { code, keyId: derived.keyId, publicKeyHex: derived.publicKeyHex, createdAt: now };
673
+ }
674
+
675
+ /** 规范 JSON:键序固定、无空白、忽略 undefined。 */
676
+ export function canonicalJson(value: unknown): string {
677
+ if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
678
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(',')}]`;
679
+ const record = value as Record<string, unknown>;
680
+ const keys = Object.keys(record).filter((key) => record[key] !== undefined).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
681
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
682
+ }
683
+
684
+ export function sha256Hex(value: string | Uint8Array): string {
685
+ return createHash('sha256').update(value).digest('hex');
686
+ }
687
+
688
+ /** §3.2 动作摘要的规范 JSON。 */
689
+ export function securityActionCanonical(params: SecurityActionParams): Record<string, unknown> {
690
+ const base: Record<string, unknown> = { authority: 'mebular-security', op: params.op };
691
+ if (params.op === 'kick') {
692
+ base.initiator = params.initiator;
693
+ base.subject = params.subject;
694
+ } else if (params.op === 'join') {
695
+ base.initiator = params.initiator;
696
+ base.namespace = params.namespace;
697
+ } else if (params.op === 'tier') {
698
+ base.initiator = params.initiator;
699
+ base.level = params.level;
700
+ } else if (params.op === 'restore') {
701
+ base.initiator = params.initiator;
702
+ base.subject = params.subject;
703
+ } else {
704
+ base.initiator = params.initiator;
705
+ base.oldKeyId = params.oldKeyId;
706
+ base.newPublicKey = params.newPublicKey;
707
+ }
708
+ base.nonce = params.nonce;
709
+ return base;
710
+ }
711
+
712
+ export function securityActionDigest(params: SecurityActionParams): string {
713
+ // 与 core(S1 `grantPolicy.securityActionDigest`)逐字节一致:**insertion-order JSON.stringify**,
714
+ // 不是排序键序(动作摘要的键序在 SPEC §3.2 冻结)。
715
+ return sha256Hex(JSON.stringify(securityActionCanonical(params)));
716
+ }
717
+
718
+ /** 配对号:sha256('mebular-pairing'||digest) 前 4 字节 uint32 BE % 1e6 → 'NNN-NNN'。 */
719
+ export function pairingCodeForDigest(digest: string): string {
720
+ const hash = createHash('sha256').update('mebular-pairing').update(digest).digest();
721
+ const value = hash.readUInt32BE(0) % 1_000_000;
722
+ const text = String(value).padStart(6, '0');
723
+ return `${text.slice(0, 3)}-${text.slice(3)}`;
724
+ }
725
+
726
+ function breakGlassUnsigned(proof: BreakGlassProof): Record<string, unknown> {
727
+ const { signature: _signature, ...rest } = proof;
728
+ return rest;
729
+ }
730
+
731
+ export function cosignUnsigned(proof: CosignProof): Record<string, unknown> {
732
+ const { signature: _signature, ...rest } = proof;
733
+ return rest;
734
+ }
735
+
736
+ export interface SignBreakGlassInput {
737
+ code: string;
738
+ op: SecurityAction;
739
+ initiator: string;
740
+ subject?: string;
741
+ namespace?: string;
742
+ level?: SecurityLevel;
743
+ oldKeyId?: string;
744
+ newPublicKey?: string;
745
+ nonce?: string;
746
+ now?: number;
747
+ ttlMs?: number;
748
+ }
749
+
750
+ export interface SignedBreakGlass {
751
+ proof: BreakGlassProof;
752
+ digest: string;
753
+ nonce: string;
754
+ pairingCode: string;
755
+ }
756
+
757
+ export function signBreakGlass(input: SignBreakGlassInput): SignedBreakGlass {
758
+ const derived = deriveRecoveryKey(input.code);
759
+ const nonce = input.nonce ?? randomUUID();
760
+ const now = input.now ?? Date.now();
761
+ const digest = securityActionDigest({
762
+ op: input.op,
763
+ initiator: input.initiator,
764
+ ...(input.subject !== undefined ? { subject: input.subject } : {}),
765
+ ...(input.namespace !== undefined ? { namespace: input.namespace } : {}),
766
+ ...(input.level !== undefined ? { level: input.level } : {}),
767
+ ...(input.oldKeyId !== undefined ? { oldKeyId: input.oldKeyId } : {}),
768
+ ...(input.newPublicKey !== undefined ? { newPublicKey: input.newPublicKey } : {}),
769
+ nonce,
770
+ });
771
+ const proof: BreakGlassProof = {
772
+ v: 1,
773
+ op: input.op,
774
+ keyId: derived.keyId,
775
+ digest,
776
+ nonce,
777
+ issuedAt: now,
778
+ expiresAt: now + (input.ttlMs ?? 10 * 60_000),
779
+ signature: '',
780
+ };
781
+ proof.signature = ed25519Sign(null, Buffer.from(canonicalJson(breakGlassUnsigned(proof))), derived.privateKey).toString('base64');
782
+ return { proof, digest, nonce, pairingCode: pairingCodeForDigest(digest) };
783
+ }
784
+
785
+ export interface VerifyBreakGlassOptions {
786
+ publicKeyHex: string;
787
+ expectedDigest?: string;
788
+ usedNonces?: Set<string>;
789
+ currentKeyId?: string;
790
+ }
791
+
792
+ export function verifyBreakGlassProof(proof: BreakGlassProof, options: VerifyBreakGlassOptions): { ok: boolean; reason?: string } {
793
+ if (!proof || proof.v !== 1 || typeof proof.signature !== 'string' || typeof proof.keyId !== 'string') {
794
+ return { ok: false, reason: 'invalid-proof' };
795
+ }
796
+ if (options.currentKeyId !== undefined && proof.keyId !== options.currentKeyId) {
797
+ return { ok: false, reason: 'invalid-proof' };
798
+ }
799
+ if (options.expectedDigest !== undefined && proof.digest !== options.expectedDigest) {
800
+ return { ok: false, reason: 'invalid-proof' };
801
+ }
802
+ if (options.usedNonces?.has(proof.nonce)) {
803
+ return { ok: false, reason: 'stale-or-replayed-nonce' };
804
+ }
805
+ try {
806
+ const publicKey = createPublicKey({
807
+ key: Buffer.concat([
808
+ Buffer.from('302a300506032b6570032100', 'hex'),
809
+ Buffer.from(options.publicKeyHex, 'hex'),
810
+ ]),
811
+ format: 'der',
812
+ type: 'spki',
813
+ });
814
+ const ok = ed25519Verify(
815
+ null,
816
+ Buffer.from(canonicalJson(breakGlassUnsigned(proof))),
817
+ publicKey,
818
+ Buffer.from(proof.signature, 'base64'),
819
+ );
820
+ return ok ? { ok: true } : { ok: false, reason: 'invalid-proof' };
821
+ } catch {
822
+ return { ok: false, reason: 'invalid-proof' };
823
+ }
824
+ }
825
+
826
+ export interface SignCosignInput {
827
+ privateKey: CryptoKey;
828
+ approver: string;
829
+ approverCertificateChain: DeviceCertificate[];
830
+ approvalId: string;
831
+ op: SecurityAction;
832
+ digest: string;
833
+ pairingCode?: string;
834
+ now?: number;
835
+ ttlMs?: number;
836
+ }
837
+
838
+ export async function signCosignProof(input: SignCosignInput): Promise<CosignProof> {
839
+ const now = input.now ?? Date.now();
840
+ const proof: CosignProof = {
841
+ v: 1,
842
+ op: input.op,
843
+ approver: input.approver,
844
+ approverCertificateChain: input.approverCertificateChain,
845
+ approvalId: input.approvalId,
846
+ digest: input.digest,
847
+ pairingCode: input.pairingCode ?? pairingCodeForDigest(input.digest),
848
+ issuedAt: now,
849
+ expiresAt: now + (input.ttlMs ?? 10 * 60_000),
850
+ signature: '',
851
+ };
852
+ const data = new TextEncoder().encode(canonicalJson(cosignUnsigned(proof)));
853
+ const signature = await crypto.subtle.sign({ name: 'Ed25519' }, input.privateKey, data);
854
+ proof.signature = Buffer.from(new Uint8Array(signature)).toString('base64');
855
+ return proof;
856
+ }
857
+
858
+ export async function verifyCosignProofSignature(proof: CosignProof, approverPublicKeyHex: string): Promise<boolean> {
859
+ try {
860
+ const publicKey = await crypto.subtle.importKey(
861
+ 'raw',
862
+ Buffer.from(approverPublicKeyHex, 'hex'),
863
+ { name: 'Ed25519' },
864
+ false,
865
+ ['verify'],
866
+ );
867
+ return await crypto.subtle.verify(
868
+ { name: 'Ed25519' },
869
+ publicKey,
870
+ Buffer.from(proof.signature, 'base64'),
871
+ new TextEncoder().encode(canonicalJson(cosignUnsigned(proof))),
872
+ );
873
+ } catch {
874
+ return false;
875
+ }
876
+ }
877
+
878
+ export interface RecoveryRegistry {
879
+ currentKeyId?: string;
880
+ keys: Record<string, { publicKeyHex: string; createdAt: number }>;
881
+ usedNonces: Set<string>;
882
+ lastUsedAt?: number;
883
+ }
884
+
885
+ function collectBreakGlass(record: unknown, registry: RecoveryRegistry, at?: number): void {
886
+ if (record === null || typeof record !== 'object') return;
887
+ const proof = (record as { breakGlass?: BreakGlassProof }).breakGlass;
888
+ if (proof === undefined || typeof proof.nonce !== 'string') return;
889
+ registry.usedNonces.add(proof.nonce);
890
+ if (typeof at === 'number' && (registry.lastUsedAt === undefined || at > registry.lastUsedAt)) {
891
+ registry.lastUsedAt = at;
892
+ }
893
+ }
894
+
895
+ /** 从 `__policy__` 事件推导恢复码登记/消费视图(本地预检口径;权威采纳在 core/S1)。 */
896
+ export function collectRecoveryRegistry(events: readonly PolicyEvent[]): RecoveryRegistry {
897
+ const registry: RecoveryRegistry = { keys: {}, usedNonces: new Set() };
898
+ const ordered = [...events].sort((a, b) => {
899
+ const at = a.timestamp ?? 0;
900
+ const bt = b.timestamp ?? 0;
901
+ if (at !== bt) return at - bt;
902
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
903
+ });
904
+ for (const event of ordered) {
905
+ const data = event.data ?? {};
906
+ if (event.type === SECURITY_RECOVERY_EVENT) {
907
+ const record = data.securityRecovery as SecurityRecoveryRecord | undefined;
908
+ if (record && typeof record.keyId === 'string' && typeof record.publicKey === 'string') {
909
+ registry.keys[record.keyId] = { publicKeyHex: record.publicKey, createdAt: record.createdAt };
910
+ registry.currentKeyId = record.keyId;
911
+ collectBreakGlass(record, registry, event.timestamp);
912
+ }
913
+ continue;
914
+ }
915
+ if (event.type === SECURITY_TIER_EVENT) collectBreakGlass(data.securityTier, registry, event.timestamp);
916
+ else if (event.type === 'device_revoke') collectBreakGlass(data.deviceRevoke, registry, event.timestamp);
917
+ else if (event.type === 'device_restore') collectBreakGlass(data.deviceRestore, registry, event.timestamp);
918
+ else if (event.type === 'namespace_subscription') collectBreakGlass(data.subscription, registry, event.timestamp);
919
+ }
920
+ return registry;
921
+ }
922
+
923
+ // ---------------------------------------------------------------------------
924
+ // `__policy__` 记录读写(S1 未合并时的兼容写入;形状与 SPEC 冻结一致)
925
+ // ---------------------------------------------------------------------------
926
+
927
+ export async function appendSecurityRecord(
928
+ app: SecurityApp,
929
+ type: string,
930
+ key: string,
931
+ record: unknown,
932
+ ): Promise<{ id: string }> {
933
+ if (app.eventLog === undefined) throw new Error('core 未初始化:缺少 eventLog,无法写安全记录');
934
+ return app.eventLog.append({ type, data: { [key]: record }, namespace: POLICY_NAMESPACE });
935
+ }
936
+
937
+ export async function listPolicyEvents(app: SecurityApp): Promise<PolicyEvent[]> {
938
+ if (app.eventLog === undefined) return [];
939
+ return app.eventLog.listEvents({ namespace: POLICY_NAMESPACE });
940
+ }
941
+
942
+ export function normalizeSecurityLevel(value: unknown): SecurityLevel {
943
+ return value === 'medium' || value === 'high' ? value : 'low';
944
+ }
945
+
946
+ /** 读档位:缺 `getSecurityTier()` → low(S1 未合并的当前构建不炸)。 */
947
+ export async function readSecurityState(app: SecurityApp): Promise<SecurityStateView> {
948
+ if (typeof app.getSecurityTier === 'function') {
949
+ try {
950
+ const raw = await app.getSecurityTier();
951
+ if (raw !== null && typeof raw === 'object') {
952
+ const record = raw as Record<string, unknown>;
953
+ const admitted = Array.isArray(record.admitted)
954
+ ? record.admitted.filter((value): value is string => typeof value === 'string')
955
+ : [];
956
+ const rejections = Array.isArray(record.rejections)
957
+ ? record.rejections
958
+ .filter((value): value is Record<string, unknown> => value !== null && typeof value === 'object')
959
+ .map((value) => ({ eventId: String(value.eventId ?? ''), reason: String(value.reason ?? '') }))
960
+ : [];
961
+ return {
962
+ level: normalizeSecurityLevel(record.level),
963
+ ...(typeof record.changedAt === 'number' ? { changedAt: record.changedAt } : {}),
964
+ ...(typeof record.changedBy === 'string' ? { changedBy: record.changedBy } : {}),
965
+ admitted,
966
+ ...(typeof record.recoveryKeyId === 'string' ? { recoveryKeyId: record.recoveryKeyId } : {}),
967
+ rejections,
968
+ coreTierAvailable: true,
969
+ };
970
+ }
971
+ } catch {
972
+ // 读档失败按 low(fail-closed 的显示侧;无写放大)
973
+ }
974
+ }
975
+ return { level: 'low', admitted: [], rejections: [], coreTierAvailable: false };
976
+ }
977
+
978
+ export interface WriteTierInput {
979
+ level: SecurityLevel;
980
+ note?: string;
981
+ cosign?: CosignProof;
982
+ breakGlass?: BreakGlassProof;
983
+ nonce?: string;
984
+ }
985
+
986
+ export async function writeSecurityTier(app: SecurityApp, input: WriteTierInput): Promise<{ id?: string; level: SecurityLevel }> {
987
+ // 带证明的档位变更必须把 cosign/breakGlass 内嵌进记录;若 core 的 setSecurityTier 不接受
988
+ // 证明字段,直接按冻结形状落图(S1 的推导会验收)。
989
+ if (typeof app.setSecurityTier === 'function' && input.cosign === undefined && input.breakGlass === undefined) {
990
+ const result = await app.setSecurityTier({
991
+ level: input.level,
992
+ ...(input.note !== undefined ? { note: input.note } : {}),
993
+ ...(input.nonce !== undefined ? { nonce: input.nonce } : {}),
994
+ });
995
+ const id = result !== null && typeof result === 'object' && typeof (result as { id?: unknown }).id === 'string'
996
+ ? String((result as { id: string }).id)
997
+ : undefined;
998
+ return { ...(id !== undefined ? { id } : {}), level: input.level };
999
+ }
1000
+ const record: SecurityTierRecord = {
1001
+ level: input.level,
1002
+ nonce: input.nonce ?? input.breakGlass?.nonce ?? randomUUID(),
1003
+ issuedAt: Date.now(),
1004
+ ...(input.note !== undefined ? { note: input.note } : {}),
1005
+ ...(input.cosign !== undefined ? { cosign: input.cosign } : {}),
1006
+ ...(input.breakGlass !== undefined ? { breakGlass: input.breakGlass } : {}),
1007
+ };
1008
+ const event = await appendSecurityRecord(app, SECURITY_TIER_EVENT, 'securityTier', record);
1009
+ return { id: event.id, level: input.level };
1010
+ }
1011
+
1012
+ export interface WriteDeviceRevokeInput {
1013
+ subject: string;
1014
+ note?: string;
1015
+ /** kick 动作 nonce(high 档进入 digest;带证明时必填/可由 breakGlass 推导)。 */
1016
+ nonce?: string;
1017
+ cosign?: CosignProof;
1018
+ breakGlass?: BreakGlassProof;
1019
+ }
1020
+
1021
+ export async function writeDeviceRevoke(app: SecurityApp, input: WriteDeviceRevokeInput): Promise<{ id?: string }> {
1022
+ // 带证明的记录直接按 SPEC §3.1 落图(S1 的 revokeDevice 未必接受可选证明字段)。
1023
+ if (input.cosign !== undefined || input.breakGlass !== undefined) {
1024
+ const nonce = input.nonce ?? input.breakGlass?.nonce;
1025
+ const event = await appendSecurityRecord(app, 'device_revoke', 'deviceRevoke', {
1026
+ subject: input.subject,
1027
+ issuedAt: Date.now(),
1028
+ ...(nonce !== undefined ? { nonce } : {}),
1029
+ ...(input.note !== undefined ? { note: input.note } : {}),
1030
+ ...(input.cosign !== undefined ? { cosign: input.cosign } : {}),
1031
+ ...(input.breakGlass !== undefined ? { breakGlass: input.breakGlass } : {}),
1032
+ });
1033
+ return { id: event.id };
1034
+ }
1035
+ if (typeof app.revokeDevice === 'function') {
1036
+ const event = await app.revokeDevice({ subject: input.subject, ...(input.note !== undefined ? { note: input.note } : {}) });
1037
+ const id = event !== null && typeof event === 'object' && typeof (event as { id?: unknown }).id === 'string'
1038
+ ? String((event as { id: string }).id)
1039
+ : undefined;
1040
+ return { ...(id !== undefined ? { id } : {}) };
1041
+ }
1042
+ const event = await appendSecurityRecord(app, 'device_revoke', 'deviceRevoke', {
1043
+ subject: input.subject,
1044
+ issuedAt: Date.now(),
1045
+ ...(input.note !== undefined ? { note: input.note } : {}),
1046
+ });
1047
+ return { id: event.id };
1048
+ }
1049
+
1050
+ export interface WriteDeviceRestoreInput {
1051
+ subject: string;
1052
+ note?: string;
1053
+ nonce?: string;
1054
+ cosign?: CosignProof;
1055
+ breakGlass?: BreakGlassProof;
1056
+ }
1057
+
1058
+ /** 写 `device_restore`(恢复纪元由 core 推导;high 档证明随记录走)。 */
1059
+ export async function writeDeviceRestore(app: SecurityApp, input: WriteDeviceRestoreInput): Promise<{ id?: string }> {
1060
+ if (input.cosign !== undefined || input.breakGlass !== undefined) {
1061
+ const nonce = input.nonce ?? input.breakGlass?.nonce;
1062
+ const event = await appendSecurityRecord(app, 'device_restore', 'deviceRestore', {
1063
+ subject: input.subject,
1064
+ issuedAt: Date.now(),
1065
+ ...(nonce !== undefined ? { nonce } : {}),
1066
+ ...(input.note !== undefined ? { note: input.note } : {}),
1067
+ ...(input.cosign !== undefined ? { cosign: input.cosign } : {}),
1068
+ ...(input.breakGlass !== undefined ? { breakGlass: input.breakGlass } : {}),
1069
+ });
1070
+ return { id: event.id };
1071
+ }
1072
+ if (typeof app.restoreDevice === 'function') {
1073
+ const event = await app.restoreDevice({ subject: input.subject, ...(input.note !== undefined ? { note: input.note } : {}) });
1074
+ const id = event !== null && typeof event === 'object' && typeof (event as { id?: unknown }).id === 'string'
1075
+ ? String((event as { id: string }).id)
1076
+ : undefined;
1077
+ return { ...(id !== undefined ? { id } : {}) };
1078
+ }
1079
+ const event = await appendSecurityRecord(app, 'device_restore', 'deviceRestore', {
1080
+ subject: input.subject,
1081
+ issuedAt: Date.now(),
1082
+ ...(input.note !== undefined ? { note: input.note } : {}),
1083
+ });
1084
+ return { id: event.id };
1085
+ }
1086
+
1087
+ export async function writeSecurityApproval(app: SecurityApp, record: SecurityApprovalRecord): Promise<{ id: string }> {
1088
+ return appendSecurityRecord(app, SECURITY_APPROVAL_EVENT, 'securityApproval', record);
1089
+ }
1090
+
1091
+ export async function writeSecurityRecovery(app: SecurityApp, record: SecurityRecoveryRecord): Promise<{ id: string }> {
1092
+ return appendSecurityRecord(app, SECURITY_RECOVERY_EVENT, 'securityRecovery', record);
1093
+ }
1094
+
1095
+ export async function writeSecurityTotpAudit(app: SecurityApp, record: SecurityTotpRecord): Promise<{ id: string }> {
1096
+ return appendSecurityRecord(app, SECURITY_TOTP_EVENT, 'securityTotp', record);
1097
+ }
1098
+
1099
+ /** 最新恢复码登记(本地预检;权威在 core)。 */
1100
+ export function latestRecoveryRecord(events: readonly PolicyEvent[]): SecurityRecoveryRecord | null {
1101
+ let latest: SecurityRecoveryRecord | null = null;
1102
+ let latestAt = -1;
1103
+ for (const event of events) {
1104
+ if (event.type !== SECURITY_RECOVERY_EVENT) continue;
1105
+ const record = event.data?.securityRecovery as SecurityRecoveryRecord | undefined;
1106
+ if (record && typeof record.keyId === 'string') {
1107
+ const at = event.timestamp ?? 0;
1108
+ if (at >= latestAt) {
1109
+ latest = record;
1110
+ latestAt = at;
1111
+ }
1112
+ }
1113
+ }
1114
+ return latest;
1115
+ }
1116
+
1117
+ export function approvalsFromEvents(events: readonly PolicyEvent[]): SecurityApprovalRecord[] {
1118
+ return events
1119
+ .filter((event) => event.type === SECURITY_APPROVAL_EVENT)
1120
+ .map((event) => event.data?.securityApproval as SecurityApprovalRecord | undefined)
1121
+ .filter((record): record is SecurityApprovalRecord => record !== undefined && typeof record.approvalId === 'string');
1122
+ }
1123
+
1124
+ export function pendingApprovals(events: readonly PolicyEvent[], now = Date.now()): SecurityApprovalRecord[] {
1125
+ const all = approvalsFromEvents(events);
1126
+ const decided = new Set(all.filter((record) => record.decision !== 'request').map((record) => record.approvalId));
1127
+ return all.filter((record) => record.decision === 'request' && !decided.has(record.approvalId) && record.expiresAt > now);
1128
+ }
1129
+
1130
+ export function findApproval(events: readonly PolicyEvent[], approvalId: string): SecurityApprovalRecord | null {
1131
+ return approvalsFromEvents(events).find((record) => record.approvalId === approvalId && record.decision === 'approve') ?? null;
1132
+ }
1133
+
1134
+ // ---------------------------------------------------------------------------
1135
+ // 本机门禁(CLI 与 serve 共用)
1136
+ // ---------------------------------------------------------------------------
1137
+
1138
+ export interface SecurityGateResult {
1139
+ ok: boolean;
1140
+ status?: number;
1141
+ error?: string;
1142
+ message?: string;
1143
+ retryAfterMs?: number;
1144
+ }
1145
+
1146
+ export interface SecurityGateInput {
1147
+ action: 'invite' | 'revoke' | 'tier' | 'restore';
1148
+ level?: SecurityLevel;
1149
+ code?: string;
1150
+ cosign?: CosignProof;
1151
+ breakGlass?: BreakGlassProof;
1152
+ subject?: string;
1153
+ namespace?: string;
1154
+ approvalId?: string;
1155
+ now?: number;
1156
+ /** 测试注入:TOTP 失败退避延时;生产不传(真实 setTimeout)。 */
1157
+ sleep?: (ms: number) => Promise<void>;
1158
+ }
1159
+
1160
+ /**
1161
+ * 本机程序门槛(SPEC §2.5 / §1.4):
1162
+ * - medium:invite/revoke/档位变更(除 medium→medium)需要动态码;
1163
+ * - high:invite/revoke/档位变更(除 high→high)需要会签或恢复码;
1164
+ * - low:无门槛。S1 未合并(getSecurityTier 缺失)→ low。
1165
+ */
1166
+ export async function evaluateSecurityGate(
1167
+ app: SecurityApp,
1168
+ dir: string,
1169
+ input: SecurityGateInput,
1170
+ ): Promise<SecurityGateResult> {
1171
+ const state = await readSecurityState(app);
1172
+ const level = input.level;
1173
+ if (state.level === 'medium') {
1174
+ if (input.action === 'tier' && level === 'medium') return { ok: true };
1175
+ if (input.code === undefined || input.code.trim().length === 0) {
1176
+ return {
1177
+ ok: false,
1178
+ status: 403,
1179
+ error: 'totp_required',
1180
+ message: '中档(动态码)要求输入 6 位动态码:请在请求体提供 code',
1181
+ };
1182
+ }
1183
+ const seed = await readTotpSeed(dir).catch(() => null);
1184
+ if (seed === null) {
1185
+ return {
1186
+ ok: false,
1187
+ status: 403,
1188
+ error: 'totp_not_configured',
1189
+ message: '本机尚未导入动态码种子:请从一台已有设备导入后再试',
1190
+ };
1191
+ }
1192
+ const result = await verifyTotp(seed.secret, input.code, input.now ?? Date.now(), {
1193
+ dir,
1194
+ ...(input.sleep !== undefined ? { sleep: input.sleep } : {}),
1195
+ });
1196
+ if (!result.ok) {
1197
+ const status = result.reason === 'locked' ? 429 : 403;
1198
+ const reason = result.reason === 'locked'
1199
+ ? `动态码已锁定:请在 ${Math.ceil((result.retryAfterMs ?? 0) / 1000)} 秒后重试`
1200
+ : result.reason === 'replay'
1201
+ ? '动态码已使用过(重放被拒):请等待下一个 30 秒窗口'
1202
+ : '动态码无效:请检查设备时间后重试';
1203
+ return {
1204
+ ok: false,
1205
+ status,
1206
+ error: result.reason === 'locked' ? 'totp_locked' : result.reason === 'replay' ? 'totp_replayed' : 'totp_invalid',
1207
+ message: reason,
1208
+ ...(result.retryAfterMs !== undefined ? { retryAfterMs: result.retryAfterMs } : {}),
1209
+ };
1210
+ }
1211
+ return { ok: true };
1212
+ }
1213
+
1214
+ if (state.level === 'high') {
1215
+ if (input.action === 'tier' && level === 'high') return { ok: true };
1216
+ if (input.breakGlass !== undefined) {
1217
+ const events = await listPolicyEvents(app);
1218
+ const registry = collectRecoveryRegistry(events);
1219
+ const key = input.breakGlass.keyId !== undefined ? registry.keys[input.breakGlass.keyId] : undefined;
1220
+ if (key === undefined || registry.currentKeyId !== input.breakGlass.keyId) {
1221
+ return { ok: false, status: 403, error: 'proof_invalid', message: '恢复码无效或已被轮换:请使用当前恢复码' };
1222
+ }
1223
+ let expectedDigest: string | undefined;
1224
+ if (input.action === 'revoke' && input.subject !== undefined) {
1225
+ expectedDigest = securityActionDigest({
1226
+ op: 'kick',
1227
+ initiator: app.deviceId ?? '',
1228
+ subject: input.subject,
1229
+ nonce: input.breakGlass.nonce,
1230
+ });
1231
+ } else if (input.action === 'invite' && input.namespace !== undefined) {
1232
+ expectedDigest = securityActionDigest({
1233
+ op: 'join',
1234
+ initiator: app.deviceId ?? '',
1235
+ namespace: input.namespace,
1236
+ nonce: input.breakGlass.nonce,
1237
+ });
1238
+ } else if (input.action === 'tier' && input.level !== undefined) {
1239
+ expectedDigest = securityActionDigest({
1240
+ op: 'tier',
1241
+ initiator: app.deviceId ?? '',
1242
+ level: input.level,
1243
+ nonce: input.breakGlass.nonce,
1244
+ });
1245
+ } else if (input.action === 'restore' && input.subject !== undefined) {
1246
+ expectedDigest = securityActionDigest({
1247
+ op: 'restore',
1248
+ initiator: app.deviceId ?? '',
1249
+ subject: input.subject,
1250
+ nonce: input.breakGlass.nonce,
1251
+ });
1252
+ }
1253
+ const check = verifyBreakGlassProof(input.breakGlass, {
1254
+ publicKeyHex: key.publicKeyHex,
1255
+ ...(expectedDigest !== undefined ? { expectedDigest } : {}),
1256
+ usedNonces: registry.usedNonces,
1257
+ currentKeyId: registry.currentKeyId,
1258
+ });
1259
+ if (!check.ok) {
1260
+ return {
1261
+ ok: false,
1262
+ status: 403,
1263
+ error: check.reason === 'stale-or-replayed-nonce' ? 'proof_replayed' : 'proof_invalid',
1264
+ message: check.reason === 'stale-or-replayed-nonce' ? '恢复码 nonce 已使用(重放被拒)' : '恢复码签名无效',
1265
+ };
1266
+ }
1267
+ return { ok: true };
1268
+ }
1269
+ if (input.approvalId !== undefined && input.approvalId.length > 0) {
1270
+ const events = await listPolicyEvents(app);
1271
+ const approval = findApproval(events, input.approvalId);
1272
+ if (approval === null) {
1273
+ return { ok: false, status: 403, error: 'approval_missing', message: '会签批准记录不存在或未生效' };
1274
+ }
1275
+ const expectedOp = input.action === 'invite'
1276
+ ? 'join'
1277
+ : input.action === 'revoke'
1278
+ ? 'kick'
1279
+ : input.action === 'restore'
1280
+ ? 'restore'
1281
+ : 'tier';
1282
+ if (approval.op !== expectedOp || approval.initiator !== (app.deviceId ?? '')) {
1283
+ return { ok: false, status: 403, error: 'approval_invalid', message: '批准记录与本次动作不匹配(op/发起设备)' };
1284
+ }
1285
+ if ((input.action === 'revoke' || input.action === 'restore') && approval.target !== input.subject) {
1286
+ return { ok: false, status: 403, error: 'approval_invalid', message: '批准记录的对象与实际设备不一致' };
1287
+ }
1288
+ if (input.action === 'tier' && input.level !== undefined && approval.level !== input.level) {
1289
+ return { ok: false, status: 403, error: 'approval_invalid', message: '批准记录的档位与实际要变更的档位不一致' };
1290
+ }
1291
+ if (input.action === 'invite' && input.namespace !== undefined && approval.namespace !== input.namespace) {
1292
+ return { ok: false, status: 403, error: 'approval_invalid', message: '批准记录的分区与实际邀请分区不一致' };
1293
+ }
1294
+ const expectedDigest = securityActionDigest({
1295
+ op: approval.op,
1296
+ initiator: approval.initiator,
1297
+ ...(approval.op === 'kick' || approval.op === 'restore' ? { subject: approval.target ?? '' } : {}),
1298
+ ...(approval.op === 'join' ? { namespace: approval.namespace ?? '' } : {}),
1299
+ ...(approval.op === 'tier' ? { level: approval.level ?? 'low' } : {}),
1300
+ nonce: approval.nonce,
1301
+ });
1302
+ if (expectedDigest !== approval.digest) {
1303
+ return { ok: false, status: 403, error: 'approval_invalid', message: '批准记录的 digest 与动作摘要不一致' };
1304
+ }
1305
+ if (approval.cosign !== undefined) {
1306
+ const leaf = approval.cosign.approverCertificateChain?.[0];
1307
+ if (leaf !== undefined && typeof leaf.devicePublicKey === 'string') {
1308
+ const ok = await verifyCosignProofSignature(approval.cosign, leaf.devicePublicKey);
1309
+ if (!ok) return { ok: false, status: 403, error: 'approval_invalid', message: '批准记录的会签签名无效' };
1310
+ }
1311
+ if (approval.cosign.approver === approval.initiator) {
1312
+ return { ok: false, status: 403, error: 'approval_invalid', message: '发起设备不能批准自己的动作' };
1313
+ }
1314
+ if ((input.action === 'revoke' || input.action === 'restore') && approval.cosign.approver === input.subject) {
1315
+ return { ok: false, status: 403, error: 'approval_invalid', message: '目标设备不能批准对自己的动作' };
1316
+ }
1317
+ if (state.admitted.length > 0 && !state.admitted.includes(approval.cosign.approver)) {
1318
+ return { ok: false, status: 403, error: 'approver_not_admitted', message: '批准设备尚未准入(admitted)' };
1319
+ }
1320
+ }
1321
+ return { ok: true };
1322
+ }
1323
+ if (input.cosign !== undefined) {
1324
+ const events = await listPolicyEvents(app);
1325
+ const approval = findApproval(events, input.cosign.approvalId);
1326
+ if (approval === null || approval.cosign === undefined) {
1327
+ return { ok: false, status: 403, error: 'approval_missing', message: '会签批准记录不存在或未生效' };
1328
+ }
1329
+ if (approval.digest !== input.cosign.digest || approval.cosign.signature !== input.cosign.signature) {
1330
+ return { ok: false, status: 403, error: 'proof_invalid', message: '会签证明与批准记录不一致' };
1331
+ }
1332
+ if (approval.initiator === input.cosign.approver) {
1333
+ return { ok: false, status: 403, error: 'proof_invalid', message: '发起设备不能批准自己的动作' };
1334
+ }
1335
+ const leaf = input.cosign.approverCertificateChain?.[0];
1336
+ if (leaf !== undefined && typeof leaf.devicePublicKey === 'string') {
1337
+ const signatureOk = await verifyCosignProofSignature(input.cosign, leaf.devicePublicKey);
1338
+ if (!signatureOk) {
1339
+ return { ok: false, status: 403, error: 'proof_invalid', message: '会签签名无效' };
1340
+ }
1341
+ }
1342
+ if (input.action === 'revoke' && input.cosign.approver === input.subject) {
1343
+ return { ok: false, status: 403, error: 'proof_invalid', message: '被踢设备不能批准对自己的动作' };
1344
+ }
1345
+ if (state.admitted.length > 0 && !state.admitted.includes(input.cosign.approver)) {
1346
+ return { ok: false, status: 403, error: 'approver_not_admitted', message: '批准设备尚未准入(admitted)' };
1347
+ }
1348
+ return { ok: true };
1349
+ }
1350
+ return {
1351
+ ok: false,
1352
+ status: 403,
1353
+ error: 'approval_required',
1354
+ message: '高档(设备会签)要求另一台已受准设备批准,或使用离线恢复码',
1355
+ };
1356
+ }
1357
+
1358
+ return { ok: true };
1359
+ }
1360
+
1361
+ // ---------------------------------------------------------------------------
1362
+ // 主密钥备份 / 恢复(§4)
1363
+ // ---------------------------------------------------------------------------
1364
+
1365
+ export const BACKUP_KIND = 'mebular-master-key-backup';
1366
+ export const BACKUP_SCRYPT_N = 32_768;
1367
+ export const BACKUP_SCRYPT_R = 8;
1368
+ export const BACKUP_SCRYPT_P = 1;
1369
+
1370
+ export interface MasterKeyBackupFile {
1371
+ v: 1;
1372
+ kind: typeof BACKUP_KIND;
1373
+ createdAt: number;
1374
+ deviceId: string;
1375
+ masterPublicKeyHex: string;
1376
+ kdf: { name: 'scrypt'; N: number; r: number; p: number; salt: string };
1377
+ cipher: { name: 'AES-256-GCM'; iv: string; ciphertext: string; tag: string };
1378
+ }
1379
+
1380
+ export interface MasterKeyBackupPayload {
1381
+ masterPrivateKeyPkcs8: string;
1382
+ masterPublicKeyHex: string;
1383
+ createdAt: number;
1384
+ deviceId: string;
1385
+ note?: string;
1386
+ }
1387
+
1388
+ export interface ExportBackupInput {
1389
+ dir: string;
1390
+ /** 备份码;缺省生成一个新码(调用方必须一次性显示)。 */
1391
+ code?: string;
1392
+ note?: string;
1393
+ now?: number;
1394
+ }
1395
+
1396
+ export interface ExportBackupResult {
1397
+ file: MasterKeyBackupFile;
1398
+ code: string;
1399
+ codeGenerated: boolean;
1400
+ fingerprint: string;
1401
+ deviceId: string;
1402
+ }
1403
+
1404
+ interface ResolvedHomeKey {
1405
+ keyPath: string;
1406
+ deviceId: string;
1407
+ encryption: Awaited<ReturnType<typeof readMasterKeyFile>>;
1408
+ }
1409
+
1410
+ /** 解析 home 的主密钥路径(fleet home / 守护 home / 空 home)。 */
1411
+ export async function resolveHomeMasterKey(dir: string): Promise<ResolvedHomeKey> {
1412
+ const fleetPath = fleetConfigPath(dir);
1413
+ if (existsSync(fleetPath)) {
1414
+ const config = await loadFleetConfig(fleetPath);
1415
+ return {
1416
+ keyPath: config.masterKeyFile,
1417
+ deviceId: config.device,
1418
+ encryption: await readMasterKeyFile(config.masterKeyFile),
1419
+ };
1420
+ }
1421
+ const daemonPath = join(dir, 'config.json');
1422
+ if (existsSync(daemonPath)) {
1423
+ const config = JSON.parse(await readFile(daemonPath, 'utf-8')) as Record<string, any>;
1424
+ const encryption = (config.encryption ?? {}) as Record<string, unknown>;
1425
+ const keyPath = typeof encryption.userMasterPublicKeyFile === 'string' && encryption.userMasterPublicKeyFile.length > 0
1426
+ ? encryption.userMasterPublicKeyFile
1427
+ : typeof encryption.keyFile === 'string' && encryption.keyFile.length > 0
1428
+ ? encryption.keyFile
1429
+ : join(dir, 'user-master-key.json');
1430
+ return {
1431
+ keyPath,
1432
+ deviceId: typeof config.deviceId === 'string' ? config.deviceId : `device-${process.pid}`,
1433
+ encryption: await readMasterKeyFile(keyPath),
1434
+ };
1435
+ }
1436
+ const candidates = [join(dir, 'master-key.json'), join(dir, 'user-master-key.json')];
1437
+ for (const keyPath of candidates) {
1438
+ if (existsSync(keyPath)) {
1439
+ return { keyPath, deviceId: `device-${process.pid}`, encryption: await readMasterKeyFile(keyPath) };
1440
+ }
1441
+ }
1442
+ throw new Error(`未找到主密钥文件(${candidates.join(' / ')})`);
1443
+ }
1444
+
1445
+ function scryptKey(secret: Uint8Array, salt: Buffer): Buffer {
1446
+ return scryptSync(Buffer.from(secret), salt, 32, {
1447
+ N: BACKUP_SCRYPT_N,
1448
+ r: BACKUP_SCRYPT_R,
1449
+ p: BACKUP_SCRYPT_P,
1450
+ maxmem: 128 * 1024 * 1024,
1451
+ });
1452
+ }
1453
+
1454
+ /** 导出主密钥备份(仅 root;委派设备无主私钥 → 明确失败)。 */
1455
+ export async function exportMasterKeyBackup(input: ExportBackupInput): Promise<ExportBackupResult> {
1456
+ const resolved = await resolveHomeMasterKey(input.dir);
1457
+ if (resolved.encryption.userMasterPrivateKey === undefined) {
1458
+ throw new Error('仅 root(持有主密钥私钥的设备)可导出备份;本机是委派设备(无主私钥)');
1459
+ }
1460
+ const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', resolved.encryption.userMasterPrivateKey));
1461
+ const codeGenerated = input.code === undefined;
1462
+ const code = input.code ?? generateRecoveryCode().code;
1463
+ const secret = decodeRecoveryCode(code);
1464
+ const createdAt = input.now ?? Date.now();
1465
+ const payload: MasterKeyBackupPayload = {
1466
+ masterPrivateKeyPkcs8: Buffer.from(pkcs8).toString('base64'),
1467
+ masterPublicKeyHex: Buffer.from(resolved.encryption.userMasterKey).toString('hex'),
1468
+ createdAt,
1469
+ deviceId: resolved.deviceId,
1470
+ ...(input.note !== undefined ? { note: input.note } : {}),
1471
+ };
1472
+ const salt = randomBytes(16);
1473
+ const iv = randomBytes(12);
1474
+ const key = scryptKey(secret, salt);
1475
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
1476
+ const ciphertext = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf-8'), cipher.final()]);
1477
+ const tag = cipher.getAuthTag();
1478
+ const file: MasterKeyBackupFile = {
1479
+ v: 1,
1480
+ kind: BACKUP_KIND,
1481
+ createdAt,
1482
+ deviceId: resolved.deviceId,
1483
+ masterPublicKeyHex: payload.masterPublicKeyHex,
1484
+ kdf: { name: 'scrypt', N: BACKUP_SCRYPT_N, r: BACKUP_SCRYPT_R, p: BACKUP_SCRYPT_P, salt: salt.toString('base64') },
1485
+ cipher: {
1486
+ name: 'AES-256-GCM',
1487
+ iv: iv.toString('base64'),
1488
+ ciphertext: ciphertext.toString('base64'),
1489
+ tag: tag.toString('base64'),
1490
+ },
1491
+ };
1492
+ return {
1493
+ file,
1494
+ code,
1495
+ codeGenerated,
1496
+ fingerprint: masterKeyFingerprint(resolved.encryption.userMasterKey),
1497
+ deviceId: resolved.deviceId,
1498
+ };
1499
+ }
1500
+
1501
+ export async function writeBackupFile(path: string, file: MasterKeyBackupFile): Promise<void> {
1502
+ await writePrivateFile(path, JSON.stringify(file, null, 2));
1503
+ }
1504
+
1505
+ export interface RestoreBackupInput {
1506
+ file: string | MasterKeyBackupFile;
1507
+ code: string;
1508
+ dir: string;
1509
+ keyPath?: string;
1510
+ device?: string;
1511
+ namespace?: string;
1512
+ listen?: string;
1513
+ agents?: FleetAgentConfig[];
1514
+ }
1515
+
1516
+ export interface RestoreBackupResult {
1517
+ fingerprint: string;
1518
+ masterPublicKeyHex: string;
1519
+ deviceId: string;
1520
+ keyPath: string;
1521
+ configPath?: string;
1522
+ onboarded: boolean;
1523
+ }
1524
+
1525
+ function parseBackupFile(input: string | MasterKeyBackupFile): MasterKeyBackupFile {
1526
+ const file = typeof input === 'string' ? JSON.parse(input) as MasterKeyBackupFile : input;
1527
+ if (file === null || typeof file !== 'object' || file.kind !== BACKUP_KIND || file.v !== 1) {
1528
+ throw new Error('备份文件形状非法(kind/v 不匹配)');
1529
+ }
1530
+ if (file.kdf?.name !== 'scrypt' || file.cipher?.name !== 'AES-256-GCM') {
1531
+ throw new Error('备份文件算法不支持');
1532
+ }
1533
+ return file;
1534
+ }
1535
+
1536
+ /**
1537
+ * 从备份恢复主密钥。**先解密/验公钥,再写盘**;失败不留半成品。
1538
+ * `device` 给出时顺带 `onboardDevice`(写 fleet 配置 + 设备证书,成为新 root)。
1539
+ */
1540
+ export async function restoreMasterKeyBackup(input: RestoreBackupInput): Promise<RestoreBackupResult> {
1541
+ const file = parseBackupFile(input.file);
1542
+ const secret = decodeRecoveryCode(input.code);
1543
+ let payload: MasterKeyBackupPayload;
1544
+ try {
1545
+ const key = scryptKey(secret, Buffer.from(file.kdf.salt, 'base64'));
1546
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(file.cipher.iv, 'base64'));
1547
+ decipher.setAuthTag(Buffer.from(file.cipher.tag, 'base64'));
1548
+ const plain = Buffer.concat([decipher.update(Buffer.from(file.cipher.ciphertext, 'base64')), decipher.final()]);
1549
+ payload = JSON.parse(plain.toString('utf-8')) as MasterKeyBackupPayload;
1550
+ } catch {
1551
+ throw new Error('恢复失败:备份码错误或文件被篡改');
1552
+ }
1553
+ if (typeof payload.masterPrivateKeyPkcs8 !== 'string' || typeof payload.masterPublicKeyHex !== 'string') {
1554
+ throw new Error('恢复失败:备份载荷形状非法');
1555
+ }
1556
+ if (payload.masterPublicKeyHex !== file.masterPublicKeyHex) {
1557
+ throw new Error('恢复失败:主公钥指纹不一致(文件被篡改)');
1558
+ }
1559
+ const privateKey = await crypto.subtle.importKey(
1560
+ 'pkcs8',
1561
+ Buffer.from(payload.masterPrivateKeyPkcs8, 'base64'),
1562
+ { name: 'Ed25519' },
1563
+ true,
1564
+ ['sign'],
1565
+ );
1566
+ // 从私钥导出公钥,确保配对一致(私钥/公钥不匹配 → 拒绝)
1567
+ const derivedPublic = await exportEd25519PublicFromPrivate(privateKey);
1568
+ if (Buffer.from(derivedPublic).toString('hex') !== payload.masterPublicKeyHex) {
1569
+ throw new Error('恢复失败:私钥与主公钥不匹配');
1570
+ }
1571
+
1572
+ const keyPath = input.keyPath ?? join(input.dir, 'user-master-key.json');
1573
+ const record = {
1574
+ v: 1,
1575
+ alg: 'Ed25519',
1576
+ publicKey: Buffer.from(derivedPublic).toString('base64'),
1577
+ privateKeyPkcs8: payload.masterPrivateKeyPkcs8,
1578
+ };
1579
+ const keyExisted = existsSync(keyPath);
1580
+ const created: string[] = [];
1581
+ try {
1582
+ await writePrivateFile(keyPath, JSON.stringify(record));
1583
+ if (!keyExisted) created.push(keyPath);
1584
+ let configPath: string | undefined;
1585
+ if (input.device !== undefined) {
1586
+ const onboard = await onboardDevice({
1587
+ dir: input.dir,
1588
+ device: input.device,
1589
+ masterKeyFile: keyPath,
1590
+ ...(input.namespace !== undefined ? { namespace: input.namespace } : {}),
1591
+ ...(input.listen !== undefined ? { listen: input.listen } : {}),
1592
+ ...(input.agents !== undefined ? { agents: input.agents } : {}),
1593
+ });
1594
+ configPath = fleetConfigPath(input.dir);
1595
+ created.push(configPath, `${onboard.config.storagePath}.identity.json`);
1596
+ }
1597
+ return {
1598
+ fingerprint: masterKeyFingerprint(derivedPublic),
1599
+ masterPublicKeyHex: payload.masterPublicKeyHex,
1600
+ deviceId: input.device ?? payload.deviceId,
1601
+ keyPath,
1602
+ ...(configPath !== undefined ? { configPath } : {}),
1603
+ onboarded: input.device !== undefined,
1604
+ };
1605
+ } catch (error) {
1606
+ // 失败不残留:只清理本次新建的文件
1607
+ for (const path of created.reverse()) await rm(path, { force: true }).catch(() => undefined);
1608
+ if (!keyExisted) await rm(keyPath, { force: true }).catch(() => undefined);
1609
+ throw error;
1610
+ }
1611
+ }
1612
+
1613
+ /** 从 CryptoKey 私钥导出 raw 公钥(Node WebCrypto 无 direct public export;经 PKCS8→KeyObject)。 */
1614
+ async function exportEd25519PublicFromPrivate(privateKey: CryptoKey): Promise<Uint8Array> {
1615
+ const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', privateKey));
1616
+ const nodeKey = createPrivateKey({ key: Buffer.from(pkcs8), format: 'der', type: 'pkcs8' });
1617
+ const publicKey = createPublicKey(nodeKey);
1618
+ const spki = publicKey.export({ format: 'der', type: 'spki' });
1619
+ return new Uint8Array(spki.subarray(spki.length - 32));
1620
+ }
1621
+
1622
+ // ---------------------------------------------------------------------------
1623
+ // 分发展示(QR 编码失败 → 分组文本降级)
1624
+ // ---------------------------------------------------------------------------
1625
+
1626
+ export interface TotpShareResult {
1627
+ secret: string;
1628
+ fingerprint: string;
1629
+ otpauth: string;
1630
+ groups: string;
1631
+ qrText?: string;
1632
+ }
1633
+
1634
+ export interface TotpShareOptions {
1635
+ qr?: boolean;
1636
+ }
1637
+
1638
+ export async function renderTotpShare(
1639
+ secret: string,
1640
+ deviceId: string,
1641
+ options: TotpShareOptions = {},
1642
+ ): Promise<TotpShareResult> {
1643
+ const otpauth = totpOtpauthUri(secret, deviceId);
1644
+ const groups = secret.replace(/(.{4})/g, '$1 ').trim();
1645
+ const result: TotpShareResult = { secret, fingerprint: totpFingerprint(secret), otpauth, groups };
1646
+ if (options.qr === false) return result;
1647
+ const qr = await renderTerminalQr(otpauth).catch(() => null);
1648
+ if (qr !== null) result.qrText = qr.value;
1649
+ return result;
1650
+ }
1651
+
1652
+ export async function renderTotpShareSvg(
1653
+ secret: string,
1654
+ deviceId: string,
1655
+ options: TotpShareOptions = {},
1656
+ ): Promise<string | null> {
1657
+ if (options.qr === false) return null;
1658
+ const qr = await renderSvgQr(totpOtpauthUri(secret, deviceId)).catch(() => null);
1659
+ return qr === null ? null : qr.value;
1660
+ }