@vizamodo/aws-sts-core 0.4.7 → 0.4.10

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.
@@ -10,11 +10,8 @@ export interface IssueAwsCredentialsInput {
10
10
  forceRefresh?: boolean;
11
11
  }
12
12
  /**
13
- * Reset isolate-level signing material and cert serial caches.
14
- * ONLY for use in tests — forces re-import of signing key on next call.
15
- *
16
- * L1 cache is bypassed in tests via `forceRefresh: true` on each call —
17
- * there is no need to expose a cache-clear API from the core library.
13
+ * Reset isolate-level signing-material cache.
14
+ * ONLY for use in tests.
18
15
  */
19
16
  export declare function resetIsolateCache(): void;
20
17
  export declare function issueAwsCredentials(input: IssueAwsCredentialsInput): Promise<AwsCredentialResult>;
package/dist/sts/issue.js CHANGED
@@ -1,18 +1,14 @@
1
+ import { getCachedOrFetch, wrapResult } from "@vizamodo/edge-cache-core";
1
2
  import { canonicalizeHeaders } from "../sigv4/headers";
2
3
  import { buildCanonicalRequest } from "../sigv4/canonical";
3
4
  import { buildStringToSign } from "../sigv4/string-to-sign";
4
5
  import { signStringToSign } from "./signer";
5
6
  import { InternalError } from "./errors";
6
7
  import { sha256Hex } from "../crypto/sha256";
7
- import { getCachedOrFetch, wrapResult } from "@vizamodo/edge-cache-core";
8
8
  // ── Constants ──────────────────────────────────────────────────────────────
9
9
  const ALGORITHM = "AWS4-X509-ECDSA-SHA256";
10
10
  const SERVICE = "rolesanywhere";
11
11
  const PATH = "/sessions";
