@vizamodo/aws-sts-core 0.4.23 → 0.4.24

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.
@@ -1,5 +1,4 @@
1
1
  import type { AwsCredentialResult } from "../types";
2
- export declare function resetIsolateCache(): void;
3
2
  export declare function issueAwsCredentials(input: {
4
3
  roleArn: string;
5
4
  profileArn: string;
package/dist/sts/issue.js CHANGED
@@ -5,7 +5,7 @@ import { buildStringToSign } from "../sigv4/string-to-sign";
5
5
  import { signStringToSign } from "./signer";
6
6
  import { InternalError } from "./errors";
7
7
  import { sha256Hex } from "../crypto/sha256";
8
- // ── Constants ──────────────────────────────────────────────────────────────
8
+ // ---- constants ----
9
9
  const ALGORITHM = "AWS4-X509-ECDSA-SHA256";
10
10
  const SERVICE = "rolesanywhere";
11
11
  const PATH = "/sessions";
@@ -18,22 +18,20 @@ const PROFILE_TTL = {
18
18
  BillingAccountant: 3 * 60 * 60,
19
19
  };
20
20
  const DEFAULT_TTL = 2 * 60 * 60;
21
- const MIN_PROFILE_TTL = 45 * 60;
22
- const MAX_PROFILE_TTL = 12 * 60 * 60;
23
- // ── Isolate-level signing-material cache ───────────────────────────────────
24
- // FIX 2: cache vars updated inside .then() — no race condition.
21
+ const MIN_PROFILE_TTL = 45 * 60; // 45 min — lower guard
22
+ const MAX_PROFILE_TTL = 12 * 60 * 60; // 12 h — upper guard
23
+ // ---- isolate-level signing material cache ----
24
+ // Single Promise-based slot avoids concurrent cold-start races.
25
25
  let signingKeyPromise = null;
26
26
  let cachedSigningKey = null;
27
27
  let cachedCertBase64 = null;
28
28
  let cachedPrivateKeyBase64 = null;
29
- // ── Test utilities ─────────────────────────────────────────────────────────
30
- export function resetIsolateCache() {
31
- signingKeyPromise = null;
32
- cachedSigningKey = null;
33
- cachedCertBase64 = null;
34
- cachedPrivateKeyBase64 = null;
35
- }
36
- // ── Signing material ───────────────────────────────────────────────────────
29
+ // ---- certificate serial cache (DER walk is CPU-bound, cert rarely rotates) ----
30
+ let cachedCertSerialDec = null;
31
+ let cachedCertSerialSource = null;
32
+ // ---------------------------------------------------------------------------
33
+ // Signing material
34
+ // ---------------------------------------------------------------------------
37
35
  async function getSigningMaterial(input) {
38
36
  // Fast path: same material already imported.
39
37
  if (cachedSigningKey &&
@@ -41,42 +39,57 @@ async function getSigningMaterial(input) {
41
39
  cachedPrivateKeyBase64 === input.privateKeyPkcs8Base64) {
42
40
  return { signingKey: cachedSigningKey, certBase64: cachedCertBase64 };
43
41
  }
44
- // FIX 2: reset + update cache vars inside .then() so concurrent callers
45
- // awaiting the same promise all get the correct key.
46
- signingKeyPromise = null;
47
- cachedSigningKey = null;
48
- cachedCertBase64 = null;
49
- cachedPrivateKeyBase64 = null;
50
- signingKeyPromise = crypto.subtle
51
- .importKey("pkcs8", base64ToBytes(input.privateKeyPkcs8Base64), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"])
52
- .then((key) => {
53
- cachedSigningKey = key;
42
+ // Material rotated discard the stale resolved key so we re-import.
43
+ if (cachedSigningKey) {
44
+ signingKeyPromise = null;
45
+ cachedSigningKey = null;
46
+ }
47
+ if (!signingKeyPromise) {
48
+ try {
49
+ const keyBuffer = base64ToBytes(input.privateKeyPkcs8Base64);
50
+ signingKeyPromise = crypto.subtle.importKey("pkcs8", keyBuffer, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
51
+ }
52
+ catch {
53
+ throw new InternalError("invalid_signing_material");
54
+ }
55
+ }
56
+ try {
57
+ cachedSigningKey = await signingKeyPromise;
54
58
  cachedCertBase64 = input.certBase64;
55
59
  cachedPrivateKeyBase64 = input.privateKeyPkcs8Base64;
56
- return key;
57
- })
58
- .catch(() => {
59
- signingKeyPromise = null;
60
+ return { signingKey: cachedSigningKey, certBase64: cachedCertBase64 };
61
+ }
62
+ catch {
63
+ signingKeyPromise = null; // allow retry on next call
60
64
  throw new InternalError("invalid_signing_material");
61
- });
62
- const key = await signingKeyPromise;
63
- return { signingKey: key, certBase64: input.certBase64 };
65
+ }
64
66
  }
65
- // ── Profile TTL resolution ─────────────────────────────────────────────────
67
+ // ---------------------------------------------------------------------------
68
+ // Profile TTL resolution
69
+ // ---------------------------------------------------------------------------
66
70
  function resolveSessionTtlByProfile(profile) {
67
71
  const ttl = PROFILE_TTL[profile] ?? DEFAULT_TTL;
68
72
  return Math.min(Math.max(ttl, MIN_PROFILE_TTL), MAX_PROFILE_TTL);
69
73
  }
70
- // ── Main export ────────────────────────────────────────────────────────────
74
+ // ---------------------------------------------------------------------------
75
+ // Main export
76
+ // ---------------------------------------------------------------------------
71
77
  export async function issueAwsCredentials(input) {
72
78
  const { roleArn, profileArn, trustAnchorArn, region, certBase64, privateKeyPkcs8Base64, profile, forceRefresh, } = input;
73
79
  const sessionTtl = resolveSessionTtlByProfile(profile);
74
80
  const normalizedCert = normalizeCert(certBase64);
75
- // No isolate cache stale cached values caused wrong-serial bugs.
76
- const certSerialDec = parseCertSerialDec(normalizedCert);
81
+ // ---- DER serial extraction (with isolate-level cache) ----
82
+ let certSerialDec;
83
+ if (cachedCertSerialDec && cachedCertSerialSource === normalizedCert) {
84
+ certSerialDec = cachedCertSerialDec;
85
+ }
86
+ else {
87
+ certSerialDec = parseCertSerialDec(normalizedCert); // throws InternalError on bad DER
88
+ cachedCertSerialDec = certSerialDec;
89
+ cachedCertSerialSource = normalizedCert;
90
+ }
77
91
  const cacheKey = `${region}|${roleArn}|${profileArn}|${trustAnchorArn}|${certSerialDec}`;
78
- // BUG 1 KEPT: signing happens before getCachedOrFetch — runs on every
79
- // request even when L1/L2 cache would have returned a hit.
92
+ // ---- Build SigV4 request ----
80
93
  const { signingKey } = await getSigningMaterial({
81
94
  certBase64: normalizedCert,
82
95
  privateKeyPkcs8Base64,
@@ -96,12 +109,19 @@ export async function issueAwsCredentials(input) {
96
109
  const { canonicalHeaders, signedHeaders } = canonicalizeHeaders(baseHeaders);
97
110
  const credentialScope = `${dateStamp}/${region}/${SERVICE}/aws4_request`;
98
111
  const canonicalRequest = buildCanonicalRequest({
99
- method: "POST", canonicalUri: PATH, query: "",
100
- canonicalHeaders, signedHeaders, payloadHash,
112
+ method: "POST",
113
+ canonicalUri: PATH,
114
+ query: "",
115
+ canonicalHeaders,
116
+ signedHeaders,
117
+ payloadHash,
101
118
  });
119
+ const canonicalRequestHash = await sha256Hex(canonicalRequest);
102
120
  const stringToSign = buildStringToSign({
103
- algorithm: ALGORITHM, amzDate, credentialScope,
104
- canonicalRequestHash: await sha256Hex(canonicalRequest),
121
+ algorithm: ALGORITHM,
122
+ amzDate,
123
+ credentialScope,
124
+ canonicalRequestHash,
105
125
  });
106
126
  const signatureHex = await signStringToSign(stringToSign, signingKey);
107
127
  const finalHeaders = new Headers({
@@ -110,31 +130,15 @@ export async function issueAwsCredentials(input) {
110
130
  "X-Amz-X509": normalizedCert,
111
131
  "Authorization": `${ALGORITHM} Credential=${certSerialDec}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signatureHex}`,
112
132
  });
133
+ const issuedAt = Date.now(); // snapshot before the network round-trip
113
134
  return getCachedOrFetch(cacheKey, async () => {
114
- // FIX 4: catch network errors as aws_unreachable.
115
- let res;
116
- try {
117
- res = await fetch(`https://${host}${PATH}`, {
118
- method: "POST",
119
- headers: finalHeaders,
120
- body,
121
- });
122
- }
123
- catch (err) {
124
- console.warn("[aws-unreachable]", { region, err });
125
- throw new InternalError("aws_unreachable");
126
- }
135
+ const res = await fetch(`https://${host}${PATH}`, {
136
+ method: "POST",
137
+ headers: finalHeaders,
138
+ body,
139
+ });
127
140
  if (!res.ok) {
128
- const errorBody = await res.text().catch(() => "<no-body>");
129
- console.error("[aws-rejected]", {
130
- status: res.status,
131
- region,
132
- profile,
133
- errorBody,
134
- certSerial: certSerialDec,
135
- amzDate,
136
- credentialScope,
137
- });
141
+ console.warn("[aws-rejected]", { status: res.status, region, profile });
138
142
  throw new InternalError("aws_rejected");
139
143
  }
140
144
  const json = await res.json();
@@ -149,70 +153,79 @@ export async function issueAwsCredentials(input) {
149
153
  sessionToken: creds.sessionToken,
150
154
  expiration: creds.expiration,
151
155
  };
152
- // FIX 3: no unsafe cast, receivedAt captured after response.
153
- const receivedAt = Date.now();
156
+ // derive TTL = 1/3 lifetime
154
157
  const expiresAtMs = Date.parse(creds.expiration);
155
- const credLifetimeSec = Math.floor((expiresAtMs - receivedAt) / 1000);
158
+ const credLifetimeSec = Math.floor((expiresAtMs - issuedAt) / 1000);
156
159
  if (Number.isFinite(expiresAtMs) && credLifetimeSec > 0) {
157
- const cacheTtlSec = Math.floor(credLifetimeSec / 3);
158
- const cacheExpiry = new Date(receivedAt + cacheTtlSec * 1000).toISOString();
159
- return wrapResult(value, cacheExpiry);
160
+ const edgeCacheTtlSec = Math.floor(credLifetimeSec / 3);
161
+ const edgeCacheExpiry = new Date(issuedAt + edgeCacheTtlSec * 1000).toISOString();
162
+ return wrapResult(value, edgeCacheExpiry);
160
163
  }
161
164
  return value;
162
- }, { ttlSec: 60, forceRefresh });
165
+ }, {
166
+ ttlSec: 60,
167
+ ...(forceRefresh !== undefined ? { forceRefresh } : {})
168
+ });
163
169
  }
164
- // ── Helpers ────────────────────────────────────────────────────────────────
170
+ // ---------------------------------------------------------------------------
171
+ // Helpers
172
+ // ---------------------------------------------------------------------------
173
+ /** Strip whitespace and reject PEM-wrapped input. */
165
174
  function normalizeCert(raw) {
166
- if (raw.includes("BEGIN CERTIFICATE"))
175
+ if (raw.includes("BEGIN CERTIFICATE")) {
167
176
  throw new InternalError("pem_not_allowed");
177
+ }
168
178
  return raw.replace(/\s+/g, "");
169
179
  }
180
+ /**
181
+ * Minimal DER walk to extract the certificate serial number as a decimal string.
182
+ * Throws InternalError("invalid_cert_der") on any parse failure.
183
+ */
170
184
  function parseCertSerialDec(normalizedCertBase64) {
171
185
  try {
172
186
  const der = base64ToBytes(normalizedCertBase64);
173
- // Use array pointer instead of closure-captured variable to avoid
174
- // bundler (esbuild/wrangler) variable-rename bugs.
175
- const p = [0];
176
- function readLen(p) {
177
- if (p[0] >= der.length)
187
+ let offset = 0;
188
+ function readLen() {
189
+ if (offset >= der.length)
178
190
  throw new Error("DER overflow");
179
- const b = der[p[0]++];
191
+ const b = der[offset++];
180
192
  if ((b & 0x80) === 0)
181
193
  return b;
182
194
  const n = b & 0x7f;
183
195
  let len = 0;
184
- if (p[0] + n > der.length)
196
+ if (offset + n > der.length)
185
197
  throw new Error("DER overflow");
186
198
  for (let i = 0; i < n; i++)
187
- len = (len << 8) | der[p[0]++];
199
+ len = (len << 8) | der[offset++];
188
200
  return len;
189
201
  }
190
- if (der[p[0]++] !== 0x30)
202
+ if (der[offset++] !== 0x30)
191
203
  throw new Error("bad cert");
192
- readLen(p);
193
- if (der[p[0]++] !== 0x30)
204
+ readLen();
205
+ if (der[offset++] !== 0x30)
194
206
  throw new Error("bad tbs");
195
- readLen(p);
207
+ readLen();
196
208
  // Skip optional [0] EXPLICIT version field.
197
- const p0_before = p[0];
198
- if (der[p[0]] === 0xa0) {
199
- p[0]++;
200
- p[0] += readLen(p);
209
+ if (der[offset] === 0xa0) {
210
+ offset++;
211
+ const vLen = readLen();
212
+ offset += vLen;
201
213
  }
202
- const p0_after = p[0];
203
- console.debug("[parser-v2]", { p0_before, p0_after, nextTag: der[p[0]]?.toString(16) });
204
- if (der[p[0]++] !== 0x02)
214
+ if (der[offset++] !== 0x02)
205
215
  throw new Error("bad serial tag");
206
- const serialLen = readLen(p);
207
- console.debug("[parser-v2-serial]", { serialLen, firstByte: der[p[0]]?.toString(16) });
208
- if (p[0] + serialLen > der.length)
216
+ const serialLen = readLen();
217
+ if (offset + serialLen > der.length)
209
218
  throw new Error("DER overflow");
210
- let serial = der.slice(p[0], p[0] + serialLen);
211
- if (serial.length > 1 && serial[0] === 0x00)
219
+ let serial = der.slice(offset, offset + serialLen);
220
+ // Strip ASN.1 sign-extension padding byte.
221
+ if (serial.length > 1 && serial[0] === 0x00) {
212
222
  serial = serial.slice(1);
223
+ }
224
+ // Accumulate directly to BigInt — avoids intermediate hex string allocation.
213
225
  let serialBig = 0n;
214
- for (let i = 0; i < serial.length; i++)
226
+ for (let i = 0; i < serial.length; i++) {
215
227
  serialBig = (serialBig << 8n) | BigInt(serial[i]);
228
+ }
216
229
  return serialBig.toString();
217
230
  }
218
231
  catch (e) {
@@ -220,16 +233,26 @@ function parseCertSerialDec(normalizedCertBase64) {
220
233
  throw new InternalError("invalid_cert_der");
221
234
  }
222
235
  }
236
+ /**
237
+ * Convert an ISO-8601 timestamp to the compact AMZ date-time format.
238
+ * e.g. "2026-03-07T12:00:00.000Z" → "20260307T120000Z"
239
+ */
223
240
  function isoToAmzDate(iso) {
224
- return (iso.slice(0, 4) + iso.slice(5, 7) + iso.slice(8, 10) +
241
+ return (iso.slice(0, 4) +
242
+ iso.slice(5, 7) +
243
+ iso.slice(8, 10) +
225
244
  "T" +
226
- iso.slice(11, 13) + iso.slice(14, 16) + iso.slice(17, 19) +
245
+ iso.slice(11, 13) +
246
+ iso.slice(14, 16) +
247
+ iso.slice(17, 19) +
227
248
  "Z");
228
249
  }
250
+ /** Faster base64 → Uint8Array using V8's vectorized atob path. */
229
251
  function base64ToBytes(base64) {
230
252
  const binary = atob(base64);
231
253
  const bytes = new Uint8Array(binary.length);
232
- for (let i = 0; i < binary.length; i++)
254
+ for (let i = 0; i < binary.length; i++) {
233
255
  bytes[i] = binary.charCodeAt(i);
256
+ }
234
257
  return bytes;
235
258
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizamodo/aws-sts-core",
3
- "version": "0.4.23",
3
+ "version": "0.4.24",
4
4
  "description": "Pure AWS STS + SigV4 (X509 Roles Anywhere) core logic",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",