@yezack/dsh-remote-mobile 1.9.2 → 1.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,408 +11,411 @@ export function getLoginPageHtml(publicKeyPem, lang = 'zh') {
11
11
  headerDesc: isEn ? 'Enter 6-digit dynamic pairing code or persistent password' : '请输入 6 位动态配对码或长期访问密码',
12
12
  placeholder: isEn ? '6-digit code or password' : '6 位临时配对码 / 长期密码',
13
13
  submitBtn: isEn ? 'Authorize & Connect' : '立即授权连接',
14
- footerSecurity: isEn ? '🔒 Protected by 2048-bit RSA encryption' : '🔒 全程受 2048 位 RSA 加密保护',
14
+ footerSecurity: isEn ? '🔒 Login credentials encrypted with 2048-bit RSA' : '🔒 登录凭证使用 2048 位 RSA 加密',
15
15
  verifying: isEn ? '🔐 Encrypting & verifying...' : '🔐 正在 RSA 加密与验证...',
16
16
  success: isEn ? 'Authorization successful! Entering workspace...' : '授权成功!正在进入工作区...',
17
17
  networkErr: isEn ? 'Network connection error, please try again' : '网络连接异常,请检查网络',
18
+ cryptoErr: isEn
19
+ ? 'Secure encryption is unavailable. Refresh the page or use another browser.'
20
+ : '当前环境无法安全加密,请刷新页面或更换浏览器。',
18
21
  };
