@askalf/dario 3.29.0 → 3.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -34
- package/dist/cc-oauth-detect.d.ts +5 -1
- package/dist/cc-oauth-detect.js +78 -9
- package/dist/cc-template-data.json +21 -25
- package/dist/live-fingerprint.d.ts +1 -1
- package/dist/live-fingerprint.js +1 -1
- package/dist/proxy.d.ts +3 -5
- package/dist/proxy.js +16 -187
- package/package.json +2 -2
- package/dist/sealed-pool.d.ts +0 -202
- package/dist/sealed-pool.js +0 -416
package/dist/sealed-pool.js
DELETED
|
@@ -1,416 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sealed-sender overflow pool — RSA blind signatures for unlinkable capacity
|
|
3
|
-
* sharing inside a trust group.
|
|
4
|
-
*
|
|
5
|
-
* The problem this solves: in a federated pool where several friends lend
|
|
6
|
-
* each other account capacity, a naive design leaks who is borrowing what.
|
|
7
|
-
* If member A sends "I'm borrowing from your pool" to member B's dario
|
|
8
|
-
* instance, B learns exactly which of their friends is running which
|
|
9
|
-
* workload — and over time B can build a pretty detailed surveillance log
|
|
10
|
-
* of everyone else's agent sessions. That's the opposite of what a private
|
|
11
|
-
* friend pool should provide.
|
|
12
|
-
*
|
|
13
|
-
* The solution is Chaum's 1983 blind signature construction. A trusted
|
|
14
|
-
* admin (one member of the group, selected by social consensus) issues
|
|
15
|
-
* signed borrow tokens to each member. The admin never sees the token
|
|
16
|
-
* values — they sign blinded values, and the blinding is unlinkable at
|
|
17
|
-
* the cryptographic level. When a member sends a token to a lender, the
|
|
18
|
-
* lender can verify "this was signed by the group admin" without learning
|
|
19
|
-
* WHICH member holds that token. The lender sees a valid group credential
|
|
20
|
-
* and nothing more.
|
|
21
|
-
*
|
|
22
|
-
* From Anthropic's perspective nothing changes: the request still hits
|
|
23
|
-
* their API under the lender's identity, fully attributable to a real
|
|
24
|
-
* paying Max subscriber. The privacy property is entirely INSIDE the
|
|
25
|
-
* trust group — no member can surveil another member's usage through
|
|
26
|
-
* the pool layer.
|
|
27
|
-
*
|
|
28
|
-
* What this is NOT: this is not anonymity from Anthropic, not onion
|
|
29
|
-
* routing, not credential laundering. It is a privacy layer on top of
|
|
30
|
-
* a legitimate friends-pool arrangement. Members opt in, the admin is
|
|
31
|
-
* known, membership is revocable by rotating the group key. It's the
|
|
32
|
-
* same trust model as a family Netflix account, with unlinkability as
|
|
33
|
-
* a feature for the pool's internal telemetry.
|
|
34
|
-
*
|
|
35
|
-
* Implementation notes:
|
|
36
|
-
* - RSA-2048 with FDH (full-domain hash) padding via MGF1-SHA256.
|
|
37
|
-
* - Node's crypto.publicEncrypt / privateDecrypt with RSA_NO_PADDING
|
|
38
|
-
* for raw RSA operations. All modular arithmetic happens in BigInt.
|
|
39
|
-
* - Tokens are 32 random bytes each, single-use (lender tracks SHA-256
|
|
40
|
-
* hashes of seen tokens to prevent double-spend).
|
|
41
|
-
* - Admin does not need to be online for members to use tokens. Admin
|
|
42
|
-
* only runs when issuing a new batch (typically once per day/week).
|
|
43
|
-
*/
|
|
44
|
-
import { generateKeyPairSync, publicEncrypt, privateDecrypt, constants, createPublicKey, randomBytes, createHash, } from 'node:crypto';
|
|
45
|
-
// ======================================================================
|
|
46
|
-
// BigInt / buffer helpers
|
|
47
|
-
// ======================================================================
|
|
48
|
-
function bigintToBytes(n, len) {
|
|
49
|
-
let hex = n.toString(16);
|
|
50
|
-
if (hex.length % 2)
|
|
51
|
-
hex = '0' + hex;
|
|
52
|
-
const buf = Buffer.from(hex, 'hex');
|
|
53
|
-
if (buf.length > len)
|
|
54
|
-
throw new Error('value too large for buffer');
|
|
55
|
-
if (buf.length === len)
|
|
56
|
-
return buf;
|
|
57
|
-
const padded = Buffer.alloc(len);
|
|
58
|
-
buf.copy(padded, len - buf.length);
|
|
59
|
-
return padded;
|
|
60
|
-
}
|
|
61
|
-
function bytesToBigint(buf) {
|
|
62
|
-
if (buf.length === 0)
|
|
63
|
-
return 0n;
|
|
64
|
-
return BigInt('0x' + buf.toString('hex'));
|
|
65
|
-
}
|
|
66
|
-
function egcd(a, b) {
|
|
67
|
-
let [oldR, r] = [a, b];
|
|
68
|
-
let [oldS, s] = [1n, 0n];
|
|
69
|
-
let [oldT, t] = [0n, 1n];
|
|
70
|
-
while (r !== 0n) {
|
|
71
|
-
const q = oldR / r;
|
|
72
|
-
[oldR, r] = [r, oldR - q * r];
|
|
73
|
-
[oldS, s] = [s, oldS - q * s];
|
|
74
|
-
[oldT, t] = [t, oldT - q * t];
|
|
75
|
-
}
|
|
76
|
-
return [oldR, oldS, oldT];
|
|
77
|
-
}
|
|
78
|
-
function modInverse(a, n) {
|
|
79
|
-
const norm = ((a % n) + n) % n;
|
|
80
|
-
const [g, x] = egcd(norm, n);
|
|
81
|
-
if (g !== 1n)
|
|
82
|
-
throw new Error('no modular inverse');
|
|
83
|
-
return ((x % n) + n) % n;
|
|
84
|
-
}
|
|
85
|
-
// ======================================================================
|
|
86
|
-
// Full-domain hash (FDH) for RSA blind signatures
|
|
87
|
-
// ======================================================================
|
|
88
|
-
/**
|
|
89
|
-
* Map a message to a uniformly-distributed integer in [1, n). Standard
|
|
90
|
-
* FDH construction via MGF1-SHA256 with a counter-based retry loop to
|
|
91
|
-
* handle the edge case where the candidate is ≥ n.
|
|
92
|
-
*
|
|
93
|
-
* Why FDH matters: without it, RSA signatures are vulnerable to
|
|
94
|
-
* multiplicative forgery attacks (signatures can be combined to forge
|
|
95
|
-
* signatures on products of messages). FDH destroys the algebraic
|
|
96
|
-
* structure of the message so no such combination exists.
|
|
97
|
-
*/
|
|
98
|
-
function fdh(message, modulus, modulusBytes) {
|
|
99
|
-
for (let counter = 0; counter < 1000; counter++) {
|
|
100
|
-
const out = Buffer.alloc(modulusBytes);
|
|
101
|
-
let written = 0;
|
|
102
|
-
let i = 0;
|
|
103
|
-
while (written < modulusBytes) {
|
|
104
|
-
const hash = createHash('sha256');
|
|
105
|
-
hash.update(message);
|
|
106
|
-
const counterBuf = Buffer.alloc(4);
|
|
107
|
-
counterBuf.writeUInt32BE(counter, 0);
|
|
108
|
-
hash.update(counterBuf);
|
|
109
|
-
const iBuf = Buffer.alloc(4);
|
|
110
|
-
iBuf.writeUInt32BE(i, 0);
|
|
111
|
-
hash.update(iBuf);
|
|
112
|
-
const digest = hash.digest();
|
|
113
|
-
const take = Math.min(digest.length, modulusBytes - written);
|
|
114
|
-
digest.copy(out, written, 0, take);
|
|
115
|
-
written += take;
|
|
116
|
-
i++;
|
|
117
|
-
}
|
|
118
|
-
// Clear the top bit to reduce the chance of value ≥ n on the first try.
|
|
119
|
-
out[0] &= 0x7f;
|
|
120
|
-
const candidate = bytesToBigint(out);
|
|
121
|
-
if (candidate > 0n && candidate < modulus)
|
|
122
|
-
return candidate;
|
|
123
|
-
}
|
|
124
|
-
throw new Error('FDH: exhausted counter without finding candidate');
|
|
125
|
-
}
|
|
126
|
-
function rawPublicOp(key, value) {
|
|
127
|
-
const input = bigintToBytes(value, key.modulusBytes);
|
|
128
|
-
const output = publicEncrypt({ key: key.keyObj, padding: constants.RSA_NO_PADDING }, input);
|
|
129
|
-
return bytesToBigint(output);
|
|
130
|
-
}
|
|
131
|
-
function rawPrivateOp(key, value) {
|
|
132
|
-
const input = bigintToBytes(value, key.modulusBytes);
|
|
133
|
-
const output = privateDecrypt({ key: key.keyObjPriv, padding: constants.RSA_NO_PADDING }, input);
|
|
134
|
-
return bytesToBigint(output);
|
|
135
|
-
}
|
|
136
|
-
// ======================================================================
|
|
137
|
-
// Blind signature protocol
|
|
138
|
-
// ======================================================================
|
|
139
|
-
/**
|
|
140
|
-
* Blind a token: pick r ∈ [2, n), compute blinded = FDH(token) · r^e mod n.
|
|
141
|
-
* The admin sees only the blinded value (uniform over Z_n*) and learns
|
|
142
|
-
* nothing about the token.
|
|
143
|
-
*/
|
|
144
|
-
export function blindToken(tokenBytes, pubKey) {
|
|
145
|
-
const m = fdh(tokenBytes, pubKey.n, pubKey.modulusBytes);
|
|
146
|
-
let r = 0n;
|
|
147
|
-
for (let attempts = 0; attempts < 32; attempts++) {
|
|
148
|
-
const rBytes = randomBytes(pubKey.modulusBytes);
|
|
149
|
-
rBytes[0] &= 0x7f;
|
|
150
|
-
const candidate = bytesToBigint(rBytes);
|
|
151
|
-
if (candidate > 1n && candidate < pubKey.n) {
|
|
152
|
-
r = candidate;
|
|
153
|
-
break;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
if (r === 0n)
|
|
157
|
-
throw new Error('blindToken: failed to sample r');
|
|
158
|
-
const rE = rawPublicOp(pubKey, r);
|
|
159
|
-
const blinded = (m * rE) % pubKey.n;
|
|
160
|
-
return { blinded, r };
|
|
161
|
-
}
|
|
162
|
-
/** Admin-side: sign a blinded value. No knowledge of the original token. */
|
|
163
|
-
export function signBlinded(blinded, privKey) {
|
|
164
|
-
return rawPrivateOp(privKey, blinded);
|
|
165
|
-
}
|
|
166
|
-
/**
|
|
167
|
-
* Member-side: given the admin's signature on the blinded value, remove
|
|
168
|
-
* the blinding factor to obtain a raw RSA-FDH signature over the original
|
|
169
|
-
* token that the admin never saw.
|
|
170
|
-
*
|
|
171
|
-
* Math: signed_blinded = (FDH(t) · r^e)^d = FDH(t)^d · r mod n.
|
|
172
|
-
* Multiplying by r^(-1) mod n yields FDH(t)^d = the raw signature.
|
|
173
|
-
*/
|
|
174
|
-
export function unblindSignature(blindedSignature, r, pubKey) {
|
|
175
|
-
const rInv = modInverse(r, pubKey.n);
|
|
176
|
-
return (blindedSignature * rInv) % pubKey.n;
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* Verify a (token, signature) pair against the admin's public key.
|
|
180
|
-
* True iff signature^e ≡ FDH(token) (mod n).
|
|
181
|
-
*/
|
|
182
|
-
export function verifyTokenSignature(tokenBytes, signature, pubKey) {
|
|
183
|
-
if (signature <= 0n || signature >= pubKey.n)
|
|
184
|
-
return false;
|
|
185
|
-
const expected = fdh(tokenBytes, pubKey.n, pubKey.modulusBytes);
|
|
186
|
-
const actual = rawPublicOp(pubKey, signature);
|
|
187
|
-
return expected === actual;
|
|
188
|
-
}
|
|
189
|
-
// ======================================================================
|
|
190
|
-
// Key generation and export/import
|
|
191
|
-
// ======================================================================
|
|
192
|
-
export function generateGroupKey(bits = 2048) {
|
|
193
|
-
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
|
194
|
-
modulusLength: bits,
|
|
195
|
-
publicExponent: 65537,
|
|
196
|
-
});
|
|
197
|
-
const jwk = publicKey.export({ format: 'jwk' });
|
|
198
|
-
const n = bytesToBigint(Buffer.from(jwk.n, 'base64url'));
|
|
199
|
-
const e = bytesToBigint(Buffer.from(jwk.e, 'base64url'));
|
|
200
|
-
const modulusBytes = Math.ceil(bits / 8);
|
|
201
|
-
return {
|
|
202
|
-
n, e, modulusBytes,
|
|
203
|
-
keyObj: publicKey,
|
|
204
|
-
keyObjPriv: privateKey,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
export function exportGroupPublicKey(key) {
|
|
208
|
-
return {
|
|
209
|
-
n: key.n.toString(16),
|
|
210
|
-
e: key.e.toString(16),
|
|
211
|
-
modulusBytes: key.modulusBytes,
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
export function importGroupPublicKey(exported) {
|
|
215
|
-
const n = BigInt('0x' + exported.n);
|
|
216
|
-
const e = BigInt('0x' + exported.e);
|
|
217
|
-
const nBytes = bigintToBytes(n, exported.modulusBytes);
|
|
218
|
-
let eHex = e.toString(16);
|
|
219
|
-
if (eHex.length % 2)
|
|
220
|
-
eHex = '0' + eHex;
|
|
221
|
-
const eBytes = Buffer.from(eHex, 'hex');
|
|
222
|
-
const keyObj = createPublicKey({
|
|
223
|
-
key: {
|
|
224
|
-
kty: 'RSA',
|
|
225
|
-
n: nBytes.toString('base64url'),
|
|
226
|
-
e: eBytes.toString('base64url'),
|
|
227
|
-
},
|
|
228
|
-
format: 'jwk',
|
|
229
|
-
});
|
|
230
|
-
return { n, e, modulusBytes: exported.modulusBytes, keyObj };
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Admin holds the group private key and a roster of authorized members.
|
|
234
|
-
* Admin does NOT hold any of the tokens, by design — blind signing means
|
|
235
|
-
* the admin never sees what they signed. This is the key privacy property.
|
|
236
|
-
*
|
|
237
|
-
* The admin's responsibilities are purely social: decide who's in the
|
|
238
|
-
* group, set per-member quotas, rotate the group key when someone leaves.
|
|
239
|
-
*/
|
|
240
|
-
export class GroupAdmin {
|
|
241
|
-
groupId;
|
|
242
|
-
key;
|
|
243
|
-
members;
|
|
244
|
-
constructor(groupId, key, members) {
|
|
245
|
-
this.groupId = groupId;
|
|
246
|
-
this.key = key;
|
|
247
|
-
this.members = members;
|
|
248
|
-
}
|
|
249
|
-
static create(groupId, bits = 2048) {
|
|
250
|
-
return new GroupAdmin(groupId, generateGroupKey(bits), new Map());
|
|
251
|
-
}
|
|
252
|
-
addMember(pubkey, quotaPerBatch = 100, validForDays = 365) {
|
|
253
|
-
this.members.set(pubkey, {
|
|
254
|
-
pubkey,
|
|
255
|
-
expiresAt: Date.now() + validForDays * 86400_000,
|
|
256
|
-
quotaPerBatch,
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
removeMember(pubkey) {
|
|
260
|
-
return this.members.delete(pubkey);
|
|
261
|
-
}
|
|
262
|
-
/**
|
|
263
|
-
* Sign a batch of blinded tokens submitted by a member. The admin
|
|
264
|
-
* authenticates the request out-of-band (member identity auth happens
|
|
265
|
-
* at the HTTP layer via a member signing key — not modelled here).
|
|
266
|
-
*
|
|
267
|
-
* Throws on: unknown member, expired membership, batch-too-large.
|
|
268
|
-
*/
|
|
269
|
-
signBatch(memberPubkey, blinded) {
|
|
270
|
-
const member = this.members.get(memberPubkey);
|
|
271
|
-
if (!member)
|
|
272
|
-
throw new Error(`unknown member: ${memberPubkey.slice(0, 16)}...`);
|
|
273
|
-
if (member.expiresAt < Date.now())
|
|
274
|
-
throw new Error('member membership expired');
|
|
275
|
-
if (blinded.length > member.quotaPerBatch) {
|
|
276
|
-
throw new Error(`batch size ${blinded.length} exceeds quota ${member.quotaPerBatch}`);
|
|
277
|
-
}
|
|
278
|
-
return blinded.map((b) => signBlinded(b, this.key));
|
|
279
|
-
}
|
|
280
|
-
publicKey() {
|
|
281
|
-
return exportGroupPublicKey(this.key);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* Member holds an identity pubkey (used by the admin for roster lookup)
|
|
286
|
-
* and a local stash of unused (token, signature) pairs. Tokens are single-
|
|
287
|
-
* use — consume one per borrow. Admin never saw any of these tokens.
|
|
288
|
-
*/
|
|
289
|
-
export class GroupMember {
|
|
290
|
-
memberPubkey;
|
|
291
|
-
groupPublicKey;
|
|
292
|
-
tokens = [];
|
|
293
|
-
constructor(memberPubkey, groupPublicKey) {
|
|
294
|
-
this.memberPubkey = memberPubkey;
|
|
295
|
-
this.groupPublicKey = groupPublicKey;
|
|
296
|
-
}
|
|
297
|
-
/**
|
|
298
|
-
* Step 1 of a token batch: generate random tokens, blind each, return
|
|
299
|
-
* the blinded values (to send to admin) plus the per-token state
|
|
300
|
-
* (kept locally for unblinding after admin responds).
|
|
301
|
-
*/
|
|
302
|
-
prepareBatch(count) {
|
|
303
|
-
const blinded = [];
|
|
304
|
-
const state = [];
|
|
305
|
-
for (let i = 0; i < count; i++) {
|
|
306
|
-
const token = randomBytes(32);
|
|
307
|
-
const { blinded: b, r } = blindToken(token, this.groupPublicKey);
|
|
308
|
-
blinded.push(b);
|
|
309
|
-
state.push({ token, r });
|
|
310
|
-
}
|
|
311
|
-
return { blinded, state };
|
|
312
|
-
}
|
|
313
|
-
/**
|
|
314
|
-
* Step 2 of a token batch: unblind each admin-signed value, verify the
|
|
315
|
-
* resulting raw signature, and add the (token, signature) pair to the
|
|
316
|
-
* local stash. Any verification failure throws — we never store a token
|
|
317
|
-
* whose signature doesn't check out.
|
|
318
|
-
*/
|
|
319
|
-
finalizeBatch(signedBlinded, state) {
|
|
320
|
-
if (signedBlinded.length !== state.length) {
|
|
321
|
-
throw new Error('finalizeBatch: length mismatch');
|
|
322
|
-
}
|
|
323
|
-
const toStore = [];
|
|
324
|
-
for (let i = 0; i < signedBlinded.length; i++) {
|
|
325
|
-
const signature = unblindSignature(signedBlinded[i], state[i].r, this.groupPublicKey);
|
|
326
|
-
if (!verifyTokenSignature(state[i].token, signature, this.groupPublicKey)) {
|
|
327
|
-
throw new Error(`finalizeBatch: signature ${i} failed verification`);
|
|
328
|
-
}
|
|
329
|
-
toStore.push({ token: state[i].token, signature });
|
|
330
|
-
}
|
|
331
|
-
this.tokens.push(...toStore);
|
|
332
|
-
}
|
|
333
|
-
consumeToken() {
|
|
334
|
-
return this.tokens.shift() ?? null;
|
|
335
|
-
}
|
|
336
|
-
tokenCount() {
|
|
337
|
-
return this.tokens.length;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
/**
|
|
341
|
-
* Lender holds the group public key and a set of token hashes that have
|
|
342
|
-
* already been redeemed. Memory-only in v1; a persisted set would be a
|
|
343
|
-
* sqlite table keyed on token hash, or just a file of sha256 hex lines.
|
|
344
|
-
*
|
|
345
|
-
* The lender LEARNS NOTHING about which member borrowed. That's the
|
|
346
|
-
* whole point — blind signatures decouple "who signed this request"
|
|
347
|
-
* (the admin, uniformly) from "who holds the token" (one specific
|
|
348
|
-
* member who is anonymous to the lender).
|
|
349
|
-
*/
|
|
350
|
-
export class GroupLender {
|
|
351
|
-
groupId;
|
|
352
|
-
groupPublicKey;
|
|
353
|
-
seenTokens = new Set();
|
|
354
|
-
maxSeenTokens;
|
|
355
|
-
constructor(groupId, groupPublicKey, opts = {}) {
|
|
356
|
-
this.groupId = groupId;
|
|
357
|
-
this.groupPublicKey = groupPublicKey;
|
|
358
|
-
this.maxSeenTokens = opts.maxSeenTokens ?? 100_000;
|
|
359
|
-
}
|
|
360
|
-
acceptBorrow(token, signature) {
|
|
361
|
-
if (!verifyTokenSignature(token, signature, this.groupPublicKey)) {
|
|
362
|
-
return { ok: false, reason: 'invalid_signature' };
|
|
363
|
-
}
|
|
364
|
-
const hash = createHash('sha256').update(token).digest('hex');
|
|
365
|
-
if (this.seenTokens.has(hash)) {
|
|
366
|
-
return { ok: false, reason: 'double_spend' };
|
|
367
|
-
}
|
|
368
|
-
this.seenTokens.add(hash);
|
|
369
|
-
if (this.seenTokens.size > this.maxSeenTokens) {
|
|
370
|
-
const oldest = this.seenTokens.values().next().value;
|
|
371
|
-
if (oldest)
|
|
372
|
-
this.seenTokens.delete(oldest);
|
|
373
|
-
}
|
|
374
|
-
return { ok: true };
|
|
375
|
-
}
|
|
376
|
-
seenCount() {
|
|
377
|
-
return this.seenTokens.size;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
export function encodeBorrowEnvelope(groupId, bt, request) {
|
|
381
|
-
const env = {
|
|
382
|
-
v: 1,
|
|
383
|
-
groupId,
|
|
384
|
-
token: bt.token.toString('base64url'),
|
|
385
|
-
sig: bt.signature.toString(16),
|
|
386
|
-
request,
|
|
387
|
-
};
|
|
388
|
-
return JSON.stringify(env);
|
|
389
|
-
}
|
|
390
|
-
export function decodeBorrowEnvelope(s) {
|
|
391
|
-
try {
|
|
392
|
-
const obj = JSON.parse(s);
|
|
393
|
-
if (obj?.v !== 1 ||
|
|
394
|
-
typeof obj.groupId !== 'string' ||
|
|
395
|
-
typeof obj.token !== 'string' ||
|
|
396
|
-
typeof obj.sig !== 'string' ||
|
|
397
|
-
obj.request === undefined) {
|
|
398
|
-
return null;
|
|
399
|
-
}
|
|
400
|
-
return obj;
|
|
401
|
-
}
|
|
402
|
-
catch {
|
|
403
|
-
return null;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
export function parseBorrowToken(env) {
|
|
407
|
-
try {
|
|
408
|
-
return {
|
|
409
|
-
token: Buffer.from(env.token, 'base64url'),
|
|
410
|
-
signature: BigInt('0x' + env.sig),
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
catch {
|
|
414
|
-
return null;
|
|
415
|
-
}
|
|
416
|
-
}
|