@originator-profile/ca-client 0.0.0 → 0.7.0-beta.4
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/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +253 -29
- package/dist/index.d.ts +130 -0
- package/dist/index.js +723 -0
- package/package.json +42 -8
package/dist/index.js
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import { UnsignedContentAttestation, ContentAttestationSet } from '@originator-profile/model';
|
|
2
|
+
import { fetchAndSetDigestSri, fetchAndSetTargetIntegrity, createIntegrity } from '@originator-profile/sign';
|
|
3
|
+
import { JSDOM } from 'jsdom';
|
|
4
|
+
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
5
|
+
import { dirname, resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const CaClientErrorCode = {
|
|
8
|
+
Config: "CA_CONFIG",
|
|
9
|
+
Validation: "CA_VALIDATION",
|
|
10
|
+
Http: "CA_HTTP",
|
|
11
|
+
Response: "CA_RESPONSE",
|
|
12
|
+
File: "CA_FILE"
|
|
13
|
+
};
|
|
14
|
+
class CaClientError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
status;
|
|
17
|
+
constructor(message, options) {
|
|
18
|
+
super(message, { cause: options.cause });
|
|
19
|
+
this.name = "CaClientError";
|
|
20
|
+
this.code = options.code;
|
|
21
|
+
this.status = options.status;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const isUnauthorized = (error) => error instanceof CaClientError && error.code === CaClientErrorCode.Http && error.status === 401;
|
|
25
|
+
|
|
26
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
|
|
28
|
+
const requireConfigString = (value, field, hint = "") => {
|
|
29
|
+
if (typeof value !== "string" || value === "") {
|
|
30
|
+
throw new CaClientError(`CCSP auth failed: ${field} is required${hint}`, {
|
|
31
|
+
code: CaClientErrorCode.Config
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
const parseConfigRecord = (decoded) => {
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(decoded);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new CaClientError("CCSP auth failed: failed to parse config", {
|
|
42
|
+
code: CaClientErrorCode.Config,
|
|
43
|
+
cause: error
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (!isRecord(parsed)) {
|
|
47
|
+
throw new CaClientError("CCSP auth failed: failed to parse config", {
|
|
48
|
+
code: CaClientErrorCode.Config
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return parsed;
|
|
52
|
+
};
|
|
53
|
+
const parseCcspConfig = (base64Config) => {
|
|
54
|
+
const parsed = parseConfigRecord(
|
|
55
|
+
Buffer.from(base64Config.replace(/^CCSP:/, ""), "base64").toString("utf-8")
|
|
56
|
+
);
|
|
57
|
+
const authType = requireConfigString(parsed.authType, "authType");
|
|
58
|
+
if (authType !== "client_secret_post") {
|
|
59
|
+
throw new CaClientError(
|
|
60
|
+
`CCSP auth failed: unsupported auth type "${authType}" (only "client_secret_post" is supported)`,
|
|
61
|
+
{ code: CaClientErrorCode.Config }
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const config = {
|
|
65
|
+
authType,
|
|
66
|
+
clientId: requireConfigString(parsed.clientId, "clientId"),
|
|
67
|
+
clientSec: requireConfigString(
|
|
68
|
+
parsed.clientSec,
|
|
69
|
+
"clientSec",
|
|
70
|
+
" (OAuth client_secret)"
|
|
71
|
+
),
|
|
72
|
+
tokenUrl: requireConfigString(parsed.tokenUrl, "tokenUrl")
|
|
73
|
+
};
|
|
74
|
+
if (!URL.canParse(config.tokenUrl)) {
|
|
75
|
+
throw new CaClientError("CCSP auth failed: tokenUrl is not a valid URL", {
|
|
76
|
+
code: CaClientErrorCode.Config
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return config;
|
|
80
|
+
};
|
|
81
|
+
const parseTokenResponse = (data) => {
|
|
82
|
+
if (!isRecord(data) || typeof data.access_token !== "string" || data.access_token === "") {
|
|
83
|
+
throw new CaClientError(
|
|
84
|
+
"CCSP auth failed: response is missing access_token",
|
|
85
|
+
{ code: CaClientErrorCode.Response }
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const token = { access_token: data.access_token };
|
|
89
|
+
if (typeof data.token_type === "string") {
|
|
90
|
+
token.token_type = data.token_type;
|
|
91
|
+
}
|
|
92
|
+
if (typeof data.expires_in === "number") {
|
|
93
|
+
token.expires_in = data.expires_in;
|
|
94
|
+
}
|
|
95
|
+
if (typeof data.scope === "string") {
|
|
96
|
+
token.scope = data.scope;
|
|
97
|
+
}
|
|
98
|
+
return token;
|
|
99
|
+
};
|
|
100
|
+
const getCcspAccessToken = async (config, fetchOps = { fetch }) => {
|
|
101
|
+
const formData = new URLSearchParams();
|
|
102
|
+
formData.append("grant_type", "client_credentials");
|
|
103
|
+
formData.append("client_id", config.clientId);
|
|
104
|
+
formData.append("client_secret", config.clientSec);
|
|
105
|
+
let response;
|
|
106
|
+
try {
|
|
107
|
+
response = await fetchOps.fetch(config.tokenUrl, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
111
|
+
},
|
|
112
|
+
body: formData.toString()
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new CaClientError(
|
|
116
|
+
`CCSP auth failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
117
|
+
{ code: CaClientErrorCode.Http, cause: error }
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
const errorText = await response.text();
|
|
122
|
+
throw new CaClientError(
|
|
123
|
+
`CCSP auth failed: ${response.status} ${response.statusText}: ${errorText}`,
|
|
124
|
+
{ code: CaClientErrorCode.Http, status: response.status }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return parseTokenResponse(await response.json());
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const decodeJwtPayload = (token) => {
|
|
131
|
+
const parts = token.split(".");
|
|
132
|
+
if (parts.length !== 3) {
|
|
133
|
+
throw new CaClientError(
|
|
134
|
+
`Invalid JWT: expected 3 parts, got ${parts.length}`,
|
|
135
|
+
{ code: CaClientErrorCode.Validation }
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const [, payload] = parts;
|
|
139
|
+
if (!payload) {
|
|
140
|
+
throw new CaClientError("Invalid JWT: empty payload", {
|
|
141
|
+
code: CaClientErrorCode.Validation
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
const decoded = Buffer.from(payload, "base64url").toString("utf-8");
|
|
147
|
+
parsed = JSON.parse(decoded);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw new CaClientError("Failed to decode JWT payload", {
|
|
150
|
+
code: CaClientErrorCode.Validation,
|
|
151
|
+
cause: error
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (!isRecord(parsed)) {
|
|
155
|
+
throw new CaClientError("Failed to decode JWT payload", {
|
|
156
|
+
code: CaClientErrorCode.Validation
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return parsed;
|
|
160
|
+
};
|
|
161
|
+
const getJwtExpiration = (token) => {
|
|
162
|
+
try {
|
|
163
|
+
const payload = decodeJwtPayload(token);
|
|
164
|
+
const exp = payload.exp;
|
|
165
|
+
if (typeof exp === "number") {
|
|
166
|
+
return exp;
|
|
167
|
+
}
|
|
168
|
+
return void 0;
|
|
169
|
+
} catch {
|
|
170
|
+
return void 0;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const DEFAULT_TTL_SECONDS = 3600;
|
|
175
|
+
const resolveExpiresAt = (response, now) => {
|
|
176
|
+
if (typeof response.expires_in === "number" && response.expires_in > 0) {
|
|
177
|
+
return now + response.expires_in;
|
|
178
|
+
}
|
|
179
|
+
const jwtExp = getJwtExpiration(response.access_token);
|
|
180
|
+
if (jwtExp && jwtExp > 0) {
|
|
181
|
+
return jwtExp;
|
|
182
|
+
}
|
|
183
|
+
return now + DEFAULT_TTL_SECONDS;
|
|
184
|
+
};
|
|
185
|
+
const resolveBufferSeconds = (ttlSeconds, configuredBufferSeconds) => Math.min(configuredBufferSeconds, Math.max(0, Math.floor(ttlSeconds / 2)));
|
|
186
|
+
const defaultTokenOperations = {
|
|
187
|
+
getCcspAccessToken,
|
|
188
|
+
now: () => Math.floor(Date.now() / 1e3)
|
|
189
|
+
};
|
|
190
|
+
class TokenManager {
|
|
191
|
+
cachedToken = null;
|
|
192
|
+
refreshPromise = null;
|
|
193
|
+
config;
|
|
194
|
+
bufferSeconds;
|
|
195
|
+
tokenOps;
|
|
196
|
+
constructor(config, bufferSeconds = 300, tokenOps = defaultTokenOperations) {
|
|
197
|
+
this.config = config;
|
|
198
|
+
this.bufferSeconds = bufferSeconds;
|
|
199
|
+
this.tokenOps = tokenOps;
|
|
200
|
+
}
|
|
201
|
+
async getAccessToken() {
|
|
202
|
+
if (this.refreshPromise) {
|
|
203
|
+
return await this.refreshPromise;
|
|
204
|
+
}
|
|
205
|
+
if (this.cachedToken && this.isTokenValid()) {
|
|
206
|
+
return this.cachedToken.accessToken;
|
|
207
|
+
}
|
|
208
|
+
return await this.refreshToken();
|
|
209
|
+
}
|
|
210
|
+
async refreshToken() {
|
|
211
|
+
if (this.refreshPromise) {
|
|
212
|
+
return await this.refreshPromise;
|
|
213
|
+
}
|
|
214
|
+
this.refreshPromise = (async () => {
|
|
215
|
+
try {
|
|
216
|
+
const response = await this.tokenOps.getCcspAccessToken(this.config);
|
|
217
|
+
const now = this.tokenOps.now();
|
|
218
|
+
const expiresAt = resolveExpiresAt(response, now);
|
|
219
|
+
this.cachedToken = {
|
|
220
|
+
accessToken: response.access_token,
|
|
221
|
+
expiresAt,
|
|
222
|
+
bufferSeconds: resolveBufferSeconds(
|
|
223
|
+
expiresAt - now,
|
|
224
|
+
this.bufferSeconds
|
|
225
|
+
)
|
|
226
|
+
};
|
|
227
|
+
return response.access_token;
|
|
228
|
+
} finally {
|
|
229
|
+
this.refreshPromise = null;
|
|
230
|
+
}
|
|
231
|
+
})();
|
|
232
|
+
return await this.refreshPromise;
|
|
233
|
+
}
|
|
234
|
+
clearCache() {
|
|
235
|
+
this.cachedToken = null;
|
|
236
|
+
this.refreshPromise = null;
|
|
237
|
+
}
|
|
238
|
+
isTokenValid() {
|
|
239
|
+
if (!this.cachedToken) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
return this.cachedToken.expiresAt > this.tokenOps.now() + this.cachedToken.bufferSeconds;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const createTokenManager = (ccspConfig, bufferSeconds) => new TokenManager(parseCcspConfig(ccspConfig), bufferSeconds);
|
|
246
|
+
|
|
247
|
+
const JWT_PAYLOAD_CONTENT_ATTESTATION_KEYS = [
|
|
248
|
+
"@context",
|
|
249
|
+
"type",
|
|
250
|
+
"issuer",
|
|
251
|
+
"credentialSubject",
|
|
252
|
+
"allowedUrl",
|
|
253
|
+
"target"
|
|
254
|
+
];
|
|
255
|
+
const assertJwtPayloadHasContentAttestationKeys = (payload, source) => {
|
|
256
|
+
const missingKeys = JWT_PAYLOAD_CONTENT_ATTESTATION_KEYS.filter(
|
|
257
|
+
(key) => payload[key] === void 0 || payload[key] === null
|
|
258
|
+
);
|
|
259
|
+
if (missingKeys.length > 0) {
|
|
260
|
+
throw new CaClientError(
|
|
261
|
+
`Invalid Content Attestation: missing required keys in ${source}: ${missingKeys.join(", ")}`,
|
|
262
|
+
{ code: CaClientErrorCode.Validation }
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const jwtPayloadToUnsignedCa = (payload, source, options) => {
|
|
267
|
+
assertJwtPayloadHasContentAttestationKeys(payload, source);
|
|
268
|
+
const issuer = options?.issuer !== void 0 ? options.issuer : payload.issuer;
|
|
269
|
+
const parsed = UnsignedContentAttestation.safeParse({
|
|
270
|
+
"@context": payload["@context"],
|
|
271
|
+
type: payload.type,
|
|
272
|
+
issuer,
|
|
273
|
+
credentialSubject: payload.credentialSubject,
|
|
274
|
+
allowedUrl: payload.allowedUrl,
|
|
275
|
+
target: payload.target
|
|
276
|
+
});
|
|
277
|
+
if (!parsed.success) {
|
|
278
|
+
throw new CaClientError(
|
|
279
|
+
`Invalid Content Attestation: invalid payload in ${source}: ${parsed.error.message}`,
|
|
280
|
+
{ code: CaClientErrorCode.Validation, cause: parsed.error }
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return parsed.data;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
function parseDates({
|
|
287
|
+
issuedAt: issuedAtDateOrString = /* @__PURE__ */ new Date(),
|
|
288
|
+
expiredAt: expiredAtDateOrString
|
|
289
|
+
} = {}) {
|
|
290
|
+
const issuedAt = new Date(issuedAtDateOrString);
|
|
291
|
+
const expiredAt = expiredAtDateOrString ? new Date(expiredAtDateOrString) : (() => {
|
|
292
|
+
const next = new Date(issuedAt);
|
|
293
|
+
next.setUTCFullYear(next.getUTCFullYear() + 1);
|
|
294
|
+
return next;
|
|
295
|
+
})();
|
|
296
|
+
if (Number.isNaN(issuedAt.getTime())) {
|
|
297
|
+
throw new CaClientError("issuedAt must be a valid date", {
|
|
298
|
+
code: CaClientErrorCode.Validation
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
if (Number.isNaN(expiredAt.getTime())) {
|
|
302
|
+
throw new CaClientError("expiredAt must be a valid date", {
|
|
303
|
+
code: CaClientErrorCode.Validation
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (expiredAt.getTime() <= issuedAt.getTime()) {
|
|
307
|
+
throw new CaClientError("expiredAt must be after issuedAt", {
|
|
308
|
+
code: CaClientErrorCode.Validation
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return { issuedAt, expiredAt };
|
|
312
|
+
}
|
|
313
|
+
const toUnixTime = (date) => Math.floor(date.getTime() / 1e3);
|
|
314
|
+
|
|
315
|
+
async function fetchDocument(url, fetchOps) {
|
|
316
|
+
let res;
|
|
317
|
+
try {
|
|
318
|
+
res = await fetchOps.fetch(url);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
throw new CaClientError(
|
|
321
|
+
`Failed to fetch document: ${error instanceof Error ? error.message : String(error)}`,
|
|
322
|
+
{ code: CaClientErrorCode.Http, cause: error }
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (!res.ok) {
|
|
326
|
+
throw new CaClientError(
|
|
327
|
+
`Failed to fetch document: ${res.status} ${res.statusText}`,
|
|
328
|
+
{ code: CaClientErrorCode.Http, status: res.status }
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
return await res.text();
|
|
332
|
+
}
|
|
333
|
+
async function documentProvider({ type, content = "" }, fetchOps = { fetch }) {
|
|
334
|
+
if (type === "ExternalResourceTargetIntegrity") {
|
|
335
|
+
throw new CaClientError(
|
|
336
|
+
"Invalid Content Attestation: ExternalResourceTargetIntegrity is not supported",
|
|
337
|
+
{ code: CaClientErrorCode.Validation }
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (Array.isArray(content) && content.length > 1) {
|
|
341
|
+
throw new CaClientError(
|
|
342
|
+
"Invalid Content Attestation: multiple contents are not supported",
|
|
343
|
+
{ code: CaClientErrorCode.Validation }
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
const flatContent = Array.isArray(content) ? content[0] ?? "" : content;
|
|
347
|
+
let url;
|
|
348
|
+
let html;
|
|
349
|
+
if (URL.canParse(flatContent)) {
|
|
350
|
+
url = flatContent;
|
|
351
|
+
html = await fetchDocument(url, fetchOps);
|
|
352
|
+
} else {
|
|
353
|
+
url = void 0;
|
|
354
|
+
html = flatContent;
|
|
355
|
+
}
|
|
356
|
+
const dom = new JSDOM(html, {
|
|
357
|
+
url
|
|
358
|
+
});
|
|
359
|
+
return dom.window.document;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function jwtFromCaResponse(body) {
|
|
363
|
+
const responseBody = body.trim();
|
|
364
|
+
if (responseBody === "") {
|
|
365
|
+
throw new CaClientError("CA signing failed: empty response", {
|
|
366
|
+
code: CaClientErrorCode.Response
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
let result;
|
|
370
|
+
try {
|
|
371
|
+
result = JSON.parse(responseBody);
|
|
372
|
+
} catch {
|
|
373
|
+
return responseBody;
|
|
374
|
+
}
|
|
375
|
+
if (typeof result === "string") {
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
if (Array.isArray(result) && typeof result[0] === "string") {
|
|
379
|
+
return result[0];
|
|
380
|
+
}
|
|
381
|
+
throw new CaClientError("CA signing failed: response did not contain a JWT", {
|
|
382
|
+
code: CaClientErrorCode.Response
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
async function signByCaServer(uca, {
|
|
386
|
+
endpoint,
|
|
387
|
+
accessToken,
|
|
388
|
+
documentProvider: documentProvider$1,
|
|
389
|
+
fetchOps = { fetch },
|
|
390
|
+
...timingOptions
|
|
391
|
+
}) {
|
|
392
|
+
const { issuedAt, expiredAt } = parseDates(timingOptions);
|
|
393
|
+
const resolveDocument = documentProvider$1 ?? ((raw) => documentProvider(raw, fetchOps));
|
|
394
|
+
let payload;
|
|
395
|
+
try {
|
|
396
|
+
payload = UnsignedContentAttestation.parse(structuredClone(uca));
|
|
397
|
+
await Promise.all([
|
|
398
|
+
fetchAndSetDigestSri("sha256", payload.credentialSubject.image),
|
|
399
|
+
fetchAndSetTargetIntegrity("sha256", payload, resolveDocument)
|
|
400
|
+
]);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (error instanceof CaClientError) {
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
throw new CaClientError(
|
|
406
|
+
`Invalid Content Attestation: ${error instanceof Error ? error.message : String(error)}`,
|
|
407
|
+
{ code: CaClientErrorCode.Validation, cause: error }
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const subjectId = payload.credentialSubject.id;
|
|
411
|
+
let response;
|
|
412
|
+
try {
|
|
413
|
+
response = await fetchOps.fetch(endpoint, {
|
|
414
|
+
method: "POST",
|
|
415
|
+
headers: {
|
|
416
|
+
"Content-Type": "application/json",
|
|
417
|
+
Authorization: `Bearer ${accessToken}`
|
|
418
|
+
},
|
|
419
|
+
body: JSON.stringify({
|
|
420
|
+
...payload,
|
|
421
|
+
iss: payload.issuer,
|
|
422
|
+
...subjectId !== void 0 ? { sub: subjectId } : {},
|
|
423
|
+
iat: toUnixTime(issuedAt),
|
|
424
|
+
exp: toUnixTime(expiredAt),
|
|
425
|
+
issuedAt: issuedAt.toISOString(),
|
|
426
|
+
expiredAt: expiredAt.toISOString()
|
|
427
|
+
})
|
|
428
|
+
});
|
|
429
|
+
} catch (error) {
|
|
430
|
+
throw new CaClientError(
|
|
431
|
+
`CA signing failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
432
|
+
{ code: CaClientErrorCode.Http, cause: error }
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
if (!response.ok) {
|
|
436
|
+
const responseBody = await response.text();
|
|
437
|
+
throw new CaClientError(
|
|
438
|
+
`CA signing failed: ${response.status} ${response.statusText}: ${responseBody}`,
|
|
439
|
+
{ code: CaClientErrorCode.Http, status: response.status }
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return jwtFromCaResponse(await response.text());
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const serverSignOptions = (endpoint, tokens) => ({
|
|
446
|
+
endpoint,
|
|
447
|
+
getAccessToken: () => tokens.getAccessToken(),
|
|
448
|
+
refreshAccessToken: () => tokens.refreshToken()
|
|
449
|
+
});
|
|
450
|
+
const signByServer = async (uca, options) => {
|
|
451
|
+
const {
|
|
452
|
+
getAccessToken,
|
|
453
|
+
refreshAccessToken,
|
|
454
|
+
sign = signByCaServer,
|
|
455
|
+
...signOptions
|
|
456
|
+
} = options;
|
|
457
|
+
const accessToken = await getAccessToken();
|
|
458
|
+
try {
|
|
459
|
+
return await sign(uca, {
|
|
460
|
+
...signOptions,
|
|
461
|
+
accessToken
|
|
462
|
+
});
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (refreshAccessToken && isUnauthorized(error)) {
|
|
465
|
+
return await sign(uca, {
|
|
466
|
+
...signOptions,
|
|
467
|
+
accessToken: await refreshAccessToken()
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const reSign = async (jwtPayload, options) => {
|
|
475
|
+
const { source, issuer, ...signOptions } = options;
|
|
476
|
+
const uca = jwtPayloadToUnsignedCa(jwtPayload, source, { issuer });
|
|
477
|
+
return signByServer(uca, signOptions);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const createCaClient = (config, options) => {
|
|
481
|
+
const tokenManager = createTokenManager(
|
|
482
|
+
config.ccspConfig,
|
|
483
|
+
config.tokenBufferSeconds
|
|
484
|
+
);
|
|
485
|
+
const signOptions = {
|
|
486
|
+
...serverSignOptions(config.endpoint, tokenManager),
|
|
487
|
+
...options?.sign ? { sign: options.sign } : {}
|
|
488
|
+
};
|
|
489
|
+
return {
|
|
490
|
+
config,
|
|
491
|
+
sign: (uca) => signByServer(uca, signOptions),
|
|
492
|
+
reSign: (jwtPayload, source) => reSign(jwtPayload, {
|
|
493
|
+
source,
|
|
494
|
+
issuer: config.issuer,
|
|
495
|
+
...signOptions
|
|
496
|
+
})
|
|
497
|
+
};
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const isEnoent = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
501
|
+
const toFileError = (message, error) => {
|
|
502
|
+
if (error instanceof CaClientError) {
|
|
503
|
+
return error;
|
|
504
|
+
}
|
|
505
|
+
return new CaClientError(
|
|
506
|
+
`${message}: ${error instanceof Error ? error.message : String(error)}`,
|
|
507
|
+
{ code: CaClientErrorCode.File, cause: error }
|
|
508
|
+
);
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const isEmpty = (value) => value.trim() === "";
|
|
512
|
+
const resolveCasFilePath = (filePath) => {
|
|
513
|
+
if (isEmpty(filePath)) {
|
|
514
|
+
throw new CaClientError("filePath must be a non-empty string", {
|
|
515
|
+
code: CaClientErrorCode.Validation
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
return resolve(filePath);
|
|
519
|
+
};
|
|
520
|
+
const writeCasFile = async ({
|
|
521
|
+
filePath,
|
|
522
|
+
jwt
|
|
523
|
+
}) => {
|
|
524
|
+
if (isEmpty(jwt)) {
|
|
525
|
+
throw new CaClientError("jwt must be a non-empty string", {
|
|
526
|
+
code: CaClientErrorCode.Validation
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
const dest = resolveCasFilePath(filePath);
|
|
530
|
+
const casContent = `${JSON.stringify([jwt], null, 2)}
|
|
531
|
+
`;
|
|
532
|
+
try {
|
|
533
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
534
|
+
await writeFile(dest, casContent, "utf8");
|
|
535
|
+
} catch (error) {
|
|
536
|
+
throw toFileError(`Failed to write CAS file ${dest}`, error);
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const DEFAULT_EXTERNAL_SELECTOR = ".target-integrity";
|
|
541
|
+
const uniqueSelectors = (selectors) => [
|
|
542
|
+
...new Set((selectors ?? []).filter((selector) => selector.length > 0))
|
|
543
|
+
];
|
|
544
|
+
const toExtractedTarget = (type, integrity, cssSelector) => cssSelector === void 0 ? { type, integrity } : { type, integrity, cssSelector };
|
|
545
|
+
const extractExternalTargetIntegrities = (document, cssSelector = DEFAULT_EXTERNAL_SELECTOR) => {
|
|
546
|
+
const targets = [];
|
|
547
|
+
for (const element of document.querySelectorAll(cssSelector)) {
|
|
548
|
+
const integrity = element.getAttribute("integrity");
|
|
549
|
+
if (integrity && /^sha(256|384|512)-/.test(integrity)) {
|
|
550
|
+
targets.push({
|
|
551
|
+
type: "ExternalResourceTargetIntegrity",
|
|
552
|
+
integrity
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return targets;
|
|
557
|
+
};
|
|
558
|
+
const extractDomTargets = async (document, type, selectors) => {
|
|
559
|
+
const extracted = await Promise.all(
|
|
560
|
+
uniqueSelectors(selectors).map(async (cssSelector) => {
|
|
561
|
+
const target = await createIntegrity(
|
|
562
|
+
"sha256",
|
|
563
|
+
{ type, cssSelector },
|
|
564
|
+
document
|
|
565
|
+
);
|
|
566
|
+
if (!target?.integrity) {
|
|
567
|
+
return void 0;
|
|
568
|
+
}
|
|
569
|
+
return toExtractedTarget(target.type, target.integrity, cssSelector);
|
|
570
|
+
})
|
|
571
|
+
);
|
|
572
|
+
return extracted.filter((target) => target !== void 0);
|
|
573
|
+
};
|
|
574
|
+
const extractTargetsFromHtml = async (htmlContent, options = {}) => {
|
|
575
|
+
const document = new JSDOM(htmlContent).window.document;
|
|
576
|
+
const [textTargets, htmlTargets, visibleTextTargets] = await Promise.all([
|
|
577
|
+
extractDomTargets(document, "TextTargetIntegrity", options.textSelectors),
|
|
578
|
+
extractDomTargets(document, "HtmlTargetIntegrity", options.htmlSelectors),
|
|
579
|
+
extractDomTargets(
|
|
580
|
+
document,
|
|
581
|
+
"VisibleTextTargetIntegrity",
|
|
582
|
+
options.visibleTextSelectors
|
|
583
|
+
)
|
|
584
|
+
]);
|
|
585
|
+
return [
|
|
586
|
+
...textTargets,
|
|
587
|
+
...htmlTargets,
|
|
588
|
+
...visibleTextTargets,
|
|
589
|
+
...extractExternalTargetIntegrities(document, options.externalSelector)
|
|
590
|
+
];
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const INVALID_CAS_FORMAT = "Invalid CAS file format (expected JSON array with JWT string)";
|
|
594
|
+
const jwtFromCasItem = (item) => {
|
|
595
|
+
if (typeof item === "string") {
|
|
596
|
+
return item;
|
|
597
|
+
}
|
|
598
|
+
if (isRecord(item) && typeof item.attestation === "string") {
|
|
599
|
+
return item.attestation;
|
|
600
|
+
}
|
|
601
|
+
return void 0;
|
|
602
|
+
};
|
|
603
|
+
const isCasTarget = (value) => isRecord(value) && typeof value.type === "string" && typeof value.integrity === "string";
|
|
604
|
+
const normalizeTargets = (targets) => {
|
|
605
|
+
if (!Array.isArray(targets)) {
|
|
606
|
+
return [];
|
|
607
|
+
}
|
|
608
|
+
return targets.filter(isCasTarget).map(
|
|
609
|
+
(target) => typeof target.cssSelector === "string" ? {
|
|
610
|
+
type: target.type,
|
|
611
|
+
integrity: target.integrity,
|
|
612
|
+
cssSelector: target.cssSelector
|
|
613
|
+
} : { type: target.type, integrity: target.integrity }
|
|
614
|
+
).sort((a, b) => {
|
|
615
|
+
const left = `${a.type}\0${a.cssSelector ?? ""}\0${a.integrity}`;
|
|
616
|
+
const right = `${b.type}\0${b.cssSelector ?? ""}\0${b.integrity}`;
|
|
617
|
+
return left.localeCompare(right);
|
|
618
|
+
});
|
|
619
|
+
};
|
|
620
|
+
const areTargetsEqual = (currentTargets, casTargets) => {
|
|
621
|
+
if (currentTargets.length !== casTargets.length) {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
return currentTargets.every((target, index) => {
|
|
625
|
+
const casTarget = casTargets[index];
|
|
626
|
+
const cssSelectorEqual = casTarget?.cssSelector === void 0 || target.cssSelector === casTarget.cssSelector;
|
|
627
|
+
return target.type === casTarget?.type && target.integrity === casTarget?.integrity && cssSelectorEqual;
|
|
628
|
+
});
|
|
629
|
+
};
|
|
630
|
+
const parseCasTargets = (casFileContent) => {
|
|
631
|
+
let parsed;
|
|
632
|
+
try {
|
|
633
|
+
parsed = JSON.parse(casFileContent);
|
|
634
|
+
} catch (error) {
|
|
635
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
636
|
+
return { ok: false, reason: detail };
|
|
637
|
+
}
|
|
638
|
+
const cas = ContentAttestationSet.safeParse(parsed);
|
|
639
|
+
if (!cas.success) {
|
|
640
|
+
return { ok: false, reason: INVALID_CAS_FORMAT };
|
|
641
|
+
}
|
|
642
|
+
const jwt = jwtFromCasItem(cas.data[0]);
|
|
643
|
+
if (!jwt) {
|
|
644
|
+
return { ok: false, reason: INVALID_CAS_FORMAT };
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
const payload = decodeJwtPayload(jwt);
|
|
648
|
+
return { ok: true, targets: normalizeTargets(payload.target) };
|
|
649
|
+
} catch (error) {
|
|
650
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
651
|
+
return { ok: false, reason: detail };
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
const selectorsForType = (casTargets, type, fallback) => {
|
|
655
|
+
const fromCas = [
|
|
656
|
+
...new Set(
|
|
657
|
+
casTargets.flatMap(
|
|
658
|
+
(target) => target.type === type && typeof target.cssSelector === "string" ? [target.cssSelector] : []
|
|
659
|
+
)
|
|
660
|
+
)
|
|
661
|
+
];
|
|
662
|
+
if (fromCas.length > 0) {
|
|
663
|
+
return fromCas;
|
|
664
|
+
}
|
|
665
|
+
return fallback && fallback.length > 0 ? [fallback] : [];
|
|
666
|
+
};
|
|
667
|
+
const extractOptionsFromCas = (casTargets, options) => ({
|
|
668
|
+
textSelectors: selectorsForType(
|
|
669
|
+
casTargets,
|
|
670
|
+
"TextTargetIntegrity",
|
|
671
|
+
options.textSelector
|
|
672
|
+
),
|
|
673
|
+
htmlSelectors: selectorsForType(
|
|
674
|
+
casTargets,
|
|
675
|
+
"HtmlTargetIntegrity",
|
|
676
|
+
options.htmlSelector
|
|
677
|
+
),
|
|
678
|
+
visibleTextSelectors: selectorsForType(
|
|
679
|
+
casTargets,
|
|
680
|
+
"VisibleTextTargetIntegrity",
|
|
681
|
+
options.visibleTextSelector
|
|
682
|
+
),
|
|
683
|
+
externalSelector: options.externalSelector ?? casTargets.find(
|
|
684
|
+
(target) => target.type === "ExternalResourceTargetIntegrity"
|
|
685
|
+
)?.cssSelector ?? DEFAULT_EXTERNAL_SELECTOR
|
|
686
|
+
});
|
|
687
|
+
const detectDrift = async (options) => {
|
|
688
|
+
const dest = resolveCasFilePath(options.filePath);
|
|
689
|
+
let casFileContent;
|
|
690
|
+
try {
|
|
691
|
+
casFileContent = await readFile(dest, "utf8");
|
|
692
|
+
} catch (error) {
|
|
693
|
+
if (isEnoent(error)) {
|
|
694
|
+
return { status: "cas_missing", casFilePath: dest };
|
|
695
|
+
}
|
|
696
|
+
throw toFileError(`Failed to read CAS file ${dest}`, error);
|
|
697
|
+
}
|
|
698
|
+
const casRead = parseCasTargets(casFileContent);
|
|
699
|
+
if (!casRead.ok) {
|
|
700
|
+
return { status: "cas_invalid", casFilePath: dest, reason: casRead.reason };
|
|
701
|
+
}
|
|
702
|
+
const casTargets = casRead.targets;
|
|
703
|
+
const currentTargets = normalizeTargets(
|
|
704
|
+
await extractTargetsFromHtml(
|
|
705
|
+
options.html,
|
|
706
|
+
extractOptionsFromCas(casTargets, options)
|
|
707
|
+
)
|
|
708
|
+
);
|
|
709
|
+
if (currentTargets.length === 0) {
|
|
710
|
+
return { status: "html_no_targets", casFilePath: dest };
|
|
711
|
+
}
|
|
712
|
+
if (!areTargetsEqual(currentTargets, casTargets)) {
|
|
713
|
+
return {
|
|
714
|
+
status: "drifted",
|
|
715
|
+
casFilePath: dest,
|
|
716
|
+
current: currentTargets,
|
|
717
|
+
expected: casTargets
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
return { status: "ok", casFilePath: dest };
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
export { CaClientError, CaClientErrorCode, createCaClient, detectDrift, isUnauthorized, writeCasFile };
|