12
- /**
13
- * Per-profile AWS session duration (seconds).
14
- * Clamped to [MIN_SESSION_TTL, MAX_SESSION_TTL] at resolution time.
15
- */
16
12
  const PROFILE_TTL = {
17
13
  Runtime: 45 * 60,
18
14
  ConsoleReadOnly: 12 * 60 * 60,
@@ -21,46 +17,39 @@ const PROFILE_TTL = {
21
17
  BillingAdmin: 3 * 60 * 60,
22
18
  BillingAccountant: 3 * 60 * 60,
23
19
  };
24
- const DEFAULT_SESSION_TTL = 2 * 60 * 60;
25
- const MIN_SESSION_TTL = 45 * 60; // 45 min
26
- const MAX_SESSION_TTL = 12 * 60 * 60; // 12 h
20
+ const DEFAULT_TTL = 2 * 60 * 60;
21
+ const MIN_PROFILE_TTL = 45 * 60; // 45 min
22
+ const MAX_PROFILE_TTL = 12 * 60 * 60; // 12 h
27
23
  // ── Isolate-level signing-material cache ───────────────────────────────────
28
- // Single Promise slot prevents concurrent cold-start import races.
24
+ // Single Promise slot deduplicates concurrent cold-start imports.
25
+ // Cache vars are updated inside .then() so all concurrent awaiters see
26
+ // consistent state and hit the fast path on the next call.
29
27
  let signingKeyPromise = null;
30
28
  let cachedSigningKey = null;
31
29
  let cachedCertBase64 = null;
32
30
  let cachedPrivateKeyBase64 = null;
33
- // ── Isolate-level cert-serial cache ───────────────────────────────────────
34
- // DER walk is CPU-bound; the cert rarely rotates within an isolate lifetime.
35
- let cachedCertSerialDec = null;
36
- let cachedCertSerialSource = null;
37
31
  // ── Test utilities ─────────────────────────────────────────────────────────
38
32
  /**
39
- * Reset isolate-level signing material and cert serial caches.
40
- * ONLY for use in tests — forces re-import of signing key on next call.
41
- *
42
- * L1 cache is bypassed in tests via `forceRefresh: true` on each call —
43
- * there is no need to expose a cache-clear API from the core library.
33
+ * Reset isolate-level signing-material cache.
34
+ * ONLY for use in tests.
44
35
  */
45
36
  export function resetIsolateCache() {
46
37
  signingKeyPromise = null;
47
38
  cachedSigningKey = null;
48
39
  cachedCertBase64 = null;
49
40
  cachedPrivateKeyBase64 = null;
50
- cachedCertSerialDec = null;
51
- cachedCertSerialSource = null;
52
41
  }
53
42
  // ── Signing material ───────────────────────────────────────────────────────
54
43
  async function getSigningMaterial(certBase64, privateKeyPkcs8Base64) {
55
- // Fast path: same material already imported in this isolate.
44
+ // Fast path: same material already imported.
56
45
  if (cachedSigningKey &&
57
46
  cachedCertBase64 === certBase64 &&
58
47
  cachedPrivateKeyBase64 === privateKeyPkcs8Base64) {
59
48
  return cachedSigningKey;
60
49
  }
61
50
  // Material changed or first call — reset and re-import.
62
- // Cache vars are updated inside .then() so concurrent callers
63
- // awaiting the same promise all see consistent state after resolve.
51
+ // Cache vars are updated inside .then() so concurrent callers awaiting
52
+ // the same promise all get the correct key and hit the fast path next call.
64
53
  signingKeyPromise = null;
65
54
  cachedSigningKey = null;
66
55
  cachedCertBase64 = null;
@@ -68,32 +57,30 @@ async function getSigningMaterial(certBase64, privateKeyPkcs8Base64) {
68
57
  signingKeyPromise = crypto.subtle
69
58
  .importKey("pkcs8", base64ToBytes(privateKeyPkcs8Base64), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"])
70
59
  .then((key) => {
71
- // Update cache atomically after successful import.
72
- // All concurrent callers awaiting this promise will see
73
- // consistent state and hit the fast path on next call.
74
60
  cachedSigningKey = key;
75
61
  cachedCertBase64 = certBase64;
76
62
  cachedPrivateKeyBase64 = privateKeyPkcs8Base64;
77
63
  return key;
78
64
  })
79
65
  .catch(() => {
80
- signingKeyPromise = null; // allow retry on next call
66
+ signingKeyPromise = null; // allow retry
81
67
  throw new InternalError("invalid_signing_material");
82
68
  });
83
69
  return signingKeyPromise;
84
70
  }
85
71
  // ── Session TTL ────────────────────────────────────────────────────────────
86
72
  function resolveSessionTtl(profile) {
87
- const ttl = PROFILE_TTL[profile] ?? DEFAULT_SESSION_TTL;
88
- return Math.min(Math.max(ttl, MIN_SESSION_TTL), MAX_SESSION_TTL);
73
+ const ttl = PROFILE_TTL[profile] ?? DEFAULT_TTL;
74
+ return Math.min(Math.max(ttl, MIN_PROFILE_TTL), MAX_PROFILE_TTL);
89
75
  }
90
76
  // ── Main export ────────────────────────────────────────────────────────────
91
77
  export async function issueAwsCredentials(input) {
92
78
  const { roleArn, profileArn, trustAnchorArn, region, certBase64, privateKeyPkcs8Base64, profile, forceRefresh, } = input;
93
79
  const sessionTtl = resolveSessionTtl(profile);
94
80
  const normalizedCert = normalizeCert(certBase64);
95
- // Cert serial — use isolate cache to avoid redundant DER walks.
96
- const certSerialDec = getCertSerialDec(normalizedCert);
81
+ // Parse serial fresh every call no isolate cache.
82
+ // DER walk is ~5 µs; stale isolate-cached values caused wrong-serial bugs.
83
+ const certSerialDec = parseCertSerialDec(normalizedCert);
97
84
  const cacheKey = `${region}|${roleArn}|${profileArn}|${trustAnchorArn}|${certSerialDec}`;
98
85
  return getCachedOrFetch(cacheKey, () => fetchCredentials({
99
86
  roleArn, profileArn, trustAnchorArn,
@@ -103,7 +90,9 @@ export async function issueAwsCredentials(input) {
103
90
  }
104
91
  async function fetchCredentials(input) {
105
92
  const { roleArn, profileArn, trustAnchorArn, region, normalizedCert, privateKeyPkcs8Base64, sessionTtl, } = input;
93
+ // Signing happens INSIDE the fetcher so it only runs on a cache miss.
106
94
  const signingKey = await getSigningMaterial(normalizedCert, privateKeyPkcs8Base64);
95
+ const certSerialDec = parseCertSerialDec(normalizedCert);
107
96
  const host = `${SERVICE}.${region}.amazonaws.com`;
108
97
  const iso = new Date().toISOString();
109
98
  const amzDate = isoToAmzDate(iso);
@@ -119,51 +108,14 @@ async function fetchCredentials(input) {
119
108
  const { canonicalHeaders, signedHeaders } = canonicalizeHeaders(baseHeaders);
120
109
  const credentialScope = `${dateStamp}/${region}/${SERVICE}/aws4_request`;
121
110
  const canonicalRequest = buildCanonicalRequest({
122
- method: "POST",
123
- canonicalUri: PATH,
124
- query: "",
125
- canonicalHeaders,
126
- signedHeaders,
127
- payloadHash,
111
+ method: "POST", canonicalUri: PATH, query: "",
112
+ canonicalHeaders, signedHeaders, payloadHash,
128
113
  });
129
- const canonicalRequestHash = await sha256Hex(canonicalRequest);
130
114
  const stringToSign = buildStringToSign({
131
- algorithm: ALGORITHM,
132
- amzDate,
133
- credentialScope,
134
- canonicalRequestHash,
115
+ algorithm: ALGORITHM, amzDate, credentialScope,
116
+ canonicalRequestHash: await sha256Hex(canonicalRequest),
135
117
  });
136
118
  const signatureHex = await signStringToSign(stringToSign, signingKey);
137
- const certSerialDec = getCertSerialDec(normalizedCert);
138
- // 🔍 Deep debug helper (compare with working version)
139
- function debugAwsSigning() {
140
- try {
141
- console.debug("[aws-debug][input]", {
142
- roleArn,
143
- profileArn,
144
- trustAnchorArn,
145
- region,
146
- sessionTtl,
147
- });
148
- console.debug("[aws-debug][cert]", {
149
- length: normalizedCert.length,
150
- preview: normalizedCert.slice(0, 30),
151
- });
152
- console.debug("[aws-debug][serial]", {
153
- serialDec: certSerialDec,
154
- serialHex: BigInt(certSerialDec).toString(16),
155
- });
156
- console.debug("[aws-debug][headers]", baseHeaders);
157
- console.debug("[aws-debug][signedHeaders]", signedHeaders);
158
- console.debug("[aws-debug][canonicalRequest]", canonicalRequest);
159
- console.debug("[aws-debug][stringToSign]", stringToSign);
160
- console.debug("[aws-debug][signatureHex]", signatureHex);
161
- }
162
- catch (e) {
163
- console.warn("[aws-debug][error]", e);
164
- }
165
- }
166
- debugAwsSigning();
167
119
  const finalHeaders = new Headers({
168
120
  "Content-Type": "application/json",
169
121
  "X-Amz-Date": amzDate,
@@ -183,12 +135,7 @@ async function fetchCredentials(input) {
183
135
  throw new InternalError("aws_unreachable");
184
136
  }
185
137
  if (!res.ok) {
186
- const text = await res.text().catch(() => "<no-body>");
187
- console.error("[aws-rejected]", {
188
- status: res.status,
189
- body: text,
190
- region,
191
- });
138
+ console.warn("[aws-rejected]", { status: res.status, region });
192
139
  throw new InternalError("aws_rejected");
193
140
  }
194
141
  const json = await res.json();
@@ -204,9 +151,9 @@ async function fetchCredentials(input) {
204
151
  expiration: creds.expiration,
205
152
  };
206
153
  // Cache for 1/3 of the credential lifetime so it's refreshed well before expiry.
207
- // deriveEdgeTtlSec in cache.ts will further subtract EDGE_BUFFER_SEC (5 min).
208
- const expiresAtMs = Date.parse(creds.expiration);
154
+ // getCachedOrFetch will further subtract EDGE_BUFFER_SEC (5 min).
209
155
  const receivedAt = Date.now();
156
+ const expiresAtMs = Date.parse(creds.expiration);
210
157
  const credLifetimeSec = Math.floor((expiresAtMs - receivedAt) / 1000);
211
158
  if (Number.isFinite(expiresAtMs) && credLifetimeSec > 0) {
212
159
  const cacheTtlSec = Math.floor(credLifetimeSec / 3);
@@ -216,25 +163,14 @@ async function fetchCredentials(input) {
216
163
  return value;
217
164
  }
218
165
  // ── Helpers ────────────────────────────────────────────────────────────────
219
- /** Strip whitespace; reject PEM-wrapped input. */
220
166
  function normalizeCert(raw) {
221
- if (raw.includes("BEGIN CERTIFICATE")) {
167
+ if (raw.includes("BEGIN CERTIFICATE"))
222
168
  throw new InternalError("pem_not_allowed");
223
- }
224
169
  return raw.replace(/\s+/g, "");
225
170
  }
226
- /** Return cert serial as decimal string, using isolate-level cache. */
227
- function getCertSerialDec(normalizedCert) {
228
- if (cachedCertSerialDec && cachedCertSerialSource === normalizedCert) {
229
- return cachedCertSerialDec;
230
- }
231
- const serial = parseCertSerialDec(normalizedCert);
232
- cachedCertSerialDec = serial;
233
- cachedCertSerialSource = normalizedCert;
234
- return serial;
235
- }
236
171
  /**
237
172
  * Minimal DER walk to extract the certificate serial number as a decimal string.
173
+ * No isolate-level cache — stale cached values caused wrong-serial bugs in production.
238
174
  * Throws InternalError("invalid_cert_der") on any parse failure.
239
175
  */
240
176
  function parseCertSerialDec(normalizedCertBase64) {
@@ -255,34 +191,28 @@ function parseCertSerialDec(normalizedCertBase64) {
255
191
  len = (len << 8) | der[offset++];
256
192
  return len;
257
193
  }
258
- // Certificate ::= SEQUENCE
259
194
  if (der[offset++] !== 0x30)
260
195
  throw new Error("bad cert");
261
196
  readLen();
262
- // tbsCertificate ::= SEQUENCE
263
197
  if (der[offset++] !== 0x30)
264
198
  throw new Error("bad tbs");
265
199
  readLen();
266
- // Optional version [0] EXPLICIT
200
+ // Skip optional [0] EXPLICIT version field.
267
201
  if (der[offset] === 0xa0) {
268
- offset++; // tag
269
- offset += readLen(); // skip content
202
+ offset++;
203
+ offset += readLen();
270
204
  }
271
- // 👉 SERIAL MUST be the next INTEGER after optional version
272
205
  if (der[offset++] !== 0x02)
273
206
  throw new Error("bad serial tag");
274
- const len = readLen();
275
- if (offset + len > der.length)
207
+ const serialLen = readLen();
208
+ if (offset + serialLen > der.length)
276
209
  throw new Error("DER overflow");
277
- let serial = der.slice(offset, offset + len);
278
- // Strip leading 0x00 only if it's padding for signed INTEGER
279
- if (serial.length > 1 && serial[0] === 0x00) {
210
+ let serial = der.slice(offset, offset + serialLen);
211
+ if (serial.length > 1 && serial[0] === 0x00)
280
212
  serial = serial.slice(1);
281
- }
282
213
  let serialBig = 0n;
283
- for (let i = 0; i < serial.length; i++) {
214
+ for (let i = 0; i < serial.length; i++)
284
215
  serialBig = (serialBig << 8n) | BigInt(serial[i]);
285
- }
286
216
  return serialBig.toString();
287
217
  }
288
218
  catch (e) {
@@ -290,26 +220,16 @@ function parseCertSerialDec(normalizedCertBase64) {
290
220
  throw new InternalError("invalid_cert_der");
291
221
  }
292
222
  }
293
- /**
294
- * Convert ISO-8601 to compact AMZ date-time format.
295
- * e.g. "2026-03-07T12:00:00.000Z" → "20260307T120000Z"
296
- */
297
223
  function isoToAmzDate(iso) {
298
- return (iso.slice(0, 4) +
299
- iso.slice(5, 7) +
300
- iso.slice(8, 10) +
224
+ return (iso.slice(0, 4) + iso.slice(5, 7) + iso.slice(8, 10) +
301
225
  "T" +
302
- iso.slice(11, 13) +
303
- iso.slice(14, 16) +
304
- iso.slice(17, 19) +
226
+ iso.slice(11, 13) + iso.slice(14, 16) + iso.slice(17, 19) +
305
227
  "Z");
306
228
  }
307
- /** base64 → Uint8Array via V8's vectorized atob path. */
308
229
  function base64ToBytes(base64) {
309
230
  const binary = atob(base64);
310
231
  const bytes = new Uint8Array(binary.length);
311
- for (let i = 0; i < binary.length; i++) {
232
+ for (let i = 0; i < binary.length; i++)
312
233
  bytes[i] = binary.charCodeAt(i);
313
- }
314
234
  return bytes;
315
235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizamodo/aws-sts-core",
3
- "version": "0.4.7",
3
+ "version": "0.4.10",
4
4
  "description": "Pure AWS STS + SigV4 (X509 Roles Anywhere) core logic",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",