@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.
Files changed (2) hide show
  1. package/dist/sts/issue.js +112 -108
  2. 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
- console.debug("[sts-cache] signingKey MISS importing key");
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
- const keyBuffer = base64ToBytes(input.privateKeyPkcs8Base64);
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
- const { signingKey } = await getSigningMaterial({
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
- let hex = "";
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
- hex += HEX_TABLE[serial[i]];
125
+ serialBig = (serialBig << 8n) | BigInt(serial[i]);
125
126
  }
126
- certSerialDec = BigInt("0x" + hex).toString();
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
- console.debug("[sts-debug] requestHeaders", {
184
- host,
185
- amzDate,
186
- signedHeaders,
187
- certLen: normalizedCert.length
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
- throw new InternalError("aws_rejected");
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
- const json = await res.json();
221
- const creds = json?.credentialSet?.[0]?.credentials;
222
- if (!creds?.accessKeyId || !creds?.secretAccessKey || !creds?.sessionToken) {
223
- console.error("[issueAwsCredentials] malformed AWS credential response", { json });
224
- throw new InternalError("aws_malformed_credentials");
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
- return {
227
- accessKeyId: creds.accessKeyId,
228
- secretAccessKey: creds.secretAccessKey,
229
- sessionToken: creds.sessionToken,
230
- expiration: creds.expiration,
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
- const data = textEncoder.encode(input);
256
- const hash = await crypto.subtle.digest("SHA-256", data);
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
- // build hex string with minimal allocations
259
- let out = "";
260
- for (let i = 0; i < bytes.length; i++) {
261
- out += HEX_TABLE[bytes[i]];
263
+ const hexParts = new Array(32); // SHA-256 luôn 32 bytes
264
+ for (let i = 0; i < 32; i++) {
265
+ hexParts[i] = HEX_TABLE[bytes[i]];
262
266
  }
263
- return out;
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.1",
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
- "typescript": "^5.9.3"
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
  }