19
- return `<!DOCTYPE html>
20
- <html lang="${isEn ? 'en' : 'zh-CN'}">
21
- <head>
22
- <meta charset="UTF-8">
23
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
24
- <title>${i18n.pageTitle}</title>
25
- <style>
26
- :root {
27
- --bg: #0f1117;
28
- --card-bg: rgba(26, 29, 39, 0.85);
29
- --card-border: rgba(255, 255, 255, 0.08);
30
- --primary: #3b82f6;
31
- --primary-hover: #2563eb;
32
- --text: #f3f4f6;
33
- --text-muted: #9ca3af;
34
- --error: #ef4444;
35
- --success: #10b981;
36
- }
37
- @media (prefers-color-scheme: light) {
38
- :root {
39
- --bg: #f3f4f6;
40
- --card-bg: rgba(255, 255, 255, 0.9);
41
- --card-border: rgba(0, 0, 0, 0.08);
42
- --text: #111827;
43
- --text-muted: #6b7280;
44
- }
45
- }
46
- * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
47
- body {
48
- background: var(--bg);
49
- color: var(--text);
50
- min-height: 100vh;
51
- display: flex;
52
- align-items: center;
53
- justify-content: center;
54
- padding: 20px;
55
- }
56
- .auth-card {
57
- width: 100%;
58
- max-width: 380px;
59
- background: var(--card-bg);
60
- backdrop-filter: blur(16px);
61
- -webkit-backdrop-filter: blur(16px);
62
- border: 1px solid var(--card-border);
63
- border-radius: 20px;
64
- padding: 32px 24px;
65
- box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
66
- text-align: center;
67
- }
68
- .logo-badge {
69
- display: inline-flex;
70
- align-items: center;
71
- justify-content: center;
72
- width: 56px;
73
- height: 56px;
74
- background: linear-gradient(135deg, #3b82f6, #8b5cf6);
75
- border-radius: 16px;
76
- margin-bottom: 20px;
77
- color: white;
78
- box-shadow: 0 8px 16px rgba(59, 130, 246, 0.3);
79
- }
80
- h1 {
81
- font-size: 20px;
82
- font-weight: 700;
83
- margin-bottom: 8px;
84
- }
85
- p.desc {
86
- font-size: 13px;
87
- color: var(--text-muted);
88
- line-height: 1.5;
89
- margin-bottom: 24px;
90
- }
91
- .input-group {
92
- margin-bottom: 16px;
93
- }
94
- input {
95
- width: 100%;
96
- height: 48px;
97
- background: rgba(128, 128, 128, 0.08);
98
- border: 1px solid var(--card-border);
99
- border-radius: 12px;
100
- padding: 0 16px;
101
- font-size: 16px;
102
- color: var(--text);
103
- outline: none;
104
- text-align: center;
105
- letter-spacing: 2px;
106
- transition: all 0.2s;
107
- }
108
- input:focus {
109
- border-color: var(--primary);
110
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
111
- }
112
- button.submit-btn {
113
- width: 100%;
114
- height: 48px;
115
- background: var(--primary);
116
- color: white;
117
- border: none;
118
- border-radius: 12px;
119
- font-size: 15px;
120
- font-weight: 600;
121
- cursor: pointer;
122
- transition: background 0.2s;
123
- display: flex;
124
- align-items: center;
125
- justify-content: center;
126
- gap: 8px;
127
- }
128
- button.submit-btn:active {
129
- background: var(--primary-hover);
130
- transform: scale(0.99);
131
- }
132
- .status-msg {
133
- margin-top: 16px;
134
- font-size: 13px;
135
- display: none;
136
- }
137
- .status-msg.error {
138
- color: var(--error);
139
- display: block;
140
- }
141
- .status-msg.success {
142
- color: var(--success);
143
- display: block;
144
- }
145
- .info-footer {
146
- margin-top: 24px;
147
- padding-top: 16px;
148
- border-top: 1px solid var(--card-border);
149
- font-size: 12px;
150
- color: var(--text-muted);
151
- line-height: 1.6;
152
- text-align: left;
153
- }
154
- </style>
155
- </head>
156
- <body>
157
- <div class="auth-card">
158
- <div class="logo-badge">
159
- <svg style="width: 28px; height: 28px; fill: currentColor;" viewBox="0 0 24 24">
160
- <path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/>
161
- </svg>
162
- </div>
163
- <h1>${i18n.headerTitle}</h1>
164
- <p class="desc">${i18n.headerDesc}</p>
165
-
166
- <form id="authForm">
167
- <div class="input-group">
168
- <input
169
- type="password"
170
- id="credentialInput"
171
- placeholder="${i18n.placeholder}"
172
- autocomplete="off"
173
- required
174
- />
175
- </div>
176
- <button type="submit" class="submit-btn" id="submitBtn">
177
- ${i18n.submitBtn}
178
- </button>
179
- <div id="statusMsg" class="status-msg"></div>
180
- </form>
181
-
182
- <div class="info-footer">
183
- <div>${i18n.footerSecurity}</div>
184
- </div>
185
- </div>
186
-
187
- <script>
188
- const SERVER_RSA_KEY = ${JSON.stringify(inlinePublicKey)};
189
- const form = document.getElementById('authForm');
190
- const input = document.getElementById('credentialInput');
191
- const statusMsg = document.getElementById('statusMsg');
192
- const submitBtn = document.getElementById('submitBtn');
193
-
194
- // 解析 SPKI 格式公钥并提取 n 和 e(用于纯 JS 垫片计算)
195
- function parseSpkiKey(pemStr) {
196
- const b64 = pemStr.replace(/-----BEGIN PUBLIC KEY-----/g, '').replace(/-----END PUBLIC KEY-----/g, '').replace(/\\s/g, '');
197
- const binary = window.atob(b64);
198
- const bytes = new Uint8Array(binary.length);
199
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
200
-
201
- let idx = 0;
202
- while (idx < bytes.length && bytes[idx] !== 0x02) idx++;
203
- const lenByte = bytes[idx + 1];
204
- let nLen = 0;
205
- let nStart = 0;
206
- if (lenByte === 0x82) {
207
- nLen = (bytes[idx + 2] << 8) | bytes[idx + 3];
208
- nStart = idx + 4;
209
- } else if (lenByte === 0x81) {
210
- nLen = bytes[idx + 2];
211
- nStart = idx + 3;
212
- } else {
213
- nLen = lenByte;
214
- nStart = idx + 2;
215
- }
216
- let nBytes = bytes.slice(nStart, nStart + nLen);
217
- if (nBytes[0] === 0x00) nBytes = nBytes.slice(1);
218
-
219
- idx = nStart + nLen;
220
- while (idx < bytes.length && bytes[idx] !== 0x02) idx++;
221
- const eLen = bytes[idx + 1];
222
- const eBytes = bytes.slice(idx + 2, idx + 2 + eLen);
223
-
224
- let nHex = '';
225
- for (let b of nBytes) nHex += b.toString(16).padStart(2, '0');
226
- let eHex = '';
227
- for (let b of eBytes) eHex += b.toString(16).padStart(2, '0');
228
-
229
- return { n: BigInt('0x' + nHex), e: BigInt('0x' + eHex), keyLen: nBytes.length };
230
- }
231
-
232
- function modPow(b, exp, mod) {
233
- let res = 1n;
234
- let base = b % mod;
235
- let e = exp;
236
- while (e > 0n) {
237
- if (e & 1n) res = (res * base) % mod;
238
- base = (base * base) % mod;
239
- e >>= 1n;
240
- }
241
- return res;
242
- }
243
-
244
- // 纯 JS RSA 垫片加密:使用 CSPRNG (crypto.getRandomValues) 填充
245
- function rsaEncryptPureJs(plainText, pubPem) {
246
- const { n, e, keyLen } = parseSpkiKey(pubPem);
247
- const msgBytes = new TextEncoder().encode(plainText);
248
- const padLen = keyLen - 3 - msgBytes.length;
249
- if (padLen < 8) throw new Error('Message too long');
250
-
251
- const pad = new Uint8Array(padLen);
252
- if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
253
- window.crypto.getRandomValues(pad);
254
- for (let i = 0; i < padLen; i++) {
255
- if (pad[i] === 0) pad[i] = (Math.floor(Math.random() * 254) + 1);
256
- }
257
- } else {
258
- for (let i = 0; i < padLen; i++) {
259
- pad[i] = Math.floor(Math.random() * 255) + 1;
260
- }
261
- }
262
-
263
- const em = new Uint8Array(keyLen);
264
- em[0] = 0x00;
265
- em[1] = 0x02;
266
- em.set(pad, 2);
267
- em[2 + padLen] = 0x00;
268
- em.set(msgBytes, 3 + padLen);
269
-
270
- let emHex = '';
271
- for (let b of em) emHex += b.toString(16).padStart(2, '0');
272
- const m = BigInt('0x' + emHex);
273
- const c = modPow(m, e, n);
274
- let cHex = c.toString(16);
275
- if (cHex.length % 2 !== 0) cHex = '0' + cHex;
276
- while (cHex.length < keyLen * 2) cHex = '00' + cHex;
277
-
278
- let binary = '';
279
- for (let i = 0; i < cHex.length; i += 2) {
280
- binary += String.fromCharCode(parseInt(cHex.slice(i, i + 2), 16));
281
- }
282
- return window.btoa(binary);
283
- }
284
-
285
- // 尝试使用 Web Crypto API 原生 RSA-OAEP SHA-256 加密(安全上下文可用)
286
- async function trySubtleEncrypt(plainText, pubPem) {
287
- if (!window.crypto || !window.crypto.subtle) return null;
288
- const b64 = pubPem.replace(/-----BEGIN PUBLIC KEY-----/g, '').replace(/-----END PUBLIC KEY-----/g, '').replace(/\\s/g, '');
289
- const binary = window.atob(b64);
290
- const bytes = new Uint8Array(binary.length);
291
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
292
-
293
- const cryptoKey = await window.crypto.subtle.importKey(
294
- 'spki',
295
- bytes.buffer,
296
- { name: 'RSA-OAEP', hash: 'SHA-256' },
297
- false,
298
- ['encrypt']
299
- );
300
-
301
- const encBuffer = await window.crypto.subtle.encrypt(
302
- { name: 'RSA-OAEP' },
303
- cryptoKey,
304
- new TextEncoder().encode(plainText)
305
- );
306
-
307
- const encBytes = new Uint8Array(encBuffer);
308
- let binaryEnc = '';
309
- for (let i = 0; i < encBytes.length; i++) binaryEnc += String.fromCharCode(encBytes[i]);
310
- return window.btoa(binaryEnc);
311
- }
312
-
313
- async function encryptCredential(plainText) {
314
- try {
315
- const subtleB64 = await trySubtleEncrypt(plainText, SERVER_RSA_KEY);
316
- if (subtleB64) return { encryptedCredential: subtleB64 };
317
- } catch (e) {}
318
-
319
- try {
320
- const pureB64 = rsaEncryptPureJs(plainText, SERVER_RSA_KEY);
321
- return { encryptedCredential: pureB64 };
322
- } catch (err) {
323
- console.error('RSA encrypt error:', err);
324
- return { credential: plainText };
325
- }
326
- }
327
-
328
- // 检测 URL 参数里的 token,仅回填输入框,不自动触发验证
329
- const urlParams = new URLSearchParams(window.location.search);
330
- const queryToken = urlParams.get('token');
331
- if (queryToken) {
332
- input.value = queryToken;
333
- }
334
-
335
- // 自适应网关子路径 / baseURI 与重定向
336
- const verifyApiUrl = (function () {
337
- try {
338
- if (document.baseURI && document.querySelector('base')) {
339
- return new URL('api/remote-mobile/verify', document.baseURI).toString();
340
- }
341
- // ⚠️ 本段内联脚本位于模板字符串内:反斜杠在字符串求值时会被吃掉,
342
- // 所以 /auth 后缀正则必须写成双反斜杠形式,否则会拼出以两条斜杠开头
343
- // 的行注释,使整个 script 抛 SyntaxError、登录按钮彻底失效(下两处同理)。
344
- const pathname = window.location.pathname.replace(/\\/auth\\/?$/, '/');
345
- return new URL('api/remote-mobile/verify', window.location.origin + pathname).toString();
346
- } catch (e) {
347
- return '/api/remote-mobile/verify';
348
- }
349
- })();
350
-
351
- const successRedirectUrl = (function () {
352
- try {
353
- // 预留参数能力:支持通过 ?redirect=/path 携带登录后重定向目标
354
- const redirectParam = urlParams.get('redirect');
355
- if (redirectParam && redirectParam.startsWith('/') && !redirectParam.startsWith('//')) {
356
- const appPrefix = window.location.pathname.replace(/\\/auth\\/?$/, '');
357
- if (appPrefix && redirectParam.startsWith(appPrefix)) {
358
- return window.location.origin + redirectParam;
359
- }
360
- return window.location.origin + appPrefix + redirectParam;
361
- }
362
- if (document.baseURI && document.querySelector('base')) {
363
- return new URL('./', document.baseURI).toString();
364
- }
365
- const appPrefix = window.location.pathname.replace(/\\/auth\\/?$/, '/');
366
- return window.location.origin + appPrefix;
367
- } catch (e) {
368
- return '/';
369
- }
370
- })();
371
-
372
- form.addEventListener('submit', (e) => {
373
- e.preventDefault();
374
- const val = input.value.trim();
375
- if (!val) return;
376
- doVerify(val);
377
- });
378
-
379
- async function doVerify(credential) {
380
- submitBtn.disabled = true;
381
- submitBtn.innerText = ${JSON.stringify(i18n.verifying)};
382
- statusMsg.className = 'status-msg';
383
- statusMsg.style.display = 'none';
384
-
385
- try {
386
- const payload = await encryptCredential(credential);
387
- const res = await fetch(verifyApiUrl, {
388
- method: 'POST',
389
- headers: { 'Content-Type': 'application/json' },
390
- body: JSON.stringify(payload)
391
- });
392
- const data = await res.json();
393
- if (data.success) {
394
- statusMsg.className = 'status-msg success';
395
- statusMsg.innerText = ${JSON.stringify(i18n.success)};
396
- statusMsg.style.display = 'block';
397
- setTimeout(() => {
398
- window.location.href = successRedirectUrl;
399
- }, 600);
400
- } else {
401
- statusMsg.className = 'status-msg error';
402
- statusMsg.innerText = data.reason || ${JSON.stringify(isEn ? 'Authorization failed, please try again' : '授权失败,请重试')};
403
- statusMsg.style.display = 'block';
404
- submitBtn.disabled = false;
405
- submitBtn.innerText = ${JSON.stringify(i18n.submitBtn)};
406
- }
407
- } catch (err) {
408
- statusMsg.className = 'status-msg error';
409
- statusMsg.innerText = ${JSON.stringify(i18n.networkErr)};
410
- statusMsg.style.display = 'block';
411
- submitBtn.disabled = false;
412
- submitBtn.innerText = ${JSON.stringify(i18n.submitBtn)};
413
- }
414
- }
415
- </script>
416
- </body>
22
+ return `<!DOCTYPE html>
23
+ <html lang="${isEn ? 'en' : 'zh-CN'}">
24
+ <head>
25
+ <meta charset="UTF-8">
26
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
27
+ <title>${i18n.pageTitle}</title>
28
+ <style>
29
+ :root {
30
+ --bg: #0f1117;
31
+ --card-bg: rgba(26, 29, 39, 0.85);
32
+ --card-border: rgba(255, 255, 255, 0.08);
33
+ --primary: #3b82f6;
34
+ --primary-hover: #2563eb;
35
+ --text: #f3f4f6;
36
+ --text-muted: #9ca3af;
37
+ --error: #ef4444;
38
+ --success: #10b981;
39
+ }
40
+ @media (prefers-color-scheme: light) {
41
+ :root {
42
+ --bg: #f3f4f6;
43
+ --card-bg: rgba(255, 255, 255, 0.9);
44
+ --card-border: rgba(0, 0, 0, 0.08);
45
+ --text: #111827;
46
+ --text-muted: #6b7280;
47
+ }
48
+ }
49
+ * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
50
+ body {
51
+ background: var(--bg);
52
+ color: var(--text);
53
+ min-height: 100vh;
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ padding: 20px;
58
+ }
59
+ .auth-card {
60
+ width: 100%;
61
+ max-width: 380px;
62
+ background: var(--card-bg);
63
+ backdrop-filter: blur(16px);
64
+ -webkit-backdrop-filter: blur(16px);
65
+ border: 1px solid var(--card-border);
66
+ border-radius: 20px;
67
+ padding: 32px 24px;
68
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
69
+ text-align: center;
70
+ }
71
+ .logo-badge {
72
+ display: inline-flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ width: 56px;
76
+ height: 56px;
77
+ background: linear-gradient(135deg, #3b82f6, #8b5cf6);
78
+ border-radius: 16px;
79
+ margin-bottom: 20px;
80
+ color: white;
81
+ box-shadow: 0 8px 16px rgba(59, 130, 246, 0.3);
82
+ }
83
+ h1 {
84
+ font-size: 20px;
85
+ font-weight: 700;
86
+ margin-bottom: 8px;
87
+ }
88
+ p.desc {
89
+ font-size: 13px;
90
+ color: var(--text-muted);
91
+ line-height: 1.5;
92
+ margin-bottom: 24px;
93
+ }
94
+ .input-group {
95
+ margin-bottom: 16px;
96
+ }
97
+ input {
98
+ width: 100%;
99
+ height: 48px;
100
+ background: rgba(128, 128, 128, 0.08);
101
+ border: 1px solid var(--card-border);
102
+ border-radius: 12px;
103
+ padding: 0 16px;
104
+ font-size: 16px;
105
+ color: var(--text);
106
+ outline: none;
107
+ text-align: center;
108
+ letter-spacing: 2px;
109
+ transition: all 0.2s;
110
+ }
111
+ input:focus {
112
+ border-color: var(--primary);
113
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
114
+ }
115
+ button.submit-btn {
116
+ width: 100%;
117
+ height: 48px;
118
+ background: var(--primary);
119
+ color: white;
120
+ border: none;
121
+ border-radius: 12px;
122
+ font-size: 15px;
123
+ font-weight: 600;
124
+ cursor: pointer;
125
+ transition: background 0.2s;
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ gap: 8px;
130
+ }
131
+ button.submit-btn:active {
132
+ background: var(--primary-hover);
133
+ transform: scale(0.99);
134
+ }
135
+ .status-msg {
136
+ margin-top: 16px;
137
+ font-size: 13px;
138
+ display: none;
139
+ }
140
+ .status-msg.error {
141
+ color: var(--error);
142
+ display: block;
143
+ }
144
+ .status-msg.success {
145
+ color: var(--success);
146
+ display: block;
147
+ }
148
+ .info-footer {
149
+ margin-top: 24px;
150
+ padding-top: 16px;
151
+ border-top: 1px solid var(--card-border);
152
+ font-size: 12px;
153
+ color: var(--text-muted);
154
+ line-height: 1.6;
155
+ text-align: left;
156
+ }
157
+ </style>
158
+ </head>
159
+ <body>
160
+ <div class="auth-card">
161
+ <div class="logo-badge">
162
+ <svg style="width: 28px; height: 28px; fill: currentColor;" viewBox="0 0 24 24">
163
+ <path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/>
164
+ </svg>
165
+ </div>
166
+ <h1>${i18n.headerTitle}</h1>
167
+ <p class="desc">${i18n.headerDesc}</p>
168
+
169
+ <form id="authForm">
170
+ <div class="input-group">
171
+ <input
172
+ type="password"
173
+ id="credentialInput"
174
+ placeholder="${i18n.placeholder}"
175
+ autocomplete="off"
176
+ required
177
+ />
178
+ </div>
179
+ <button type="submit" class="submit-btn" id="submitBtn">
180
+ ${i18n.submitBtn}
181
+ </button>
182
+ <div id="statusMsg" class="status-msg"></div>
183
+ </form>
184
+
185
+ <div class="info-footer">
186
+ <div>${i18n.footerSecurity}</div>
187
+ </div>
188
+ </div>
189
+
190
+ <script>
191
+ const SERVER_RSA_KEY = ${JSON.stringify(inlinePublicKey)};
192
+ const form = document.getElementById('authForm');
193
+ const input = document.getElementById('credentialInput');
194
+ const statusMsg = document.getElementById('statusMsg');
195
+ const submitBtn = document.getElementById('submitBtn');
196
+
197
+ // 解析 SPKI 格式公钥并提取 n 和 e(用于纯 JS 垫片计算)
198
+ function parseSpkiKey(pemStr) {
199
+ const b64 = pemStr.replace(/-----BEGIN PUBLIC KEY-----/g, '').replace(/-----END PUBLIC KEY-----/g, '').replace(/\\s/g, '');
200
+ const binary = window.atob(b64);
201
+ const bytes = new Uint8Array(binary.length);
202
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
203
+
204
+ let idx = 0;
205
+ while (idx < bytes.length && bytes[idx] !== 0x02) idx++;
206
+ const lenByte = bytes[idx + 1];
207
+ let nLen = 0;
208
+ let nStart = 0;
209
+ if (lenByte === 0x82) {
210
+ nLen = (bytes[idx + 2] << 8) | bytes[idx + 3];
211
+ nStart = idx + 4;
212
+ } else if (lenByte === 0x81) {
213
+ nLen = bytes[idx + 2];
214
+ nStart = idx + 3;
215
+ } else {
216
+ nLen = lenByte;
217
+ nStart = idx + 2;
218
+ }
219
+ let nBytes = bytes.slice(nStart, nStart + nLen);
220
+ if (nBytes[0] === 0x00) nBytes = nBytes.slice(1);
221
+
222
+ idx = nStart + nLen;
223
+ while (idx < bytes.length && bytes[idx] !== 0x02) idx++;
224
+ const eLen = bytes[idx + 1];
225
+ const eBytes = bytes.slice(idx + 2, idx + 2 + eLen);
226
+
227
+ let nHex = '';
228
+ for (let b of nBytes) nHex += b.toString(16).padStart(2, '0');
229
+ let eHex = '';
230
+ for (let b of eBytes) eHex += b.toString(16).padStart(2, '0');
231
+
232
+ return { n: BigInt('0x' + nHex), e: BigInt('0x' + eHex), keyLen: nBytes.length };
233
+ }
234
+
235
+ function modPow(b, exp, mod) {
236
+ let res = 1n;
237
+ let base = b % mod;
238
+ let e = exp;
239
+ while (e > 0n) {
240
+ if (e & 1n) res = (res * base) % mod;
241
+ base = (base * base) % mod;
242
+ e >>= 1n;
243
+ }
244
+ return res;
245
+ }
246
+
247
+ // 纯 JS RSA 垫片加密:使用 CSPRNG (crypto.getRandomValues) 填充
248
+ function rsaEncryptPureJs(plainText, pubPem) {
249
+ const { n, e, keyLen } = parseSpkiKey(pubPem);
250
+ const msgBytes = new TextEncoder().encode(plainText);
251
+ const padLen = keyLen - 3 - msgBytes.length;
252
+ if (padLen < 8) throw new Error('Message too long');
253
+
254
+ const pad = new Uint8Array(padLen);
255
+ if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
256
+ window.crypto.getRandomValues(pad);
257
+ for (let i = 0; i < padLen; i++) {
258
+ if (pad[i] === 0) pad[i] = (Math.floor(Math.random() * 254) + 1);
259
+ }
260
+ } else {
261
+ for (let i = 0; i < padLen; i++) {
262
+ pad[i] = Math.floor(Math.random() * 255) + 1;
263
+ }
264
+ }
265
+
266
+ const em = new Uint8Array(keyLen);
267
+ em[0] = 0x00;
268
+ em[1] = 0x02;
269
+ em.set(pad, 2);
270
+ em[2 + padLen] = 0x00;
271
+ em.set(msgBytes, 3 + padLen);
272
+
273
+ let emHex = '';
274
+ for (let b of em) emHex += b.toString(16).padStart(2, '0');
275
+ const m = BigInt('0x' + emHex);
276
+ const c = modPow(m, e, n);
277
+ let cHex = c.toString(16);
278
+ if (cHex.length % 2 !== 0) cHex = '0' + cHex;
279
+ while (cHex.length < keyLen * 2) cHex = '00' + cHex;
280
+
281
+ let binary = '';
282
+ for (let i = 0; i < cHex.length; i += 2) {
283
+ binary += String.fromCharCode(parseInt(cHex.slice(i, i + 2), 16));
284
+ }
285
+ return window.btoa(binary);
286
+ }
287
+
288
+ // 尝试使用 Web Crypto API 原生 RSA-OAEP SHA-256 加密(安全上下文可用)
289
+ async function trySubtleEncrypt(plainText, pubPem) {
290
+ if (!window.crypto || !window.crypto.subtle) return null;
291
+ const b64 = pubPem.replace(/-----BEGIN PUBLIC KEY-----/g, '').replace(/-----END PUBLIC KEY-----/g, '').replace(/\\s/g, '');
292
+ const binary = window.atob(b64);
293
+ const bytes = new Uint8Array(binary.length);
294
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
295
+
296
+ const cryptoKey = await window.crypto.subtle.importKey(
297
+ 'spki',
298
+ bytes.buffer,
299
+ { name: 'RSA-OAEP', hash: 'SHA-256' },
300
+ false,
301
+ ['encrypt']
302
+ );
303
+
304
+ const encBuffer = await window.crypto.subtle.encrypt(
305
+ { name: 'RSA-OAEP' },
306
+ cryptoKey,
307
+ new TextEncoder().encode(plainText)
308
+ );
309
+
310
+ const encBytes = new Uint8Array(encBuffer);
311
+ let binaryEnc = '';
312
+ for (let i = 0; i < encBytes.length; i++) binaryEnc += String.fromCharCode(encBytes[i]);
313
+ return window.btoa(binaryEnc);
314
+ }
315
+
316
+ async function encryptCredential(plainText) {
317
+ try {
318
+ const subtleB64 = await trySubtleEncrypt(plainText, SERVER_RSA_KEY);
319
+ if (subtleB64) return { encryptedCredential: subtleB64 };
320
+ } catch (e) {}
321
+
322
+ try {
323
+ const pureB64 = rsaEncryptPureJs(plainText, SERVER_RSA_KEY);
324
+ return { encryptedCredential: pureB64 };
325
+ } catch (err) {
326
+ console.error('RSA encrypt error:', err);
327
+ // 加密失败时绝不回退明文;调用方会提示用户更换浏览器或刷新页面。
328
+ throw new Error('ENCRYPTION_UNAVAILABLE');
329
+ }
330
+ }
331
+
332
+ // 检测 URL 参数里的 token,仅回填输入框,不自动触发验证
333
+ const urlParams = new URLSearchParams(window.location.search);
334
+ const queryToken = urlParams.get('token');
335
+ if (queryToken) {
336
+ input.value = queryToken;
337
+ }
338
+
339
+ // 自适应网关子路径 / baseURI 与重定向
340
+ const verifyApiUrl = (function () {
341
+ try {
342
+ if (document.baseURI && document.querySelector('base')) {
343
+ return new URL('api/remote-mobile/verify', document.baseURI).toString();
344
+ }
345
+ const pathname = window.location.pathname.replace(new RegExp('/auth/?$'), '/');
346
+ return new URL('api/remote-mobile/verify', window.location.origin + pathname).toString();
347
+ } catch (e) {
348
+ return '/api/remote-mobile/verify';
349
+ }
350
+ })();
351
+
352
+ const successRedirectUrl = (function () {
353
+ try {
354
+ // 预留参数能力:支持通过 ?redirect=/path 携带登录后重定向目标
355
+ const redirectParam = urlParams.get('redirect');
356
+ if (redirectParam && redirectParam.startsWith('/') && !redirectParam.startsWith('//')) {
357
+ const appPrefix = window.location.pathname.replace(new RegExp('/auth/?$'), '');
358
+ if (appPrefix && redirectParam.startsWith(appPrefix)) {
359
+ return window.location.origin + redirectParam;
360
+ }
361
+ return window.location.origin + appPrefix + redirectParam;
362
+ }
363
+ if (document.baseURI && document.querySelector('base')) {
364
+ return new URL('./', document.baseURI).toString();
365
+ }
366
+ const appPrefix = window.location.pathname.replace(new RegExp('/auth/?$'), '/');
367
+ return window.location.origin + appPrefix;
368
+ } catch (e) {
369
+ return '/';
370
+ }
371
+ })();
372
+
373
+ form.addEventListener('submit', (e) => {
374
+ e.preventDefault();
375
+ const val = input.value.trim();
376
+ if (!val) return;
377
+ doVerify(val);
378
+ });
379
+
380
+ async function doVerify(credential) {
381
+ submitBtn.disabled = true;
382
+ submitBtn.innerText = ${JSON.stringify(i18n.verifying)};
383
+ statusMsg.className = 'status-msg';
384
+ statusMsg.style.display = 'none';
385
+
386
+ try {
387
+ const payload = await encryptCredential(credential);
388
+ const res = await fetch(verifyApiUrl, {
389
+ method: 'POST',
390
+ headers: { 'Content-Type': 'application/json' },
391
+ body: JSON.stringify(payload)
392
+ });
393
+ const data = await res.json();
394
+ if (data.success) {
395
+ statusMsg.className = 'status-msg success';
396
+ statusMsg.innerText = ${JSON.stringify(i18n.success)};
397
+ statusMsg.style.display = 'block';
398
+ setTimeout(() => {
399
+ window.location.href = successRedirectUrl;
400
+ }, 600);
401
+ } else {
402
+ statusMsg.className = 'status-msg error';
403
+ statusMsg.innerText = data.reason || ${JSON.stringify(isEn ? 'Authorization failed, please try again' : '授权失败,请重试')};
404
+ statusMsg.style.display = 'block';
405
+ submitBtn.disabled = false;
406
+ submitBtn.innerText = ${JSON.stringify(i18n.submitBtn)};
407
+ }
408
+ } catch (err) {
409
+ statusMsg.className = 'status-msg error';
410
+ statusMsg.innerText = err && err.message === 'ENCRYPTION_UNAVAILABLE'
411
+ ? ${JSON.stringify(i18n.cryptoErr)}
412
+ : ${JSON.stringify(i18n.networkErr)};
413
+ statusMsg.style.display = 'block';
414
+ submitBtn.disabled = false;
415
+ submitBtn.innerText = ${JSON.stringify(i18n.submitBtn)};
416
+ }
417
+ }
418
+ </script>
419
+ </body>
417
420
  </html>`;
418
421
  }