@vizamodo/aws-sts-core 0.3.1 → 0.3.3
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/dist/sts/issue.js +112 -108
- package/package.json +7 -2
package/dist/sts/issue.js
CHANGED
|
@@ -17,12 +17,15 @@ const PROFILE_TTL = {
|
|
|
17
17
|
};
|
|
18
18
|
const DEFAULT_TTL = 2 * 60 * 60;
|
|
19
19
|
// ---- isolate-level cached signing material ----
|
|
20
|
+
// Promise-based cache to avoid cold-start race during concurrent imports
|
|
21
|
+
let signingKeyPromise = null;
|
|
20
22
|
let cachedSigningKey = null;
|
|
21
23
|
let cachedCertBase64 = null;
|
|
22
24
|
let cachedPrivateKeyBase64 = null;
|
|
23
25
|
// ---- cached certificate serial (DER parse is expensive, cert rarely changes) ----
|
|
24
26
|
let cachedCertSerialDec = null;
|
|
25
27
|
let cachedCertSerialSource = null;
|
|
28
|
+
const stsCredentialCache = new Map();
|
|
26
29
|
// ---- shared encoder ----
|
|
27
30
|
const textEncoder = new TextEncoder();
|
|
28
31
|
// Precomputed hex table for fast byte→hex conversion (no per-byte toString alloc)
|
|
@@ -34,18 +37,32 @@ async function getSigningMaterial(input) {
|
|
|
34
37
|
if (cachedSigningKey &&
|
|
35
38
|
cachedCertBase64 === input.certBase64 &&
|
|
36
39
|
cachedPrivateKeyBase64 === input.privateKeyPkcs8Base64) {
|
|
37
|
-
console.debug("[sts-cache] signingKey HIT");
|
|
38
40
|
return { signingKey: cachedSigningKey, certBase64: cachedCertBase64 };
|
|
39
41
|
}
|
|
40
|
-
|
|
42
|
+
// Reset cache only after a signing key has been resolved and material actually changed
|
|
43
|
+
if (cachedSigningKey &&
|
|
44
|
+
(cachedCertBase64 !== input.certBase64 ||
|
|
45
|
+
cachedPrivateKeyBase64 !== input.privateKeyPkcs8Base64)) {
|
|
46
|
+
signingKeyPromise = null;
|
|
47
|
+
cachedSigningKey = null;
|
|
48
|
+
}
|
|
49
|
+
if (!signingKeyPromise) {
|
|
50
|
+
try {
|
|
51
|
+
const keyBuffer = base64ToBytes(input.privateKeyPkcs8Base64);
|
|
52
|
+
signingKeyPromise = crypto.subtle.importKey("pkcs8", keyBuffer, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new InternalError("invalid_signing_material");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
41
58
|
try {
|
|
42
|
-
|
|
43
|
-
cachedSigningKey = await crypto.subtle.importKey("pkcs8", keyBuffer, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
|
|
59
|
+
cachedSigningKey = await signingKeyPromise;
|
|
44
60
|
cachedCertBase64 = input.certBase64;
|
|
45
61
|
cachedPrivateKeyBase64 = input.privateKeyPkcs8Base64;
|
|
46
62
|
return { signingKey: cachedSigningKey, certBase64: cachedCertBase64 };
|
|
47
63
|
}
|
|
48
64
|
catch {
|
|
65
|
+
signingKeyPromise = null; // allow retry after failure
|
|
49
66
|
throw new InternalError("invalid_signing_material");
|
|
50
67
|
}
|
|
51
68
|
}
|
|
@@ -62,31 +79,14 @@ export async function issueAwsCredentials(input) {
|
|
|
62
79
|
// Normalize certificate once so every subsystem (cache, DER parse, headers)
|
|
63
80
|
// uses the exact same canonical string
|
|
64
81
|
const normalizedCert = normalizeCert(certBase64);
|
|
65
|
-
|
|
66
|
-
certBase64: normalizedCert,
|
|
67
|
-
privateKeyPkcs8Base64,
|
|
68
|
-
});
|
|
69
|
-
// 2. Setup constants
|
|
70
|
-
const host = `${SERVICE}.${region}.amazonaws.com`;
|
|
71
|
-
const path = PATH;
|
|
72
|
-
const now = new Date();
|
|
73
|
-
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
74
|
-
const dateStamp = amzDate.slice(0, 8);
|
|
75
|
-
const service = SERVICE;
|
|
76
|
-
// 3. Chuẩn bị Body & Cert
|
|
77
|
-
const body = JSON.stringify({ trustAnchorArn, profileArn, roleArn, durationSeconds: sessionTtl });
|
|
78
|
-
const payloadHash = await getPayloadHash(body);
|
|
79
|
-
// --- REFACTOR: Làm sạch chứng chỉ chặt chẽ ---
|
|
80
|
-
// const normalizedCert = normalizeCert(certBase64);
|
|
82
|
+
let cacheKey = "";
|
|
81
83
|
// --- REFACTOR: Parser ASN.1 an toàn để lấy Serial Number (DECIMAL) ---
|
|
82
84
|
// --- MINIMAL DER WALK: extract serial number ---
|
|
83
85
|
let certSerialDec;
|
|
84
86
|
if (cachedCertSerialDec && cachedCertSerialSource === normalizedCert) {
|
|
85
|
-
console.debug("[sts-cache] certSerial HIT");
|
|
86
87
|
certSerialDec = cachedCertSerialDec;
|
|
87
88
|
}
|
|
88
89
|
else {
|
|
89
|
-
console.debug("[sts-cache] certSerial MISS → parsing DER");
|
|
90
90
|
try {
|
|
91
91
|
const der = base64ToBytes(normalizedCert);
|
|
92
92
|
let offset = 0;
|
|
@@ -119,11 +119,12 @@ export async function issueAwsCredentials(input) {
|
|
|
119
119
|
if (serial.length > 1 && serial[0] === 0x00) {
|
|
120
120
|
serial = serial.slice(1);
|
|
121
121
|
}
|
|
122
|
-
|
|
122
|
+
// Build BigInt directly from bytes (avoids hex string allocation)
|
|
123
|
+
let serialBig = 0n;
|
|
123
124
|
for (let i = 0; i < serial.length; i++) {
|
|
124
|
-
|
|
125
|
+
serialBig = (serialBig << 8n) | BigInt(serial[i]);
|
|
125
126
|
}
|
|
126
|
-
certSerialDec =
|
|
127
|
+
certSerialDec = serialBig.toString();
|
|
127
128
|
cachedCertSerialDec = certSerialDec;
|
|
128
129
|
cachedCertSerialSource = normalizedCert;
|
|
129
130
|
}
|
|
@@ -132,6 +133,37 @@ export async function issueAwsCredentials(input) {
|
|
|
132
133
|
throw new InternalError("invalid_cert_der");
|
|
133
134
|
}
|
|
134
135
|
}
|
|
136
|
+
// ---- STS credential cache lookup (using certificate serial) ----
|
|
137
|
+
cacheKey = `${region}|${roleArn}|${profileArn}|${certSerialDec}`;
|
|
138
|
+
const cachedEntry = stsCredentialCache.get(cacheKey);
|
|
139
|
+
// Only reuse cached credentials if they still have >1h1m remaining
|
|
140
|
+
const MIN_REMAINING_MS = 61 * 60 * 1000;
|
|
141
|
+
if (cachedEntry) {
|
|
142
|
+
// If credentials already resolved and still valid → reuse
|
|
143
|
+
if (cachedEntry.expiresAt > Date.now() + MIN_REMAINING_MS) {
|
|
144
|
+
return cachedEntry.promise;
|
|
145
|
+
}
|
|
146
|
+
// If a refresh is already in-flight → await same promise
|
|
147
|
+
if (cachedEntry.expiresAt === 0) {
|
|
148
|
+
return cachedEntry.promise;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const { signingKey } = await getSigningMaterial({
|
|
152
|
+
certBase64: normalizedCert,
|
|
153
|
+
privateKeyPkcs8Base64,
|
|
154
|
+
});
|
|
155
|
+
// 2. Setup constants
|
|
156
|
+
const host = `${SERVICE}.${region}.amazonaws.com`;
|
|
157
|
+
const path = PATH;
|
|
158
|
+
const now = new Date();
|
|
159
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
160
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
161
|
+
const service = SERVICE;
|
|
162
|
+
// 3. Chuẩn bị Body & Cert
|
|
163
|
+
const body = JSON.stringify({ trustAnchorArn, profileArn, roleArn, durationSeconds: sessionTtl });
|
|
164
|
+
const payloadHash = await getPayloadHash(body);
|
|
165
|
+
// --- REFACTOR: Làm sạch chứng chỉ chặt chẽ ---
|
|
166
|
+
// const normalizedCert = normalizeCert(certBase64);
|
|
135
167
|
// 4. Tính toán Signature
|
|
136
168
|
const baseHeaders = {
|
|
137
169
|
"content-type": "application/json",
|
|
@@ -149,30 +181,14 @@ export async function issueAwsCredentials(input) {
|
|
|
149
181
|
signedHeaders,
|
|
150
182
|
payloadHash,
|
|
151
183
|
});
|
|
152
|
-
console.debug("[sts-debug] canonicalRequest", {
|
|
153
|
-
amzDate,
|
|
154
|
-
signedHeaders,
|
|
155
|
-
payloadHash,
|
|
156
|
-
canonicalHeadersPreview: canonicalHeaders.slice(0, 120),
|
|
157
|
-
canonicalRequestPreview: canonicalRequest.slice(0, 200)
|
|
158
|
-
});
|
|
159
184
|
const canonicalRequestHash = await getCanonicalRequestHash(canonicalRequest);
|
|
160
|
-
console.debug("[sts-debug] canonicalRequestHash", canonicalRequestHash);
|
|
161
185
|
const stringToSign = buildStringToSign({
|
|
162
186
|
algorithm: ALGORITHM,
|
|
163
187
|
amzDate,
|
|
164
188
|
credentialScope,
|
|
165
189
|
canonicalRequestHash,
|
|
166
190
|
});
|
|
167
|
-
console.debug("[sts-debug] stringToSign", {
|
|
168
|
-
credentialScope,
|
|
169
|
-
stringPreview: stringToSign.slice(0, 200)
|
|
170
|
-
});
|
|
171
191
|
const signatureHex = await signStringToSign(stringToSign, signingKey);
|
|
172
|
-
console.debug("[sts-debug] signature", {
|
|
173
|
-
length: signatureHex.length,
|
|
174
|
-
prefix: signatureHex.slice(0, 32)
|
|
175
|
-
});
|
|
176
192
|
// 5. Build Authorization Header với số Serial (DECIMAL)
|
|
177
193
|
const finalHeaders = new Headers({
|
|
178
194
|
"Content-Type": "application/json",
|
|
@@ -180,69 +196,58 @@ export async function issueAwsCredentials(input) {
|
|
|
180
196
|
"X-Amz-X509": normalizedCert,
|
|
181
197
|
"Authorization": `AWS4-X509-ECDSA-SHA256 Credential=${certSerialDec}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signatureHex}`
|
|
182
198
|
});
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
console.debug("[sts-request] issuing rolesanywhere session", {
|
|
190
|
-
region,
|
|
191
|
-
profile,
|
|
192
|
-
roleArn,
|
|
193
|
-
profileArn,
|
|
194
|
-
trustAnchorArn,
|
|
195
|
-
amzDate,
|
|
196
|
-
cacheState: {
|
|
197
|
-
hasSigningKey: !!cachedSigningKey,
|
|
198
|
-
cachedCertMatch: cachedCertBase64 === normalizedCert,
|
|
199
|
-
}
|
|
200
|
-
});
|
|
201
|
-
// 6. Execution
|
|
202
|
-
try {
|
|
203
|
-
const res = await fetch(`https://${host}${path}`, {
|
|
204
|
-
method: "POST",
|
|
205
|
-
headers: finalHeaders,
|
|
206
|
-
body,
|
|
207
|
-
});
|
|
208
|
-
if (!res.ok) {
|
|
209
|
-
const errorBody = await res.text();
|
|
210
|
-
// Debug logs
|
|
211
|
-
console.error("[aws-rejected] Request details", {
|
|
212
|
-
status: res.status,
|
|
213
|
-
response: errorBody,
|
|
214
|
-
region,
|
|
215
|
-
profile,
|
|
216
|
-
amzDate
|
|
199
|
+
const refreshPromise = (async () => {
|
|
200
|
+
try {
|
|
201
|
+
const res = await fetch(`https://${host}${path}`, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: finalHeaders,
|
|
204
|
+
body,
|
|
217
205
|
});
|
|
218
|
-
|
|
206
|
+
if (!res.ok) {
|
|
207
|
+
const errorBody = await res.text();
|
|
208
|
+
console.error("[aws-rejected] Request details", {
|
|
209
|
+
status: res.status,
|
|
210
|
+
response: errorBody,
|
|
211
|
+
region,
|
|
212
|
+
profile,
|
|
213
|
+
amzDate
|
|
214
|
+
});
|
|
215
|
+
throw new InternalError("aws_rejected");
|
|
216
|
+
}
|
|
217
|
+
const json = await res.json();
|
|
218
|
+
const creds = json?.credentialSet?.[0]?.credentials;
|
|
219
|
+
if (!creds?.accessKeyId || !creds?.secretAccessKey || !creds?.sessionToken) {
|
|
220
|
+
console.error("[issueAwsCredentials] malformed AWS credential response", { json });
|
|
221
|
+
throw new InternalError("aws_malformed_credentials");
|
|
222
|
+
}
|
|
223
|
+
const result = {
|
|
224
|
+
accessKeyId: creds.accessKeyId,
|
|
225
|
+
secretAccessKey: creds.secretAccessKey,
|
|
226
|
+
sessionToken: creds.sessionToken,
|
|
227
|
+
expiration: creds.expiration,
|
|
228
|
+
};
|
|
229
|
+
const exp = new Date(creds.expiration).getTime();
|
|
230
|
+
// update cache expiration after success
|
|
231
|
+
const entry = stsCredentialCache.get(cacheKey);
|
|
232
|
+
if (entry) {
|
|
233
|
+
entry.expiresAt = exp;
|
|
234
|
+
}
|
|
235
|
+
return result;
|
|
219
236
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
237
|
+
catch (e) {
|
|
238
|
+
// remove broken cache entry so next call can retry
|
|
239
|
+
stsCredentialCache.delete(cacheKey);
|
|
240
|
+
if (e instanceof InternalError)
|
|
241
|
+
throw e;
|
|
242
|
+
throw new InternalError("aws_unreachable");
|
|
225
243
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
catch (e) {
|
|
234
|
-
console.error("[sts-debug] fetch failure", {
|
|
235
|
-
error: String(e),
|
|
236
|
-
region,
|
|
237
|
-
roleArn,
|
|
238
|
-
profileArn,
|
|
239
|
-
trustAnchorArn,
|
|
240
|
-
amzDate
|
|
241
|
-
});
|
|
242
|
-
if (e instanceof InternalError)
|
|
243
|
-
throw e;
|
|
244
|
-
throw new InternalError("aws_unreachable");
|
|
245
|
-
}
|
|
244
|
+
})();
|
|
245
|
+
// store promise immediately to deduplicate concurrent refresh
|
|
246
|
+
stsCredentialCache.set(cacheKey, {
|
|
247
|
+
promise: refreshPromise,
|
|
248
|
+
expiresAt: 0,
|
|
249
|
+
});
|
|
250
|
+
return refreshPromise;
|
|
246
251
|
}
|
|
247
252
|
// ---- helpers ----
|
|
248
253
|
function normalizeCert(raw) {
|
|
@@ -252,15 +257,14 @@ function normalizeCert(raw) {
|
|
|
252
257
|
return raw.trim();
|
|
253
258
|
}
|
|
254
259
|
async function sha256Hex(input) {
|
|
255
|
-
|
|
256
|
-
const hash = await crypto.subtle.digest("SHA-256",
|
|
260
|
+
// Tận dụng textEncoder có sẵn
|
|
261
|
+
const hash = await crypto.subtle.digest("SHA-256", textEncoder.encode(input));
|
|
257
262
|
const bytes = new Uint8Array(hash);
|
|
258
|
-
//
|
|
259
|
-
let
|
|
260
|
-
|
|
261
|
-
out += HEX_TABLE[bytes[i]];
|
|
263
|
+
const hexParts = new Array(32); // SHA-256 luôn là 32 bytes
|
|
264
|
+
for (let i = 0; i < 32; i++) {
|
|
265
|
+
hexParts[i] = HEX_TABLE[bytes[i]];
|
|
262
266
|
}
|
|
263
|
-
return
|
|
267
|
+
return hexParts.join("");
|
|
264
268
|
}
|
|
265
269
|
async function getCanonicalRequestHash(canonicalRequest) {
|
|
266
270
|
return sha256Hex(canonicalRequest);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vizamodo/aws-sts-core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Pure AWS STS + SigV4 (X509 Roles Anywhere) core logic",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc",
|
|
16
|
+
"test": "vitest run",
|
|
16
17
|
"clean": "rm -rf dist",
|
|
17
18
|
"prepublishOnly": "npm run build",
|
|
18
19
|
"dev": "ts-node bin/viza.ts",
|
|
@@ -20,6 +21,10 @@
|
|
|
20
21
|
"release:full": "rm -rf dist && npx npm-check-updates -u && npm install && git add package.json package-lock.json && git commit -m 'chore(deps): auto update dependencies before release' || echo 'No changes' && node versioning.js && npm login && npm publish --tag latest --access public && git push"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
|
-
"
|
|
24
|
+
"@aws-crypto/sha256-js": "^5.2.0",
|
|
25
|
+
"@aws-sdk/signature-v4": "^3.374.0",
|
|
26
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
27
|
+
"typescript": "^5.9.3",
|
|
28
|
+
"vitest": "^4.0.18"
|
|
24
29
|
}
|
|
25
30
|
}
|