@agentunion/fastaun-browser 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +604 -0
- package/dist/auth.d.ts +150 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +1388 -0
- package/dist/auth.js.map +1 -0
- package/dist/certs/root.d.ts +2 -0
- package/dist/certs/root.d.ts.map +1 -0
- package/dist/certs/root.js +16 -0
- package/dist/certs/root.js.map +1 -0
- package/dist/client.d.ts +341 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +4061 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +85 -0
- package/dist/config.js.map +1 -0
- package/dist/crypto.d.ts +41 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +132 -0
- package/dist/crypto.js.map +1 -0
- package/dist/discovery.d.ts +20 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +75 -0
- package/dist/discovery.js.map +1 -0
- package/dist/e2ee-group.d.ts +221 -0
- package/dist/e2ee-group.d.ts.map +1 -0
- package/dist/e2ee-group.js +1174 -0
- package/dist/e2ee-group.js.map +1 -0
- package/dist/e2ee.d.ts +187 -0
- package/dist/e2ee.d.ts.map +1 -0
- package/dist/e2ee.js +1067 -0
- package/dist/e2ee.js.map +1 -0
- package/dist/errors.d.ts +118 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +250 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +33 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +68 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/keystore/index.d.ts +88 -0
- package/dist/keystore/index.d.ts.map +1 -0
- package/dist/keystore/index.js +3 -0
- package/dist/keystore/index.js.map +1 -0
- package/dist/keystore/indexeddb.d.ts +94 -0
- package/dist/keystore/indexeddb.d.ts.map +1 -0
- package/dist/keystore/indexeddb.js +1434 -0
- package/dist/keystore/indexeddb.js.map +1 -0
- package/dist/namespaces/auth.d.ts +52 -0
- package/dist/namespaces/auth.d.ts.map +1 -0
- package/dist/namespaces/auth.js +237 -0
- package/dist/namespaces/auth.js.map +1 -0
- package/dist/namespaces/custody.d.ts +48 -0
- package/dist/namespaces/custody.d.ts.map +1 -0
- package/dist/namespaces/custody.js +230 -0
- package/dist/namespaces/custody.js.map +1 -0
- package/dist/secret-store/index.d.ts +20 -0
- package/dist/secret-store/index.d.ts.map +1 -0
- package/dist/secret-store/index.js +12 -0
- package/dist/secret-store/index.js.map +1 -0
- package/dist/secret-store/indexeddb-store.d.ts +22 -0
- package/dist/secret-store/indexeddb-store.d.ts.map +1 -0
- package/dist/secret-store/indexeddb-store.js +133 -0
- package/dist/secret-store/indexeddb-store.js.map +1 -0
- package/dist/seq-tracker.d.ts +30 -0
- package/dist/seq-tracker.d.ts.map +1 -0
- package/dist/seq-tracker.js +219 -0
- package/dist/seq-tracker.js.map +1 -0
- package/dist/transport.d.ts +45 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +251 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/package.json +37 -0
package/dist/auth.js
ADDED
|
@@ -0,0 +1,1388 @@
|
|
|
1
|
+
// ── AuthFlow(认证流程 — 浏览器完整实现)─────────────────
|
|
2
|
+
// 负责 AID 注册、login1/login2 双阶段认证、证书链验证、token 管理。
|
|
3
|
+
// 浏览器环境使用原生 WebSocket + fetch + SubtleCrypto。
|
|
4
|
+
import { base64ToUint8, uint8ToBase64, pemToArrayBuffer, toBufferSource } from './crypto.js';
|
|
5
|
+
import { AuthError, StateError, ValidationError, mapRemoteError } from './errors.js';
|
|
6
|
+
import { ROOT_CA_PEM } from './certs/root.js';
|
|
7
|
+
import { isJsonObject, } from './types.js';
|
|
8
|
+
// ── ASN.1 / PEM 工具 ────────────────────────────────────
|
|
9
|
+
/** 拆分 PEM bundle 为独立的 PEM 字符串数组 */
|
|
10
|
+
function splitPemBundle(bundle) {
|
|
11
|
+
const marker = '-----END CERTIFICATE-----';
|
|
12
|
+
const certs = [];
|
|
13
|
+
for (const part of bundle.split(marker)) {
|
|
14
|
+
const trimmed = part.trim();
|
|
15
|
+
if (!trimmed)
|
|
16
|
+
continue;
|
|
17
|
+
certs.push(`${trimmed}\n${marker}\n`);
|
|
18
|
+
}
|
|
19
|
+
return certs;
|
|
20
|
+
}
|
|
21
|
+
/** 将 DER 格式的 ECDSA 签名转为 IEEE P1363 格式(SubtleCrypto 需要) */
|
|
22
|
+
function derToP1363(der, curveLen = 32) {
|
|
23
|
+
// DER: 0x30 <len> 0x02 <rLen> <r> 0x02 <sLen> <s>
|
|
24
|
+
if (der[0] !== 0x30)
|
|
25
|
+
throw new AuthError('无效的 DER 签名格式');
|
|
26
|
+
let offset = 2;
|
|
27
|
+
// 跳过可能的多字节长度
|
|
28
|
+
if (der[1] & 0x80)
|
|
29
|
+
offset += (der[1] & 0x7f);
|
|
30
|
+
if (der[offset] !== 0x02)
|
|
31
|
+
throw new AuthError('DER 签名缺少 r INTEGER 标签');
|
|
32
|
+
const rLen = der[offset + 1];
|
|
33
|
+
const rBytes = der.slice(offset + 2, offset + 2 + rLen);
|
|
34
|
+
offset += 2 + rLen;
|
|
35
|
+
if (der[offset] !== 0x02)
|
|
36
|
+
throw new AuthError('DER 签名缺少 s INTEGER 标签');
|
|
37
|
+
const sLen = der[offset + 1];
|
|
38
|
+
const sBytes = der.slice(offset + 2, offset + 2 + sLen);
|
|
39
|
+
// 去前导零并填充到 curveLen
|
|
40
|
+
const r = padToLength(trimSignedIntLeadingZeros(rBytes), curveLen);
|
|
41
|
+
const s = padToLength(trimSignedIntLeadingZeros(sBytes), curveLen);
|
|
42
|
+
const result = new Uint8Array(curveLen * 2);
|
|
43
|
+
result.set(r, 0);
|
|
44
|
+
result.set(s, curveLen);
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
/** 去除 ASN.1 有符号整数的前导零 */
|
|
48
|
+
function trimSignedIntLeadingZeros(bytes) {
|
|
49
|
+
let start = 0;
|
|
50
|
+
while (start < bytes.length - 1 && bytes[start] === 0)
|
|
51
|
+
start++;
|
|
52
|
+
return bytes.slice(start);
|
|
53
|
+
}
|
|
54
|
+
/** 将字节数组左侧填零至指定长度 */
|
|
55
|
+
function padToLength(bytes, len) {
|
|
56
|
+
if (bytes.length >= len)
|
|
57
|
+
return bytes.slice(bytes.length - len);
|
|
58
|
+
const padded = new Uint8Array(len);
|
|
59
|
+
padded.set(bytes, len - bytes.length);
|
|
60
|
+
return padded;
|
|
61
|
+
}
|
|
62
|
+
const CERT_CLOCK_SKEW_SECONDS = 300;
|
|
63
|
+
/** 读取 ASN.1 TLV 的 tag 和长度,返回 [tag, 值起始偏移, 值长度] */
|
|
64
|
+
function readTlv(data, offset) {
|
|
65
|
+
const tag = data[offset];
|
|
66
|
+
let lenOffset = offset + 1;
|
|
67
|
+
let length;
|
|
68
|
+
if (data[lenOffset] & 0x80) {
|
|
69
|
+
const numBytes = data[lenOffset] & 0x7f;
|
|
70
|
+
length = 0;
|
|
71
|
+
for (let i = 0; i < numBytes; i++) {
|
|
72
|
+
length = (length << 8) | data[lenOffset + 1 + i];
|
|
73
|
+
}
|
|
74
|
+
lenOffset += 1 + numBytes;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
length = data[lenOffset];
|
|
78
|
+
lenOffset += 1;
|
|
79
|
+
}
|
|
80
|
+
return [tag, lenOffset, length];
|
|
81
|
+
}
|
|
82
|
+
/** 获取 TLV 完整范围(包含 tag + length + value) */
|
|
83
|
+
function getTlvFullRange(data, offset) {
|
|
84
|
+
const [, valueStart, valueLen] = readTlv(data, offset);
|
|
85
|
+
return [offset, valueStart + valueLen];
|
|
86
|
+
}
|
|
87
|
+
/** 解析 ASN.1 UTCTime 或 GeneralizedTime 为 UNIX 时间戳 */
|
|
88
|
+
function parseAsn1Time(data, offset) {
|
|
89
|
+
const [tag, valueStart, valueLen] = readTlv(data, offset);
|
|
90
|
+
const timeStr = new TextDecoder().decode(data.slice(valueStart, valueStart + valueLen));
|
|
91
|
+
if (tag === 0x17) {
|
|
92
|
+
// UTCTime: YYMMDDHHMMSSZ
|
|
93
|
+
let year = parseInt(timeStr.substring(0, 2), 10);
|
|
94
|
+
year += year >= 50 ? 1900 : 2000;
|
|
95
|
+
const month = parseInt(timeStr.substring(2, 4), 10) - 1;
|
|
96
|
+
const day = parseInt(timeStr.substring(4, 6), 10);
|
|
97
|
+
const hour = parseInt(timeStr.substring(6, 8), 10);
|
|
98
|
+
const minute = parseInt(timeStr.substring(8, 10), 10);
|
|
99
|
+
const second = parseInt(timeStr.substring(10, 12), 10);
|
|
100
|
+
return Date.UTC(year, month, day, hour, minute, second) / 1000;
|
|
101
|
+
}
|
|
102
|
+
else if (tag === 0x18) {
|
|
103
|
+
// GeneralizedTime: YYYYMMDDHHMMSSZ
|
|
104
|
+
const year = parseInt(timeStr.substring(0, 4), 10);
|
|
105
|
+
const month = parseInt(timeStr.substring(4, 6), 10) - 1;
|
|
106
|
+
const day = parseInt(timeStr.substring(6, 8), 10);
|
|
107
|
+
const hour = parseInt(timeStr.substring(8, 10), 10);
|
|
108
|
+
const minute = parseInt(timeStr.substring(10, 12), 10);
|
|
109
|
+
const second = parseInt(timeStr.substring(12, 14), 10);
|
|
110
|
+
return Date.UTC(year, month, day, hour, minute, second) / 1000;
|
|
111
|
+
}
|
|
112
|
+
throw new AuthError('不支持的 ASN.1 时间格式');
|
|
113
|
+
}
|
|
114
|
+
/** 解析 OID 字节为点分字符串 */
|
|
115
|
+
function parseOid(data) {
|
|
116
|
+
if (data.length === 0)
|
|
117
|
+
return '';
|
|
118
|
+
const components = [];
|
|
119
|
+
// 第一个字节编码前两个组件
|
|
120
|
+
components.push(Math.floor(data[0] / 40));
|
|
121
|
+
components.push(data[0] % 40);
|
|
122
|
+
let value = 0;
|
|
123
|
+
for (let i = 1; i < data.length; i++) {
|
|
124
|
+
value = (value << 7) | (data[i] & 0x7f);
|
|
125
|
+
if (!(data[i] & 0x80)) {
|
|
126
|
+
components.push(value);
|
|
127
|
+
value = 0;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return components.join('.');
|
|
131
|
+
}
|
|
132
|
+
/** 在 ASN.1 SEQUENCE 中查找特定 OID 对应的值 */
|
|
133
|
+
function findOidValue(data, targetOid) {
|
|
134
|
+
for (let i = 0; i < data.length - 4; i++) {
|
|
135
|
+
if (data[i] === 0x06) {
|
|
136
|
+
// OID tag
|
|
137
|
+
const oidLen = data[i + 1];
|
|
138
|
+
if (i + 2 + oidLen > data.length)
|
|
139
|
+
continue;
|
|
140
|
+
const oid = parseOid(data.slice(i + 2, i + 2 + oidLen));
|
|
141
|
+
if (oid === targetOid) {
|
|
142
|
+
const valOffset = i + 2 + oidLen;
|
|
143
|
+
if (valOffset < data.length) {
|
|
144
|
+
const [, vs, vl] = readTlv(data, valOffset);
|
|
145
|
+
return data.slice(vs, vs + vl);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
/** 从 DER 数据中查找 CN(Common Name, OID 2.5.4.3)的 UTF8/PrintableString 值 */
|
|
153
|
+
function extractCN(subjectRaw) {
|
|
154
|
+
// CN OID: 2.5.4.3 → 编码为 55 04 03
|
|
155
|
+
const cnOid = '2.5.4.3';
|
|
156
|
+
const value = findOidValue(subjectRaw, cnOid);
|
|
157
|
+
if (!value)
|
|
158
|
+
return '';
|
|
159
|
+
return new TextDecoder().decode(value);
|
|
160
|
+
}
|
|
161
|
+
/** 解析 PEM 证书为结构化数据(简化版 X.509 DER 解析) */
|
|
162
|
+
function parseCertDer(pem) {
|
|
163
|
+
const derBuf = pemToArrayBuffer(pem);
|
|
164
|
+
const der = new Uint8Array(derBuf);
|
|
165
|
+
// 顶层 SEQUENCE
|
|
166
|
+
const [, certValueStart, certValueLen] = readTlv(der, 0);
|
|
167
|
+
// TBSCertificate(第一个子 SEQUENCE)
|
|
168
|
+
const [tbsStart, tbsEnd] = getTlvFullRange(der, certValueStart);
|
|
169
|
+
const tbsBytes = der.slice(tbsStart, tbsEnd);
|
|
170
|
+
const [, tbsValueStart] = readTlv(der, certValueStart);
|
|
171
|
+
let pos = tbsValueStart;
|
|
172
|
+
// 版本号(可选,EXPLICIT [0])— 跳过整个 [0] TLV
|
|
173
|
+
if (der[pos] === 0xa0) {
|
|
174
|
+
const [, versionVs, versionVl] = readTlv(der, pos);
|
|
175
|
+
pos = versionVs + versionVl;
|
|
176
|
+
}
|
|
177
|
+
// 序列号(INTEGER)
|
|
178
|
+
const [, serialStart, serialLen] = readTlv(der, pos);
|
|
179
|
+
const serialBytes = der.slice(serialStart, serialStart + serialLen);
|
|
180
|
+
const serialHex = Array.from(serialBytes)
|
|
181
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
182
|
+
.join('');
|
|
183
|
+
pos = serialStart + serialLen;
|
|
184
|
+
// 签名算法(SEQUENCE)
|
|
185
|
+
const [, sigAlgStart, sigAlgLen] = readTlv(der, pos);
|
|
186
|
+
// 提取内部 OID
|
|
187
|
+
const [, sigOidStart, sigOidLen] = readTlv(der, sigAlgStart);
|
|
188
|
+
const signatureAlgorithmOid = parseOid(der.slice(sigOidStart, sigOidStart + sigOidLen));
|
|
189
|
+
pos = sigAlgStart + sigAlgLen;
|
|
190
|
+
// Issuer(SEQUENCE)
|
|
191
|
+
const [issuerFullStart, issuerFullEnd] = getTlvFullRange(der, pos);
|
|
192
|
+
const issuerRaw = der.slice(issuerFullStart, issuerFullEnd);
|
|
193
|
+
pos = issuerFullEnd;
|
|
194
|
+
// Validity(SEQUENCE)
|
|
195
|
+
const [, validityStart, validityLen] = readTlv(der, pos);
|
|
196
|
+
const notBefore = parseAsn1Time(der, validityStart);
|
|
197
|
+
// notBefore 的完整 TLV 结尾就是 notAfter 的起始位置
|
|
198
|
+
const [, nbVs, nbVl] = readTlv(der, validityStart);
|
|
199
|
+
const notAfter = parseAsn1Time(der, nbVs + nbVl);
|
|
200
|
+
pos = validityStart + validityLen;
|
|
201
|
+
// Subject(SEQUENCE)
|
|
202
|
+
const [subjectFullStart, subjectFullEnd] = getTlvFullRange(der, pos);
|
|
203
|
+
const subjectRaw = der.slice(subjectFullStart, subjectFullEnd);
|
|
204
|
+
const subjectCN = extractCN(subjectRaw);
|
|
205
|
+
pos = subjectFullEnd;
|
|
206
|
+
// SubjectPublicKeyInfo(SEQUENCE)
|
|
207
|
+
const [spkiFullStart, spkiFullEnd] = getTlvFullRange(der, pos);
|
|
208
|
+
const spkiBytes = der.slice(spkiFullStart, spkiFullEnd);
|
|
209
|
+
pos = spkiFullEnd;
|
|
210
|
+
// 检查 BasicConstraints(在 extensions 中)
|
|
211
|
+
let isCA = false;
|
|
212
|
+
// extensions 在 [3] EXPLICIT 中
|
|
213
|
+
const extensionsSearchStart = pos;
|
|
214
|
+
const extensionsSearchEnd = tbsEnd;
|
|
215
|
+
// 搜索 BasicConstraints OID: 2.5.29.19 → 55 1D 13
|
|
216
|
+
const bcOidBytes = new Uint8Array([0x55, 0x1d, 0x13]);
|
|
217
|
+
for (let i = extensionsSearchStart; i < extensionsSearchEnd - 3; i++) {
|
|
218
|
+
if (der[i] === bcOidBytes[0] && der[i + 1] === bcOidBytes[1] && der[i + 2] === bcOidBytes[2]) {
|
|
219
|
+
// 找到 BasicConstraints OID,在后续字节中搜索 BOOLEAN TRUE (01 01 FF)
|
|
220
|
+
const searchEnd = Math.min(i + 20, extensionsSearchEnd);
|
|
221
|
+
for (let j = i + 3; j < searchEnd - 2; j++) {
|
|
222
|
+
if (der[j] === 0x01 && der[j + 1] === 0x01 && der[j + 2] === 0xff) {
|
|
223
|
+
isCA = true;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// 签名算法(第二个,在 TBS 之后)
|
|
231
|
+
const sigAlg2Pos = tbsEnd;
|
|
232
|
+
const [, sigAlg2Start, sigAlg2Len] = readTlv(der, sigAlg2Pos);
|
|
233
|
+
const signaturePos = sigAlg2Start + sigAlg2Len;
|
|
234
|
+
// 签名值(BIT STRING)
|
|
235
|
+
const [, sigBitsStart, sigBitsLen] = readTlv(der, signaturePos);
|
|
236
|
+
// BIT STRING 第一个字节是 unused bits 数量
|
|
237
|
+
const signatureBytes = der.slice(sigBitsStart + 1, sigBitsStart + sigBitsLen);
|
|
238
|
+
return {
|
|
239
|
+
pem,
|
|
240
|
+
tbsBytes,
|
|
241
|
+
signatureBytes,
|
|
242
|
+
signatureAlgorithmOid,
|
|
243
|
+
serialHex,
|
|
244
|
+
subjectCN,
|
|
245
|
+
issuerRaw,
|
|
246
|
+
subjectRaw,
|
|
247
|
+
notBefore,
|
|
248
|
+
notAfter,
|
|
249
|
+
isCA,
|
|
250
|
+
spkiBytes,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
// ── 签名验证 ────────────────────────────────────────────
|
|
254
|
+
/** 确定签名算法的哈希和曲线长度 */
|
|
255
|
+
function getSignatureParams(oid) {
|
|
256
|
+
// ecdsa-with-SHA256: 1.2.840.10045.4.3.2
|
|
257
|
+
if (oid === '1.2.840.10045.4.3.2')
|
|
258
|
+
return { hash: 'SHA-256', curveLen: 32, curveName: 'P-256' };
|
|
259
|
+
// ecdsa-with-SHA384: 1.2.840.10045.4.3.3
|
|
260
|
+
if (oid === '1.2.840.10045.4.3.3')
|
|
261
|
+
return { hash: 'SHA-384', curveLen: 48, curveName: 'P-384' };
|
|
262
|
+
// ecdsa-with-SHA512: 1.2.840.10045.4.3.4
|
|
263
|
+
if (oid === '1.2.840.10045.4.3.4')
|
|
264
|
+
return { hash: 'SHA-512', curveLen: 66, curveName: 'P-521' };
|
|
265
|
+
throw new AuthError(`不支持的签名算法 OID: ${oid}`);
|
|
266
|
+
}
|
|
267
|
+
/** 使用 SubtleCrypto 验证证书签名 */
|
|
268
|
+
async function verifyCertSignature(issuerSpkiDer, cert) {
|
|
269
|
+
const params = getSignatureParams(cert.signatureAlgorithmOid);
|
|
270
|
+
let pubKey;
|
|
271
|
+
try {
|
|
272
|
+
pubKey = await crypto.subtle.importKey('spki', toBufferSource(issuerSpkiDer), { name: 'ECDSA', namedCurve: params.curveName }, false, ['verify']);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
throw new AuthError('导入签发者公钥失败');
|
|
276
|
+
}
|
|
277
|
+
const p1363Sig = derToP1363(cert.signatureBytes, params.curveLen);
|
|
278
|
+
const valid = await crypto.subtle.verify({ name: 'ECDSA', hash: params.hash }, pubKey, toBufferSource(p1363Sig), toBufferSource(cert.tbsBytes));
|
|
279
|
+
if (!valid)
|
|
280
|
+
throw new AuthError('证书签名验证失败');
|
|
281
|
+
}
|
|
282
|
+
/** 使用 SubtleCrypto 验证通用 ECDSA 签名(用于 client_nonce 验证等) */
|
|
283
|
+
async function verifyEcdsaSignature(spkiDer, signatureDer, data) {
|
|
284
|
+
// 自动检测曲线
|
|
285
|
+
let pubKey;
|
|
286
|
+
let curveLen;
|
|
287
|
+
let hash;
|
|
288
|
+
try {
|
|
289
|
+
pubKey = await crypto.subtle.importKey('spki', toBufferSource(spkiDer), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
|
290
|
+
curveLen = 32;
|
|
291
|
+
hash = 'SHA-256';
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
try {
|
|
295
|
+
pubKey = await crypto.subtle.importKey('spki', toBufferSource(spkiDer), { name: 'ECDSA', namedCurve: 'P-384' }, false, ['verify']);
|
|
296
|
+
curveLen = 48;
|
|
297
|
+
hash = 'SHA-384';
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const p1363Sig = derToP1363(signatureDer, curveLen);
|
|
304
|
+
return crypto.subtle.verify({ name: 'ECDSA', hash }, pubKey, toBufferSource(p1363Sig), toBufferSource(data));
|
|
305
|
+
}
|
|
306
|
+
// ── Gateway URL 工具 ────────────────────────────────────
|
|
307
|
+
/** 将 WebSocket URL 转为对应的 HTTP URL */
|
|
308
|
+
function gatewayHttpUrl(gatewayUrl, path) {
|
|
309
|
+
try {
|
|
310
|
+
const parsed = new URL(gatewayUrl);
|
|
311
|
+
const scheme = parsed.protocol === 'wss:' ? 'https:' : 'http:';
|
|
312
|
+
return `${scheme}//${parsed.host}${path}`;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// 降级处理
|
|
316
|
+
const httpUrl = gatewayUrl
|
|
317
|
+
.replace(/^wss:/, 'https:')
|
|
318
|
+
.replace(/^ws:/, 'http:');
|
|
319
|
+
const urlObj = new URL(httpUrl);
|
|
320
|
+
urlObj.pathname = path;
|
|
321
|
+
urlObj.search = '';
|
|
322
|
+
urlObj.hash = '';
|
|
323
|
+
return urlObj.toString();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// ── AuthFlow 主类 ───────────────────────────────────────
|
|
327
|
+
/**
|
|
328
|
+
* 认证流程管理器 — 负责 AID 注册、登录、token 管理。
|
|
329
|
+
*
|
|
330
|
+
* 完整实现:
|
|
331
|
+
* - PKI 证书链验证(链验证 + CRL + OCSP + AID 绑定)
|
|
332
|
+
* - login1/login2 双阶段认证
|
|
333
|
+
* - token 刷新
|
|
334
|
+
* - 证书自动续期
|
|
335
|
+
*/
|
|
336
|
+
export class AuthFlow {
|
|
337
|
+
static _INSTANCE_STATE_FIELDS = [
|
|
338
|
+
'access_token',
|
|
339
|
+
'refresh_token',
|
|
340
|
+
'kite_token',
|
|
341
|
+
'access_token_expires_at',
|
|
342
|
+
];
|
|
343
|
+
_keystore;
|
|
344
|
+
_crypto;
|
|
345
|
+
_aid;
|
|
346
|
+
_deviceId;
|
|
347
|
+
_slotId;
|
|
348
|
+
_rootCaPem;
|
|
349
|
+
_verifySsl;
|
|
350
|
+
// 缓存
|
|
351
|
+
_rootCerts = null;
|
|
352
|
+
_gatewayChainCache = new Map();
|
|
353
|
+
_gatewayCrlCache = new Map();
|
|
354
|
+
_gatewayOcspCache = new Map();
|
|
355
|
+
_chainVerifiedCache = new Map();
|
|
356
|
+
_chainCacheTtl;
|
|
357
|
+
_gatewayCaVerified = new Map();
|
|
358
|
+
constructor(opts) {
|
|
359
|
+
this._keystore = opts.keystore;
|
|
360
|
+
this._crypto = opts.crypto;
|
|
361
|
+
this._aid = opts.aid ?? null;
|
|
362
|
+
this._deviceId = String(opts.deviceId ?? '').trim();
|
|
363
|
+
this._slotId = String(opts.slotId ?? '').trim();
|
|
364
|
+
this._rootCaPem = opts.rootCaPem ?? null;
|
|
365
|
+
this._verifySsl = opts.verifySsl ?? true;
|
|
366
|
+
this._chainCacheTtl = opts.chainCacheTtl ?? 86400;
|
|
367
|
+
}
|
|
368
|
+
// ── 公开 API ──────────────────────────────────────
|
|
369
|
+
/** 加载本地身份信息 */
|
|
370
|
+
async loadIdentity(aid) {
|
|
371
|
+
const identity = await this._loadIdentityOrRaise(aid);
|
|
372
|
+
const cert = await this._keystore.loadCert(identity.aid);
|
|
373
|
+
if (cert)
|
|
374
|
+
identity.cert = cert;
|
|
375
|
+
const instanceState = await this._loadInstanceState(identity.aid);
|
|
376
|
+
if (instanceState)
|
|
377
|
+
Object.assign(identity, instanceState);
|
|
378
|
+
return identity;
|
|
379
|
+
}
|
|
380
|
+
/** 加载身份,不存在时返回 null */
|
|
381
|
+
async loadIdentityOrNull(aid) {
|
|
382
|
+
try {
|
|
383
|
+
return await this.loadIdentity(aid);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** 与 Node/TS SDK 对齐的别名:加载身份,不存在时返回 null */
|
|
390
|
+
async loadIdentityOrNone(aid) {
|
|
391
|
+
return await this.loadIdentityOrNull(aid);
|
|
392
|
+
}
|
|
393
|
+
/** 获取 access_token 过期时间 */
|
|
394
|
+
getAccessTokenExpiry(identity) {
|
|
395
|
+
const expiresAt = identity.access_token_expires_at;
|
|
396
|
+
if (typeof expiresAt === 'number')
|
|
397
|
+
return expiresAt;
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
setInstanceContext(opts) {
|
|
401
|
+
this._deviceId = String(opts.deviceId ?? '').trim();
|
|
402
|
+
this._slotId = String(opts.slotId ?? '').trim();
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* 注册新 AID。
|
|
406
|
+
*
|
|
407
|
+
* 流程:
|
|
408
|
+
* 1. 确保本地密钥对存在
|
|
409
|
+
* 2. 短连接 RPC 调用 auth.create_aid
|
|
410
|
+
* 3. 保存返回的证书
|
|
411
|
+
*/
|
|
412
|
+
async createAid(gatewayUrl, aid) {
|
|
413
|
+
AuthFlow._validateAidName(aid);
|
|
414
|
+
const identity = await this._ensureLocalIdentity(aid);
|
|
415
|
+
if (identity.cert) {
|
|
416
|
+
return { aid: identity.aid, cert: identity.cert };
|
|
417
|
+
}
|
|
418
|
+
// 本地有密钥但无证书 — 尝试注册
|
|
419
|
+
try {
|
|
420
|
+
const created = await this._createAid(gatewayUrl, identity);
|
|
421
|
+
Object.assign(identity, created);
|
|
422
|
+
}
|
|
423
|
+
catch (e) {
|
|
424
|
+
if (e instanceof Error && e.message.includes('already exists')) {
|
|
425
|
+
// AID 已在服务端注册,尝试下载证书恢复
|
|
426
|
+
try {
|
|
427
|
+
const recovered = await this._recoverCertViaDownload(gatewayUrl, identity);
|
|
428
|
+
Object.assign(identity, recovered);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
throw new StateError(`AID ${aid} already registered on server but local certificate is missing. ` +
|
|
432
|
+
`Certificate download recovery failed. Options: ` +
|
|
433
|
+
`(1) use a different AID name, or ` +
|
|
434
|
+
`(2) restart server to clear registration.`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
throw e;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
await this._persistIdentity(identity);
|
|
442
|
+
this._aid = identity.aid;
|
|
443
|
+
return { aid: identity.aid, cert: identity.cert };
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* 认证已有 AID — login1/login2 双阶段流程。
|
|
447
|
+
*/
|
|
448
|
+
async authenticate(gatewayUrl, aid) {
|
|
449
|
+
const identity = await this._loadIdentityOrRaise(aid);
|
|
450
|
+
if (!identity.cert) {
|
|
451
|
+
// 尝试下载恢复证书
|
|
452
|
+
try {
|
|
453
|
+
const recovered = await this._recoverCertViaDownload(gatewayUrl, identity);
|
|
454
|
+
Object.assign(identity, recovered);
|
|
455
|
+
await this._persistIdentity(identity);
|
|
456
|
+
}
|
|
457
|
+
catch (e) {
|
|
458
|
+
throw new StateError(`local certificate missing and recovery failed: ${e}. ` +
|
|
459
|
+
`Run auth.createAid() to register a new identity.`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
let login;
|
|
463
|
+
try {
|
|
464
|
+
login = await this._login(gatewayUrl, identity);
|
|
465
|
+
}
|
|
466
|
+
catch (e) {
|
|
467
|
+
// 证书未在服务端注册或公钥不匹配 — 自动重新注册
|
|
468
|
+
if (e instanceof AuthError && (String(e.message).includes('not registered') || String(e.message).includes('public key mismatch'))) {
|
|
469
|
+
console.warn(`[auth] 证书未在服务端注册,自动重新注册: aid=${identity.aid}`);
|
|
470
|
+
const created = await this._createAid(gatewayUrl, identity);
|
|
471
|
+
identity.cert = created.cert;
|
|
472
|
+
await this._persistIdentity(identity);
|
|
473
|
+
login = await this._login(gatewayUrl, identity);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
throw e;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
this._rememberTokens(identity, login);
|
|
480
|
+
await this._validateNewCert(identity, gatewayUrl);
|
|
481
|
+
await this._persistIdentity(identity);
|
|
482
|
+
this._aid = identity.aid;
|
|
483
|
+
return {
|
|
484
|
+
aid: identity.aid,
|
|
485
|
+
access_token: identity.access_token,
|
|
486
|
+
refresh_token: identity.refresh_token,
|
|
487
|
+
expires_at: identity.access_token_expires_at,
|
|
488
|
+
gateway: gatewayUrl,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* 确保已认证(如无身份则先注册再登录)。
|
|
493
|
+
*/
|
|
494
|
+
async ensureAuthenticated(gatewayUrl) {
|
|
495
|
+
const identity = await this._ensureIdentity();
|
|
496
|
+
if (!identity.cert) {
|
|
497
|
+
const created = await this._createAid(gatewayUrl, identity);
|
|
498
|
+
Object.assign(identity, created);
|
|
499
|
+
await this._persistIdentity(identity);
|
|
500
|
+
}
|
|
501
|
+
const login = await this._login(gatewayUrl, identity);
|
|
502
|
+
this._rememberTokens(identity, login);
|
|
503
|
+
await this._validateNewCert(identity, gatewayUrl);
|
|
504
|
+
await this._persistIdentity(identity);
|
|
505
|
+
const token = (identity.access_token || identity.token || identity.kite_token);
|
|
506
|
+
if (!token)
|
|
507
|
+
throw new AuthError('login2 did not return access token');
|
|
508
|
+
return { token, identity };
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* 使用已有 token 初始化 WebSocket 会话。
|
|
512
|
+
*/
|
|
513
|
+
async initializeWithToken(transport, challenge, accessToken, opts) {
|
|
514
|
+
const params = isJsonObject(challenge?.params) ? challenge.params : {};
|
|
515
|
+
const nonce = String(params.nonce ?? '');
|
|
516
|
+
if (!nonce)
|
|
517
|
+
throw new AuthError('gateway challenge missing nonce');
|
|
518
|
+
this.setInstanceContext({
|
|
519
|
+
deviceId: String(opts?.deviceId ?? this._deviceId ?? ''),
|
|
520
|
+
slotId: String(opts?.slotId ?? this._slotId ?? ''),
|
|
521
|
+
});
|
|
522
|
+
await this._initializeSession(transport, nonce, accessToken, {
|
|
523
|
+
deviceId: String(opts?.deviceId ?? this._deviceId ?? ''),
|
|
524
|
+
slotId: String(opts?.slotId ?? this._slotId ?? ''),
|
|
525
|
+
deliveryMode: opts?.deliveryMode ?? null,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* 连接会话 — 多策略认证:显式 token → 缓存 token → refresh → 重新登录。
|
|
530
|
+
*/
|
|
531
|
+
async connectSession(transport, challenge, gatewayUrl, accessToken) {
|
|
532
|
+
const params = isJsonObject(challenge?.params) ? challenge.params : {};
|
|
533
|
+
const nonce = String(params.nonce ?? '');
|
|
534
|
+
if (!nonce)
|
|
535
|
+
throw new AuthError('gateway challenge missing nonce');
|
|
536
|
+
const connectOptions = isJsonObject(accessToken)
|
|
537
|
+
? accessToken
|
|
538
|
+
: { accessToken };
|
|
539
|
+
const deviceId = String(connectOptions.deviceId ?? this._deviceId ?? '');
|
|
540
|
+
const slotId = String(connectOptions.slotId ?? this._slotId ?? '');
|
|
541
|
+
const deliveryMode = connectOptions.deliveryMode ?? null;
|
|
542
|
+
this.setInstanceContext({ deviceId, slotId });
|
|
543
|
+
let identity;
|
|
544
|
+
try {
|
|
545
|
+
identity = await this.loadIdentity();
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
identity = null;
|
|
549
|
+
}
|
|
550
|
+
// 策略 1: 显式 token
|
|
551
|
+
const explicitToken = String(connectOptions.accessToken ?? '');
|
|
552
|
+
if (explicitToken && identity !== null) {
|
|
553
|
+
try {
|
|
554
|
+
await this._initializeSession(transport, nonce, explicitToken, {
|
|
555
|
+
deviceId,
|
|
556
|
+
slotId,
|
|
557
|
+
deliveryMode,
|
|
558
|
+
});
|
|
559
|
+
identity.access_token = explicitToken;
|
|
560
|
+
await this._persistIdentity(identity);
|
|
561
|
+
return { token: explicitToken, identity };
|
|
562
|
+
}
|
|
563
|
+
catch (e) {
|
|
564
|
+
if (!(e instanceof AuthError))
|
|
565
|
+
throw e;
|
|
566
|
+
// 显式 token 失败,继续尝试其他方式
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// 无本地身份 — 先注册 + 登录
|
|
570
|
+
if (identity === null) {
|
|
571
|
+
const authContext = await this.ensureAuthenticated(gatewayUrl);
|
|
572
|
+
const token = authContext.token;
|
|
573
|
+
await this._initializeSession(transport, nonce, token, {
|
|
574
|
+
deviceId,
|
|
575
|
+
slotId,
|
|
576
|
+
deliveryMode,
|
|
577
|
+
});
|
|
578
|
+
return authContext;
|
|
579
|
+
}
|
|
580
|
+
// 策略 2: 缓存 token
|
|
581
|
+
const cachedToken = this._getCachedAccessToken(identity);
|
|
582
|
+
if (cachedToken) {
|
|
583
|
+
try {
|
|
584
|
+
await this._initializeSession(transport, nonce, cachedToken, {
|
|
585
|
+
deviceId,
|
|
586
|
+
slotId,
|
|
587
|
+
deliveryMode,
|
|
588
|
+
});
|
|
589
|
+
return { token: cachedToken, identity };
|
|
590
|
+
}
|
|
591
|
+
catch (e) {
|
|
592
|
+
if (!(e instanceof AuthError))
|
|
593
|
+
throw e;
|
|
594
|
+
// 缓存 token 失败,尝试刷新
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// 策略 3: refresh token
|
|
598
|
+
const refreshToken = String(identity.refresh_token ?? '');
|
|
599
|
+
if (refreshToken) {
|
|
600
|
+
try {
|
|
601
|
+
identity = await this.refreshCachedTokens(gatewayUrl, identity);
|
|
602
|
+
const refreshedToken = this._getCachedAccessToken(identity);
|
|
603
|
+
if (refreshedToken) {
|
|
604
|
+
await this._initializeSession(transport, nonce, refreshedToken, {
|
|
605
|
+
deviceId,
|
|
606
|
+
slotId,
|
|
607
|
+
deliveryMode,
|
|
608
|
+
});
|
|
609
|
+
return { token: refreshedToken, identity };
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
catch (e) {
|
|
613
|
+
if (!(e instanceof AuthError))
|
|
614
|
+
throw e;
|
|
615
|
+
// refresh 失败,重新登录
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// 策略 4: 重新登录
|
|
619
|
+
const login = await this.authenticate(gatewayUrl, identity.aid);
|
|
620
|
+
const token = String(login.access_token ?? '');
|
|
621
|
+
if (!token)
|
|
622
|
+
throw new AuthError('authenticate did not return access_token');
|
|
623
|
+
await this._initializeSession(transport, nonce, token, {
|
|
624
|
+
deviceId,
|
|
625
|
+
slotId,
|
|
626
|
+
deliveryMode,
|
|
627
|
+
});
|
|
628
|
+
identity = await this.loadIdentity(identity.aid);
|
|
629
|
+
return { token, identity };
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* 刷新 token。
|
|
633
|
+
*/
|
|
634
|
+
async refreshCachedTokens(gatewayUrl, identity) {
|
|
635
|
+
const refreshToken = String(identity.refresh_token ?? '');
|
|
636
|
+
if (!refreshToken)
|
|
637
|
+
throw new AuthError('missing refresh_token');
|
|
638
|
+
const refreshed = await this._refreshAccessToken(gatewayUrl, refreshToken);
|
|
639
|
+
this._rememberTokens(identity, refreshed);
|
|
640
|
+
await this._validateNewCert(identity, gatewayUrl);
|
|
641
|
+
await this._persistIdentity(identity);
|
|
642
|
+
return identity;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* 统一的对端证书验证入口:时间有效性 + 链验证 + CRL + OCSP + AID 绑定。
|
|
646
|
+
*/
|
|
647
|
+
async verifyPeerCertificate(gatewayUrl, certPem, expectedAid) {
|
|
648
|
+
const cert = parseCertDer(certPem);
|
|
649
|
+
const now = Date.now() / 1000;
|
|
650
|
+
ensureCertTimeValid(cert, 'peer certificate', now);
|
|
651
|
+
await this._verifyAuthCertChain(gatewayUrl, cert, expectedAid);
|
|
652
|
+
try {
|
|
653
|
+
await this._verifyAuthCertRevocation(gatewayUrl, cert, expectedAid);
|
|
654
|
+
}
|
|
655
|
+
catch (e) {
|
|
656
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
657
|
+
if (/revoked/i.test(errMsg))
|
|
658
|
+
throw e instanceof AuthError ? e : new AuthError(errMsg);
|
|
659
|
+
console.warn('[aun_core.auth] CRL 检查不可用,降级继续:', errMsg);
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
await this._verifyAuthCertOcsp(gatewayUrl, cert, expectedAid);
|
|
663
|
+
}
|
|
664
|
+
catch (e) {
|
|
665
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
666
|
+
if (/revoked/i.test(errMsg))
|
|
667
|
+
throw e instanceof AuthError ? e : new AuthError(errMsg);
|
|
668
|
+
console.warn('[aun_core.auth] OCSP 检查不可用,降级继续:', errMsg);
|
|
669
|
+
}
|
|
670
|
+
if (cert.subjectCN !== expectedAid) {
|
|
671
|
+
throw new AuthError(`peer cert CN mismatch: expected ${expectedAid}, got ${cert.subjectCN || 'none'}`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// ── 内部方法:短连接 RPC ──────────────────────────
|
|
675
|
+
/** 打开原生 WebSocket,接收 challenge,发送 JSON-RPC,接收响应,关闭 */
|
|
676
|
+
async _shortRpc(gatewayUrl, method, params) {
|
|
677
|
+
return new Promise((resolve, reject) => {
|
|
678
|
+
let ws;
|
|
679
|
+
try {
|
|
680
|
+
ws = new WebSocket(gatewayUrl);
|
|
681
|
+
}
|
|
682
|
+
catch (e) {
|
|
683
|
+
reject(new AuthError(`WebSocket 连接失败: ${gatewayUrl}`));
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
let receivedChallenge = false;
|
|
687
|
+
const timeout = globalThis.setTimeout(() => {
|
|
688
|
+
try {
|
|
689
|
+
ws.close();
|
|
690
|
+
}
|
|
691
|
+
catch { /* 忽略 */ }
|
|
692
|
+
reject(new AuthError(`shortRpc 超时: ${method}`));
|
|
693
|
+
}, 10000);
|
|
694
|
+
ws.onopen = () => { };
|
|
695
|
+
ws.onerror = () => {
|
|
696
|
+
globalThis.clearTimeout(timeout);
|
|
697
|
+
reject(new AuthError(`WebSocket 连接错误: ${gatewayUrl}`));
|
|
698
|
+
};
|
|
699
|
+
ws.onclose = () => {
|
|
700
|
+
if (!receivedChallenge) {
|
|
701
|
+
globalThis.clearTimeout(timeout);
|
|
702
|
+
reject(new AuthError(`WebSocket 在收到 challenge 前关闭`));
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
ws.onmessage = (event) => {
|
|
706
|
+
try {
|
|
707
|
+
const msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
708
|
+
if (!receivedChallenge) {
|
|
709
|
+
// 首条消息是 challenge,忽略并发送 RPC 请求
|
|
710
|
+
receivedChallenge = true;
|
|
711
|
+
ws.send(JSON.stringify({
|
|
712
|
+
jsonrpc: '2.0',
|
|
713
|
+
id: `pre-${method}`,
|
|
714
|
+
method,
|
|
715
|
+
params,
|
|
716
|
+
}));
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
// 第二条消息是 RPC 响应
|
|
720
|
+
globalThis.clearTimeout(timeout);
|
|
721
|
+
try {
|
|
722
|
+
ws.close();
|
|
723
|
+
}
|
|
724
|
+
catch { /* 忽略 */ }
|
|
725
|
+
if (msg.error) {
|
|
726
|
+
reject(mapRemoteError(msg.error));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const result = msg.result;
|
|
730
|
+
if (!isJsonObject(result)) {
|
|
731
|
+
reject(new ValidationError(`invalid pre-auth response for ${method}`));
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (result.success === false) {
|
|
735
|
+
reject(new AuthError(String(result.error ?? `${method} failed`)));
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
resolve(result);
|
|
739
|
+
}
|
|
740
|
+
catch (e) {
|
|
741
|
+
globalThis.clearTimeout(timeout);
|
|
742
|
+
try {
|
|
743
|
+
ws.close();
|
|
744
|
+
}
|
|
745
|
+
catch { /* 忽略 */ }
|
|
746
|
+
reject(e instanceof Error ? e : new AuthError(String(e)));
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
// ── 内部方法:HTTP 请求 ───────────────────────────
|
|
752
|
+
/** fetch GET 返回文本 */
|
|
753
|
+
async _fetchText(url) {
|
|
754
|
+
// 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
|
|
755
|
+
const controller = new AbortController();
|
|
756
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
757
|
+
try {
|
|
758
|
+
const resp = await fetch(url, { signal: controller.signal });
|
|
759
|
+
if (!resp.ok)
|
|
760
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
761
|
+
return await resp.text();
|
|
762
|
+
}
|
|
763
|
+
catch (e) {
|
|
764
|
+
throw new AuthError(`failed to fetch ${url}: ${e}`);
|
|
765
|
+
}
|
|
766
|
+
finally {
|
|
767
|
+
clearTimeout(timeoutId);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/** fetch GET 返回 JSON */
|
|
771
|
+
async _fetchJson(url) {
|
|
772
|
+
// 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
|
|
773
|
+
const controller = new AbortController();
|
|
774
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
775
|
+
try {
|
|
776
|
+
const resp = await fetch(url, { signal: controller.signal });
|
|
777
|
+
if (!resp.ok)
|
|
778
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
779
|
+
const payload = await resp.json();
|
|
780
|
+
if (!isJsonObject(payload)) {
|
|
781
|
+
throw new AuthError(`invalid JSON payload from ${url}`);
|
|
782
|
+
}
|
|
783
|
+
return payload;
|
|
784
|
+
}
|
|
785
|
+
catch (e) {
|
|
786
|
+
if (e instanceof AuthError)
|
|
787
|
+
throw e;
|
|
788
|
+
throw new AuthError(`failed to fetch ${url}: ${e}`);
|
|
789
|
+
}
|
|
790
|
+
finally {
|
|
791
|
+
clearTimeout(timeoutId);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
// ── 内部方法:AID 创建 ───────────────────────────
|
|
795
|
+
async _createAid(gatewayUrl, identity) {
|
|
796
|
+
const response = await this._shortRpc(gatewayUrl, 'auth.create_aid', {
|
|
797
|
+
aid: identity.aid,
|
|
798
|
+
public_key: identity.public_key_der_b64,
|
|
799
|
+
curve: identity.curve ?? 'P-256',
|
|
800
|
+
});
|
|
801
|
+
return { cert: response.cert };
|
|
802
|
+
}
|
|
803
|
+
/** 下载已注册证书恢复本地状态 */
|
|
804
|
+
async _recoverCertViaDownload(gatewayUrl, identity) {
|
|
805
|
+
const certUrl = gatewayHttpUrl(gatewayUrl, `/pki/cert/${identity.aid}`);
|
|
806
|
+
const certPem = await this._fetchText(certUrl);
|
|
807
|
+
if (!certPem || !certPem.includes('BEGIN CERTIFICATE')) {
|
|
808
|
+
throw new AuthError(`failed to download certificate for ${identity.aid}`);
|
|
809
|
+
}
|
|
810
|
+
// 验证下载证书的公钥与本地密钥对匹配
|
|
811
|
+
const downloadedCert = parseCertDer(certPem);
|
|
812
|
+
const downloadedSpkiB64 = uint8ToBase64(downloadedCert.spkiBytes);
|
|
813
|
+
const localPubB64 = String(identity.public_key_der_b64 ?? '');
|
|
814
|
+
if (localPubB64 && downloadedSpkiB64 !== localPubB64) {
|
|
815
|
+
throw new AuthError(`downloaded certificate public key does not match local key pair for ${identity.aid}.`);
|
|
816
|
+
}
|
|
817
|
+
return { ...identity, cert: certPem };
|
|
818
|
+
}
|
|
819
|
+
// ── 内部方法:登录流程 ────────────────────────────
|
|
820
|
+
async _login(gatewayUrl, identity) {
|
|
821
|
+
const clientNonce = this._crypto.newClientNonce();
|
|
822
|
+
// Phase 1: 发送 AID + 证书 + client_nonce
|
|
823
|
+
const phase1 = await this._shortRpc(gatewayUrl, 'auth.aid_login1', {
|
|
824
|
+
aid: identity.aid,
|
|
825
|
+
cert: identity.cert,
|
|
826
|
+
client_nonce: clientNonce,
|
|
827
|
+
});
|
|
828
|
+
// 验证服务端响应(证书链 + client_nonce 签名)
|
|
829
|
+
await this._verifyPhase1Response(gatewayUrl, phase1, clientNonce);
|
|
830
|
+
// Phase 2: 签名 server nonce
|
|
831
|
+
const [signature, clientTime] = await this._crypto.signLoginNonce(identity.private_key_pem, phase1.nonce);
|
|
832
|
+
const phase2 = await this._shortRpc(gatewayUrl, 'auth.aid_login2', {
|
|
833
|
+
aid: identity.aid,
|
|
834
|
+
request_id: phase1.request_id,
|
|
835
|
+
nonce: phase1.nonce,
|
|
836
|
+
client_time: clientTime,
|
|
837
|
+
signature,
|
|
838
|
+
});
|
|
839
|
+
return phase2;
|
|
840
|
+
}
|
|
841
|
+
/** 刷新 access token */
|
|
842
|
+
async _refreshAccessToken(gatewayUrl, refreshToken) {
|
|
843
|
+
const result = await this._shortRpc(gatewayUrl, 'auth.refresh_token', {
|
|
844
|
+
refresh_token: refreshToken,
|
|
845
|
+
});
|
|
846
|
+
if (!result.success) {
|
|
847
|
+
throw new AuthError(String(result.error ?? 'refresh failed'));
|
|
848
|
+
}
|
|
849
|
+
return result;
|
|
850
|
+
}
|
|
851
|
+
/** 初始化 WebSocket 会话(auth.connect RPC) */
|
|
852
|
+
async _initializeSession(transport, nonce, token, opts) {
|
|
853
|
+
const result = await transport.call('auth.connect', {
|
|
854
|
+
nonce,
|
|
855
|
+
auth: { method: 'kite_token', token },
|
|
856
|
+
protocol: { min: '1.0', max: '1.0' },
|
|
857
|
+
device: { id: String(opts?.deviceId ?? this._deviceId ?? ''), type: 'sdk' },
|
|
858
|
+
client: { slot_id: String(opts?.slotId ?? this._slotId ?? '') },
|
|
859
|
+
delivery_mode: opts?.deliveryMode ?? { mode: 'fanout' },
|
|
860
|
+
capabilities: { e2ee: true, group_e2ee: true },
|
|
861
|
+
});
|
|
862
|
+
const status = isJsonObject(result) ? result.status : undefined;
|
|
863
|
+
if (status !== 'ok') {
|
|
864
|
+
throw new AuthError(`initialize failed: ${JSON.stringify(result)}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// ── 内部方法:Phase 1 响应验证 ────────────────────
|
|
868
|
+
async _verifyPhase1Response(gatewayUrl, result, clientNonce) {
|
|
869
|
+
const authCertPem = String(result.auth_cert ?? '');
|
|
870
|
+
const signatureB64 = String(result.client_nonce_signature ?? '');
|
|
871
|
+
if (!authCertPem)
|
|
872
|
+
throw new AuthError('aid_login1 missing auth_cert');
|
|
873
|
+
if (!signatureB64)
|
|
874
|
+
throw new AuthError('aid_login1 missing client_nonce_signature');
|
|
875
|
+
let authCert;
|
|
876
|
+
try {
|
|
877
|
+
authCert = parseCertDer(authCertPem);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
throw new AuthError('aid_login1 returned invalid auth_cert');
|
|
881
|
+
}
|
|
882
|
+
// 验证证书链
|
|
883
|
+
await this._verifyAuthCertChain(gatewayUrl, authCert);
|
|
884
|
+
// 验证 CRL
|
|
885
|
+
try {
|
|
886
|
+
await this._verifyAuthCertRevocation(gatewayUrl, authCert);
|
|
887
|
+
}
|
|
888
|
+
catch (e) {
|
|
889
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
890
|
+
if (/revoked/i.test(errMsg))
|
|
891
|
+
throw e instanceof AuthError ? e : new AuthError(errMsg);
|
|
892
|
+
console.warn('[aun_core.auth] CRL 检查不可用,降级继续:', errMsg);
|
|
893
|
+
}
|
|
894
|
+
// 验证 OCSP
|
|
895
|
+
try {
|
|
896
|
+
await this._verifyAuthCertOcsp(gatewayUrl, authCert);
|
|
897
|
+
}
|
|
898
|
+
catch (e) {
|
|
899
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
900
|
+
if (/revoked/i.test(errMsg))
|
|
901
|
+
throw e instanceof AuthError ? e : new AuthError(errMsg);
|
|
902
|
+
console.warn('[aun_core.auth] OCSP 检查不可用,降级继续:', errMsg);
|
|
903
|
+
}
|
|
904
|
+
// 验证 client_nonce 签名
|
|
905
|
+
try {
|
|
906
|
+
const sigBytes = base64ToUint8(signatureB64);
|
|
907
|
+
const dataBytes = new TextEncoder().encode(clientNonce);
|
|
908
|
+
const valid = await verifyEcdsaSignature(authCert.spkiBytes, sigBytes, dataBytes);
|
|
909
|
+
if (!valid)
|
|
910
|
+
throw new Error('signature invalid');
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
throw new AuthError('aid_login1 server auth signature verification failed');
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// ── 内部方法:证书链验证 ──────────────────────────
|
|
917
|
+
async _verifyAuthCertChain(gatewayUrl, authCert, chainAid = '') {
|
|
918
|
+
// 检查缓存
|
|
919
|
+
const cachedAt = this._chainVerifiedCache.get(authCert.serialHex);
|
|
920
|
+
const now = Date.now() / 1000;
|
|
921
|
+
if (cachedAt && now - cachedAt < this._chainCacheTtl)
|
|
922
|
+
return;
|
|
923
|
+
ensureCertTimeValid(authCert, 'auth certificate', now);
|
|
924
|
+
const chain = await this._loadGatewayCaChain(gatewayUrl, chainAid);
|
|
925
|
+
if (!chain.length) {
|
|
926
|
+
throw new AuthError('unable to verify auth certificate chain: missing CA chain');
|
|
927
|
+
}
|
|
928
|
+
const cacheKey = chainAid ? `${gatewayUrl}:${chainAid}` : gatewayUrl;
|
|
929
|
+
// 快速路径:CA 链已预验证过
|
|
930
|
+
if (this._gatewayCaVerified.get(cacheKey)) {
|
|
931
|
+
const issuer = chain[0];
|
|
932
|
+
ensureCertTimeValid(issuer, 'Issuer CA', now);
|
|
933
|
+
if (!issuer.isCA)
|
|
934
|
+
throw new AuthError('Issuer CA is not marked as CA (fast path)');
|
|
935
|
+
if (!arraysEqual(authCert.issuerRaw, issuer.subjectRaw)) {
|
|
936
|
+
throw new AuthError('auth certificate issuer mismatch');
|
|
937
|
+
}
|
|
938
|
+
await verifyCertSignature(issuer.spkiBytes, authCert);
|
|
939
|
+
this._chainVerifiedCache.set(authCert.serialHex, Date.now() / 1000);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
// 完整验证路径
|
|
943
|
+
let current = authCert;
|
|
944
|
+
for (let i = 0; i < chain.length; i++) {
|
|
945
|
+
const caCert = chain[i];
|
|
946
|
+
ensureCertTimeValid(caCert, `CA certificate[${i}]`, now);
|
|
947
|
+
if (!arraysEqual(current.issuerRaw, caCert.subjectRaw)) {
|
|
948
|
+
throw new AuthError(`auth certificate issuer mismatch at chain level ${i}`);
|
|
949
|
+
}
|
|
950
|
+
if (!caCert.isCA) {
|
|
951
|
+
throw new AuthError(`CA certificate[${i}] is not marked as CA`);
|
|
952
|
+
}
|
|
953
|
+
await verifyCertSignature(caCert.spkiBytes, current);
|
|
954
|
+
current = caCert;
|
|
955
|
+
}
|
|
956
|
+
// 验证根证书自签名
|
|
957
|
+
const root = chain[chain.length - 1];
|
|
958
|
+
if (!arraysEqual(root.issuerRaw, root.subjectRaw)) {
|
|
959
|
+
throw new AuthError('auth certificate chain root is not self-signed');
|
|
960
|
+
}
|
|
961
|
+
await verifyCertSignature(root.spkiBytes, root);
|
|
962
|
+
// 验证根证书在受信列表中
|
|
963
|
+
const trustedRoots = this._loadTrustedRoots();
|
|
964
|
+
const rootSpkiB64 = uint8ToBase64(root.spkiBytes);
|
|
965
|
+
const isTrusted = trustedRoots.some((trusted) => uint8ToBase64(trusted.spkiBytes) === rootSpkiB64
|
|
966
|
+
&& trusted.subjectCN === root.subjectCN);
|
|
967
|
+
if (!isTrusted) {
|
|
968
|
+
throw new AuthError('auth certificate chain is not anchored by a trusted root');
|
|
969
|
+
}
|
|
970
|
+
this._chainVerifiedCache.set(authCert.serialHex, Date.now() / 1000);
|
|
971
|
+
this._gatewayCaVerified.set(cacheKey, true);
|
|
972
|
+
}
|
|
973
|
+
/** 加载 Gateway CA 链(带缓存) */
|
|
974
|
+
async _loadGatewayCaChain(gatewayUrl, chainAid = '') {
|
|
975
|
+
const cacheKey = chainAid ? `${gatewayUrl}:${chainAid}` : gatewayUrl;
|
|
976
|
+
let cached = this._gatewayChainCache.get(cacheKey);
|
|
977
|
+
if (!cached) {
|
|
978
|
+
cached = await this._fetchGatewayCaChain(gatewayUrl);
|
|
979
|
+
this._gatewayChainCache.set(cacheKey, cached);
|
|
980
|
+
}
|
|
981
|
+
return cached.map((pem) => parseCertDer(pem));
|
|
982
|
+
}
|
|
983
|
+
/** 从 Gateway PKI 端点下载 CA 链 */
|
|
984
|
+
async _fetchGatewayCaChain(gatewayUrl) {
|
|
985
|
+
const url = gatewayHttpUrl(gatewayUrl, '/pki/chain');
|
|
986
|
+
const text = await this._fetchText(url);
|
|
987
|
+
return splitPemBundle(text);
|
|
988
|
+
}
|
|
989
|
+
// ── 内部方法:CRL 验证 ────────────────────────────
|
|
990
|
+
async _verifyAuthCertRevocation(gatewayUrl, authCert, chainAid = '') {
|
|
991
|
+
const chain = await this._loadGatewayCaChain(gatewayUrl, chainAid);
|
|
992
|
+
if (!chain.length) {
|
|
993
|
+
throw new AuthError('unable to verify auth certificate revocation: missing issuer certificate');
|
|
994
|
+
}
|
|
995
|
+
// 跨域场景:CRL 请求发到 peer 所在域
|
|
996
|
+
let crlGatewayUrl = gatewayUrl;
|
|
997
|
+
if (chainAid && chainAid.includes('.')) {
|
|
998
|
+
const peerIssuer = chainAid.split('.').slice(1).join('.');
|
|
999
|
+
const match = gatewayUrl.match(/gateway\.([^:/]+)/);
|
|
1000
|
+
const localIssuer = match ? match[1] : '';
|
|
1001
|
+
if (localIssuer && peerIssuer !== localIssuer) {
|
|
1002
|
+
crlGatewayUrl = gatewayUrl.replace(`gateway.${localIssuer}`, `gateway.${peerIssuer}`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
const revokedSerials = await this._loadGatewayRevokedSerials(crlGatewayUrl, chain[0]);
|
|
1006
|
+
if (revokedSerials.has(authCert.serialHex.toLowerCase())) {
|
|
1007
|
+
throw new AuthError('auth certificate has been revoked');
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
/** 加载 Gateway 吊销列表(带缓存) */
|
|
1011
|
+
async _loadGatewayRevokedSerials(gatewayUrl, issuerCert) {
|
|
1012
|
+
const cached = this._gatewayCrlCache.get(gatewayUrl);
|
|
1013
|
+
const now = Date.now() / 1000;
|
|
1014
|
+
if (cached && cached.nextRefreshAt > now) {
|
|
1015
|
+
return cached.revokedSerials;
|
|
1016
|
+
}
|
|
1017
|
+
const fresh = await this._fetchGatewayCrl(gatewayUrl, issuerCert);
|
|
1018
|
+
this._gatewayCrlCache.set(gatewayUrl, fresh);
|
|
1019
|
+
return fresh.revokedSerials;
|
|
1020
|
+
}
|
|
1021
|
+
/** 从 Gateway PKI 端点获取并验证 CRL */
|
|
1022
|
+
async _fetchGatewayCrl(gatewayUrl, issuerCert) {
|
|
1023
|
+
const url = gatewayHttpUrl(gatewayUrl, '/pki/crl.json');
|
|
1024
|
+
const payload = await this._fetchJson(url);
|
|
1025
|
+
// 简化 CRL 验证:JSON 格式 CRL
|
|
1026
|
+
// payload 包含 revoked_serials 数组和签名
|
|
1027
|
+
const revokedList = payload.revoked_serials;
|
|
1028
|
+
const revokedSerials = new Set();
|
|
1029
|
+
if (Array.isArray(revokedList)) {
|
|
1030
|
+
for (const s of revokedList) {
|
|
1031
|
+
revokedSerials.add(String(s).toLowerCase());
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
// 验证 CRL 签名(如果有 crl_pem)
|
|
1035
|
+
const crlPem = String(payload.crl_pem ?? '');
|
|
1036
|
+
if (crlPem) {
|
|
1037
|
+
// CRL PEM 中包含签名信息,由签发者签名
|
|
1038
|
+
// 浏览器端简化处理:验证签名数据和签发者公钥
|
|
1039
|
+
const signatureB64 = String(payload.signature ?? '');
|
|
1040
|
+
const signedDataB64 = String(payload.signed_data ?? '');
|
|
1041
|
+
if (signatureB64 && signedDataB64) {
|
|
1042
|
+
const sigBytes = base64ToUint8(signatureB64);
|
|
1043
|
+
const dataBytes = base64ToUint8(signedDataB64);
|
|
1044
|
+
const valid = await verifyEcdsaSignature(issuerCert.spkiBytes, sigBytes, dataBytes);
|
|
1045
|
+
if (!valid)
|
|
1046
|
+
throw new AuthError('gateway CRL signature verification failed');
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
// 过期时间(最多缓存 24 小时)
|
|
1050
|
+
const now = Date.now() / 1000;
|
|
1051
|
+
let nextRefreshAt = Number(payload.next_update ?? 0);
|
|
1052
|
+
if (!nextRefreshAt)
|
|
1053
|
+
nextRefreshAt = now + 300;
|
|
1054
|
+
nextRefreshAt = Math.min(nextRefreshAt, now + 86400);
|
|
1055
|
+
return { revokedSerials, nextRefreshAt };
|
|
1056
|
+
}
|
|
1057
|
+
// ── 内部方法:OCSP 验证 ───────────────────────────
|
|
1058
|
+
async _verifyAuthCertOcsp(gatewayUrl, authCert, chainAid = '') {
|
|
1059
|
+
const chain = await this._loadGatewayCaChain(gatewayUrl, chainAid);
|
|
1060
|
+
if (!chain.length) {
|
|
1061
|
+
throw new AuthError('unable to verify auth certificate OCSP status: missing issuer certificate');
|
|
1062
|
+
}
|
|
1063
|
+
const status = await this._loadGatewayOcspStatus(gatewayUrl, authCert, chain[0]);
|
|
1064
|
+
if (status === 'revoked')
|
|
1065
|
+
throw new AuthError('auth certificate OCSP status is revoked');
|
|
1066
|
+
if (status !== 'good')
|
|
1067
|
+
throw new AuthError(`auth certificate OCSP status is ${status}`);
|
|
1068
|
+
}
|
|
1069
|
+
/** 加载 OCSP 状态(带缓存) */
|
|
1070
|
+
async _loadGatewayOcspStatus(gatewayUrl, authCert, issuerCert) {
|
|
1071
|
+
const serialHex = authCert.serialHex.toLowerCase();
|
|
1072
|
+
let gatewayCache = this._gatewayOcspCache.get(gatewayUrl);
|
|
1073
|
+
if (!gatewayCache) {
|
|
1074
|
+
gatewayCache = new Map();
|
|
1075
|
+
this._gatewayOcspCache.set(gatewayUrl, gatewayCache);
|
|
1076
|
+
}
|
|
1077
|
+
const cached = gatewayCache.get(serialHex);
|
|
1078
|
+
const now = Date.now() / 1000;
|
|
1079
|
+
if (cached && cached.nextRefreshAt > now) {
|
|
1080
|
+
return cached.status;
|
|
1081
|
+
}
|
|
1082
|
+
const fresh = await this._fetchGatewayOcspStatus(gatewayUrl, authCert, issuerCert);
|
|
1083
|
+
gatewayCache.set(serialHex, fresh);
|
|
1084
|
+
return fresh.status;
|
|
1085
|
+
}
|
|
1086
|
+
/** 从 Gateway PKI 端点获取并验证 OCSP 状态 */
|
|
1087
|
+
async _fetchGatewayOcspStatus(gatewayUrl, authCert, issuerCert) {
|
|
1088
|
+
const serialHex = authCert.serialHex.toLowerCase();
|
|
1089
|
+
const url = gatewayHttpUrl(gatewayUrl, `/pki/ocsp/${serialHex}`);
|
|
1090
|
+
const payload = await this._fetchJson(url);
|
|
1091
|
+
const status = String(payload.status ?? '');
|
|
1092
|
+
const ocspB64 = String(payload.ocsp_response ?? '');
|
|
1093
|
+
if (!ocspB64)
|
|
1094
|
+
throw new AuthError('gateway OCSP endpoint returned no ocsp_response');
|
|
1095
|
+
// 简化 OCSP 验证:检查 JSON 响应中的签名
|
|
1096
|
+
const signatureB64 = String(payload.signature ?? '');
|
|
1097
|
+
const signedDataB64 = String(payload.signed_data ?? '');
|
|
1098
|
+
if (signatureB64 && signedDataB64) {
|
|
1099
|
+
const sigBytes = base64ToUint8(signatureB64);
|
|
1100
|
+
const dataBytes = base64ToUint8(signedDataB64);
|
|
1101
|
+
const valid = await verifyEcdsaSignature(issuerCert.spkiBytes, sigBytes, dataBytes);
|
|
1102
|
+
if (!valid)
|
|
1103
|
+
throw new AuthError('gateway OCSP signature verification failed');
|
|
1104
|
+
}
|
|
1105
|
+
// 检查 serial 匹配
|
|
1106
|
+
const respSerial = String(payload.serial ?? '').toLowerCase();
|
|
1107
|
+
if (respSerial && respSerial !== serialHex) {
|
|
1108
|
+
throw new AuthError('gateway OCSP response serial mismatch');
|
|
1109
|
+
}
|
|
1110
|
+
const effectiveStatus = status || 'unknown';
|
|
1111
|
+
const now = Date.now() / 1000;
|
|
1112
|
+
let nextRefreshAt = Number(payload.next_update ?? 0);
|
|
1113
|
+
if (!nextRefreshAt)
|
|
1114
|
+
nextRefreshAt = now + 300;
|
|
1115
|
+
nextRefreshAt = Math.min(nextRefreshAt, now + 86400);
|
|
1116
|
+
return { status: effectiveStatus, nextRefreshAt };
|
|
1117
|
+
}
|
|
1118
|
+
// ── 内部方法:受信根证书 ──────────────────────────
|
|
1119
|
+
_loadTrustedRoots() {
|
|
1120
|
+
if (this._rootCerts)
|
|
1121
|
+
return this._rootCerts;
|
|
1122
|
+
const roots = [];
|
|
1123
|
+
// 加载内置根证书
|
|
1124
|
+
if (ROOT_CA_PEM) {
|
|
1125
|
+
for (const pem of splitPemBundle(ROOT_CA_PEM)) {
|
|
1126
|
+
try {
|
|
1127
|
+
roots.push(parseCertDer(pem));
|
|
1128
|
+
}
|
|
1129
|
+
catch { /* 跳过无法解析的证书 */ }
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
// 加载用户指定的根证书
|
|
1133
|
+
if (this._rootCaPem) {
|
|
1134
|
+
for (const pem of splitPemBundle(this._rootCaPem)) {
|
|
1135
|
+
try {
|
|
1136
|
+
const cert = parseCertDer(pem);
|
|
1137
|
+
// 去重
|
|
1138
|
+
const spkiB64 = uint8ToBase64(cert.spkiBytes);
|
|
1139
|
+
if (!roots.some((r) => uint8ToBase64(r.spkiBytes) === spkiB64)) {
|
|
1140
|
+
roots.push(cert);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
catch { /* 跳过 */ }
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
if (!roots.length) {
|
|
1147
|
+
throw new AuthError('no trusted roots available for auth certificate verification');
|
|
1148
|
+
}
|
|
1149
|
+
this._rootCerts = roots;
|
|
1150
|
+
return roots;
|
|
1151
|
+
}
|
|
1152
|
+
// ── 内部方法:Token 管理 ──────────────────────────
|
|
1153
|
+
_rememberTokens(identity, authResult) {
|
|
1154
|
+
const accessToken = authResult.access_token ?? authResult.token ?? authResult.kite_token;
|
|
1155
|
+
const refreshToken = authResult.refresh_token;
|
|
1156
|
+
const expiresIn = authResult.expires_in;
|
|
1157
|
+
if (typeof accessToken === 'string' && accessToken)
|
|
1158
|
+
identity.access_token = accessToken;
|
|
1159
|
+
if (typeof refreshToken === 'string' && refreshToken)
|
|
1160
|
+
identity.refresh_token = refreshToken;
|
|
1161
|
+
if (typeof authResult.token === 'string' && authResult.token)
|
|
1162
|
+
identity.kite_token = authResult.token;
|
|
1163
|
+
if (typeof expiresIn === 'number') {
|
|
1164
|
+
identity.access_token_expires_at = Math.floor(Date.now() / 1000 + expiresIn);
|
|
1165
|
+
}
|
|
1166
|
+
// login2 响应含 new_cert 时(证书过半自动续期),暂存待验证
|
|
1167
|
+
const newCert = authResult.new_cert;
|
|
1168
|
+
if (typeof newCert === 'string' && newCert)
|
|
1169
|
+
identity._pending_new_cert = newCert;
|
|
1170
|
+
// 服务端返回 active_cert 用于同步本地 cert.pem
|
|
1171
|
+
const activeCert = authResult.active_cert;
|
|
1172
|
+
if (typeof activeCert === 'string' && activeCert)
|
|
1173
|
+
identity._pending_active_cert = activeCert;
|
|
1174
|
+
}
|
|
1175
|
+
/** 验证服务端返回的 new_cert,通过后正式接受 */
|
|
1176
|
+
async _validateNewCert(identity, gatewayUrl = '') {
|
|
1177
|
+
const newCertPem = identity._pending_new_cert;
|
|
1178
|
+
delete identity._pending_new_cert;
|
|
1179
|
+
if (!newCertPem || typeof newCertPem !== 'string')
|
|
1180
|
+
return;
|
|
1181
|
+
try {
|
|
1182
|
+
const cert = parseCertDer(newCertPem);
|
|
1183
|
+
const aid = String(identity.aid ?? '');
|
|
1184
|
+
// 1. CN 必须匹配当前 AID
|
|
1185
|
+
if (cert.subjectCN !== aid) {
|
|
1186
|
+
throw new AuthError(`new_cert CN mismatch: expected ${aid}, got ${cert.subjectCN || 'none'}`);
|
|
1187
|
+
}
|
|
1188
|
+
// 2. 公钥必须匹配本地私钥
|
|
1189
|
+
const localPubB64 = String(identity.public_key_der_b64 ?? '');
|
|
1190
|
+
if (localPubB64) {
|
|
1191
|
+
const certSpkiB64 = uint8ToBase64(cert.spkiBytes);
|
|
1192
|
+
if (certSpkiB64 !== localPubB64) {
|
|
1193
|
+
throw new AuthError('new_cert public key does not match local identity key');
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
// 3. 时间有效性
|
|
1197
|
+
ensureCertTimeValid(cert, 'new_cert', Date.now() / 1000);
|
|
1198
|
+
// 4. 完整证书链验证 + CRL/OCSP
|
|
1199
|
+
if (gatewayUrl) {
|
|
1200
|
+
await this._verifyAuthCertChain(gatewayUrl, cert);
|
|
1201
|
+
try {
|
|
1202
|
+
await this._verifyAuthCertRevocation(gatewayUrl, cert);
|
|
1203
|
+
}
|
|
1204
|
+
catch (e) {
|
|
1205
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
1206
|
+
if (/revoked/i.test(errMsg))
|
|
1207
|
+
throw e instanceof AuthError ? e : new AuthError(errMsg);
|
|
1208
|
+
console.warn('[aun_core.auth] CRL 检查不可用,降级继续:', errMsg);
|
|
1209
|
+
}
|
|
1210
|
+
try {
|
|
1211
|
+
await this._verifyAuthCertOcsp(gatewayUrl, cert);
|
|
1212
|
+
}
|
|
1213
|
+
catch (e) {
|
|
1214
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
1215
|
+
if (/revoked/i.test(errMsg))
|
|
1216
|
+
throw e instanceof AuthError ? e : new AuthError(errMsg);
|
|
1217
|
+
console.warn('[aun_core.auth] OCSP 检查不可用,降级继续:', errMsg);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
// 验证通过,正式接受
|
|
1221
|
+
identity.cert = newCertPem;
|
|
1222
|
+
}
|
|
1223
|
+
catch (e) {
|
|
1224
|
+
// 验证失败,静默拒绝(不影响主流程)
|
|
1225
|
+
console.warn(`拒绝服务端返回的 new_cert (${identity.aid}):`, e);
|
|
1226
|
+
}
|
|
1227
|
+
// active_cert 同步:验证公钥匹配后更新本地 cert
|
|
1228
|
+
const activeCertPem = identity._pending_active_cert;
|
|
1229
|
+
delete identity._pending_active_cert;
|
|
1230
|
+
if (typeof activeCertPem === 'string' && activeCertPem) {
|
|
1231
|
+
try {
|
|
1232
|
+
const actCert = parseCertDer(activeCertPem);
|
|
1233
|
+
const localPubB64 = String(identity.public_key_der_b64 ?? '');
|
|
1234
|
+
if (localPubB64) {
|
|
1235
|
+
const actSpkiB64 = uint8ToBase64(actCert.spkiBytes);
|
|
1236
|
+
if (actSpkiB64 === localPubB64) {
|
|
1237
|
+
identity.cert = activeCertPem;
|
|
1238
|
+
}
|
|
1239
|
+
else {
|
|
1240
|
+
console.warn(`[auth] 服务端 active_cert 公钥与本地私钥不匹配,拒绝同步 (aid=${identity.aid})`);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
catch (e) {
|
|
1245
|
+
console.warn(`[auth] active_cert 同步异常 (${identity.aid}):`, e);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
/** 获取缓存的有效 access_token */
|
|
1250
|
+
_getCachedAccessToken(identity) {
|
|
1251
|
+
const accessToken = String(identity.access_token ?? '');
|
|
1252
|
+
if (!accessToken)
|
|
1253
|
+
return '';
|
|
1254
|
+
const expiresAt = identity.access_token_expires_at;
|
|
1255
|
+
if (typeof expiresAt === 'number' && expiresAt <= Date.now() / 1000 + 30) {
|
|
1256
|
+
return '';
|
|
1257
|
+
}
|
|
1258
|
+
return accessToken;
|
|
1259
|
+
}
|
|
1260
|
+
// ── 内部方法:身份管理 ────────────────────────────
|
|
1261
|
+
// AID name 验证:4-64 字符,仅 [a-z0-9_-],首字符不为 -,不以 guest 开头
|
|
1262
|
+
static _AID_NAME_RE = /^[a-z0-9_][a-z0-9_-]{3,63}$/;
|
|
1263
|
+
static _validateAidName(aid) {
|
|
1264
|
+
const name = aid.includes('.') ? aid.split('.')[0] : aid;
|
|
1265
|
+
if (!AuthFlow._AID_NAME_RE.test(name)) {
|
|
1266
|
+
throw new ValidationError(`Invalid AID name '${name}': must be 4-64 characters, only [a-z0-9_-], cannot start with '-'`);
|
|
1267
|
+
}
|
|
1268
|
+
if (name.startsWith('guest')) {
|
|
1269
|
+
throw new ValidationError("AID name must not start with 'guest'");
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
/** 确保本地有密钥对(没有则生成) */
|
|
1273
|
+
async _ensureLocalIdentity(aid) {
|
|
1274
|
+
const existing = await this._keystore.loadIdentity(aid);
|
|
1275
|
+
if (existing) {
|
|
1276
|
+
this._aid = aid;
|
|
1277
|
+
return existing;
|
|
1278
|
+
}
|
|
1279
|
+
const identity = await this._crypto.generateIdentity();
|
|
1280
|
+
identity.aid = aid;
|
|
1281
|
+
await this._persistIdentity(identity);
|
|
1282
|
+
this._aid = aid;
|
|
1283
|
+
return identity;
|
|
1284
|
+
}
|
|
1285
|
+
/** 加载身份,不存在时抛出异常 */
|
|
1286
|
+
async _loadIdentityOrRaise(aid) {
|
|
1287
|
+
const requestedAid = aid ?? this._aid;
|
|
1288
|
+
if (requestedAid) {
|
|
1289
|
+
const existing = await this._keystore.loadIdentity(requestedAid);
|
|
1290
|
+
if (!existing) {
|
|
1291
|
+
throw new StateError(`identity not found for aid: ${requestedAid}`);
|
|
1292
|
+
}
|
|
1293
|
+
this._aid = requestedAid;
|
|
1294
|
+
if (!existing.aid)
|
|
1295
|
+
existing.aid = requestedAid;
|
|
1296
|
+
return existing;
|
|
1297
|
+
}
|
|
1298
|
+
throw new StateError('no local identity found, call auth.createAid() first');
|
|
1299
|
+
}
|
|
1300
|
+
/** 确保有身份(无则尝试生成) */
|
|
1301
|
+
async _ensureIdentity() {
|
|
1302
|
+
try {
|
|
1303
|
+
return await this._loadIdentityOrRaise();
|
|
1304
|
+
}
|
|
1305
|
+
catch {
|
|
1306
|
+
if (!this._aid) {
|
|
1307
|
+
throw new StateError('no local identity found, call auth.createAid() first');
|
|
1308
|
+
}
|
|
1309
|
+
const identity = await this._crypto.generateIdentity();
|
|
1310
|
+
identity.aid = this._aid;
|
|
1311
|
+
await this._persistIdentity(identity);
|
|
1312
|
+
return identity;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
async _loadInstanceState(aid) {
|
|
1316
|
+
if (!this._deviceId || typeof this._keystore.loadInstanceState !== 'function') {
|
|
1317
|
+
return null;
|
|
1318
|
+
}
|
|
1319
|
+
return (await this._keystore.loadInstanceState(aid, this._deviceId, this._slotId));
|
|
1320
|
+
}
|
|
1321
|
+
async _persistIdentity(identity) {
|
|
1322
|
+
const aid = String(identity.aid ?? '');
|
|
1323
|
+
if (!aid) {
|
|
1324
|
+
throw new StateError('identity missing aid');
|
|
1325
|
+
}
|
|
1326
|
+
const persisted = { ...identity };
|
|
1327
|
+
const instanceState = {};
|
|
1328
|
+
const instanceStateRecord = instanceState;
|
|
1329
|
+
const persistedRecord = persisted;
|
|
1330
|
+
for (const key of AuthFlow._INSTANCE_STATE_FIELDS) {
|
|
1331
|
+
if (key in persisted) {
|
|
1332
|
+
instanceStateRecord[key] = persistedRecord[key];
|
|
1333
|
+
delete persistedRecord[key];
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
await this._keystore.saveIdentity(aid, persisted);
|
|
1337
|
+
if (!this._deviceId) {
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
// 实例级字段已拆分到 instance_state,无需从共享 metadata 清理
|
|
1341
|
+
if (Object.keys(instanceState).length === 0 || typeof this._keystore.updateInstanceState !== 'function') {
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
await this._keystore.updateInstanceState(aid, this._deviceId, this._slotId, (current) => {
|
|
1345
|
+
Object.assign(current, instanceState);
|
|
1346
|
+
return current;
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
/** 清理过期的 gateway 缓存条目(供外部定时调用) */
|
|
1350
|
+
cleanExpiredCaches() {
|
|
1351
|
+
const now = Date.now();
|
|
1352
|
+
for (const [k, v] of this._gatewayCrlCache) {
|
|
1353
|
+
if (v.nextRefreshAt <= now)
|
|
1354
|
+
this._gatewayCrlCache.delete(k);
|
|
1355
|
+
}
|
|
1356
|
+
for (const [k, v] of this._gatewayOcspCache) {
|
|
1357
|
+
for (const [serial, entry] of v) {
|
|
1358
|
+
if (entry.nextRefreshAt <= now)
|
|
1359
|
+
v.delete(serial);
|
|
1360
|
+
}
|
|
1361
|
+
if (v.size === 0)
|
|
1362
|
+
this._gatewayOcspCache.delete(k);
|
|
1363
|
+
}
|
|
1364
|
+
for (const [k, v] of this._chainVerifiedCache) {
|
|
1365
|
+
if (now - v >= 300_000)
|
|
1366
|
+
this._chainVerifiedCache.delete(k);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
// ── 工具函数 ────────────────────────────────────────────
|
|
1371
|
+
/** 检查证书时间有效性 */
|
|
1372
|
+
function ensureCertTimeValid(cert, label, now) {
|
|
1373
|
+
if (now + CERT_CLOCK_SKEW_SECONDS < cert.notBefore)
|
|
1374
|
+
throw new AuthError(`${label} is not yet valid`);
|
|
1375
|
+
if (now - CERT_CLOCK_SKEW_SECONDS > cert.notAfter)
|
|
1376
|
+
throw new AuthError(`${label} has expired`);
|
|
1377
|
+
}
|
|
1378
|
+
/** 比较两个 Uint8Array 是否相等 */
|
|
1379
|
+
function arraysEqual(a, b) {
|
|
1380
|
+
if (a.length !== b.length)
|
|
1381
|
+
return false;
|
|
1382
|
+
for (let i = 0; i < a.length; i++) {
|
|
1383
|
+
if (a[i] !== b[i])
|
|
1384
|
+
return false;
|
|
1385
|
+
}
|
|
1386
|
+
return true;
|
|
1387
|
+
}
|
|
1388
|
+
//# sourceMappingURL=auth.js.map
|