@vizamodo/aws-sts-core 0.4.9 → 0.4.11

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,5 @@
1
1
  import type { AwsCredentialResult } from "../types";
2
- export declare function issueAwsCredentials(input: {
2
+ export interface IssueAwsCredentialsInput {
3
3
  roleArn: string;
4
4
  profileArn: string;
5
5
  trustAnchorArn: string;
@@ -8,4 +8,6 @@ export declare function issueAwsCredentials(input: {
8
8
  privateKeyPkcs8Base64: string;
9
9
  profile: string;
10
10
  forceRefresh?: boolean;
11
- }): Promise<AwsCredentialResult>;
11
+ }
12
+ export declare function resetIsolateCache(): void;
13
+ export declare function issueAwsCredentials(input: IssueAwsCredentialsInput): Promise<AwsCredentialResult>;
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,82 +18,84 @@ 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; // 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.
21
+ const MIN_PROFILE_TTL = 45 * 60;
22
+ const MAX_PROFILE_TTL = 12 * 60 * 60;
23
+ // ── Isolate-level signing-material cache ───────────────────────────────────
25
24
  let signingKeyPromise = null;
26
25
  let cachedSigningKey = null;
27
26
  let cachedCertBase64 = null;
28
27
  let cachedPrivateKeyBase64 = null;
29
- // ---- certificate serial cache (DER walk is CPU-bound, cert rarely rotates) ----
28
+ // ── Cert serial cache ──────────────────────────────────────────────────────
29
+ // BUG 5 KEPT: stale isolate cache — produced wrong serial in production.
30
30
  let cachedCertSerialDec = null;
31
31
  let cachedCertSerialSource = null;
32
- // ---------------------------------------------------------------------------
33
- // Signing material
34
- // ---------------------------------------------------------------------------
35
- async function getSigningMaterial(input) {
36
- // Fast path: same material already imported.
32
+ export function resetIsolateCache() {
33
+ signingKeyPromise = null;
34
+ cachedSigningKey = null;
35
+ cachedCertBase64 = null;
36
+ cachedPrivateKeyBase64 = null;
37
+ cachedCertSerialDec = null;
38
+ cachedCertSerialSource = null;
39
+ }
40
+ // ── Signing material ───────────────────────────────────────────────────────
41
+ async function getSigningMaterial(certBase64, privateKeyPkcs8Base64) {
37
42
  if (cachedSigningKey &&
38
- cachedCertBase64 === input.certBase64 &&
39
- cachedPrivateKeyBase64 === input.privateKeyPkcs8Base64) {
40
- return { signingKey: cachedSigningKey, certBase64: cachedCertBase64 };
43
+ cachedCertBase64 === certBase64 &&
44
+ cachedPrivateKeyBase64 === privateKeyPkcs8Base64) {
45
+ return cachedSigningKey;
41
46
  }
42
- // Material rotated discard the stale resolved key so we re-import.
43
- if (cachedSigningKey) {
47
+ // FIX 2: cache vars updated inside .then() no race condition.
48
+ signingKeyPromise = null;
49
+ cachedSigningKey = null;
50
+ cachedCertBase64 = null;
51
+ cachedPrivateKeyBase64 = null;
52
+ signingKeyPromise = crypto.subtle
53
+ .importKey("pkcs8", base64ToBytes(privateKeyPkcs8Base64), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"])
54
+ .then((key) => {
55
+ cachedSigningKey = key;
56
+ cachedCertBase64 = certBase64;
57
+ cachedPrivateKeyBase64 = privateKeyPkcs8Base64;
58
+ return key;
59
+ })
60
+ .catch(() => {
44
61
  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;
58
- cachedCertBase64 = input.certBase64;
59
- cachedPrivateKeyBase64 = input.privateKeyPkcs8Base64;
60
- return { signingKey: cachedSigningKey, certBase64: cachedCertBase64 };
61
- }
62
- catch {
63
- signingKeyPromise = null; // allow retry on next call
64
62
  throw new InternalError("invalid_signing_material");
65
- }
63
+ });
64
+ return signingKeyPromise;
66
65
  }
67
- // ---------------------------------------------------------------------------
68
- // Profile TTL resolution
69
- // ---------------------------------------------------------------------------
70
- function resolveSessionTtlByProfile(profile) {
66
+ // ── Session TTL ────────────────────────────────────────────────────────────
67
+ function resolveSessionTtl(profile) {
71
68
  const ttl = PROFILE_TTL[profile] ?? DEFAULT_TTL;
72
69
  return Math.min(Math.max(ttl, MIN_PROFILE_TTL), MAX_PROFILE_TTL);
73
70
  }
74
- // ---------------------------------------------------------------------------
75
- // Main export
76
- // ---------------------------------------------------------------------------
71
+ // ── Main export ────────────────────────────────────────────────────────────
77
72
  export async function issueAwsCredentials(input) {
78
73
  const { roleArn, profileArn, trustAnchorArn, region, certBase64, privateKeyPkcs8Base64, profile, forceRefresh, } = input;
79
- const sessionTtl = resolveSessionTtlByProfile(profile);
74
+ const sessionTtl = resolveSessionTtl(profile);
80
75
  const normalizedCert = normalizeCert(certBase64);
81
- // ---- DER serial extraction (with isolate-level cache) ----
76
+ // BUG 5 KEPT: isolate-level cert serial cache — can serve stale value
77
+ // from a previous (buggy) code version in the same long-lived isolate.
82
78
  let certSerialDec;
83
79
  if (cachedCertSerialDec && cachedCertSerialSource === normalizedCert) {
84
80
  certSerialDec = cachedCertSerialDec;
85
81
  }
86
82
  else {
87
- certSerialDec = parseCertSerialDec(normalizedCert); // throws InternalError on bad DER
83
+ certSerialDec = parseCertSerialDec(normalizedCert);
88
84
  cachedCertSerialDec = certSerialDec;
89
85
  cachedCertSerialSource = normalizedCert;
90
86
  }
91
87
  const cacheKey = `${region}|${roleArn}|${profileArn}|${trustAnchorArn}|${certSerialDec}`;
92
- // ---- Build SigV4 request ----
93
- const { signingKey } = await getSigningMaterial({
94
- certBase64: normalizedCert,
95
- privateKeyPkcs8Base64,
96
- });
88
+ return getCachedOrFetch(cacheKey, () => fetchCredentials({
89
+ roleArn, profileArn, trustAnchorArn,
90
+ region, normalizedCert, privateKeyPkcs8Base64,
91
+ sessionTtl,
92
+ }), { ttlSec: 60, forceRefresh });
93
+ }
94
+ async function fetchCredentials(input) {
95
+ const { roleArn, profileArn, trustAnchorArn, region, normalizedCert, privateKeyPkcs8Base64, sessionTtl, } = input;
96
+ // FIX 1: signing happens inside fetcher — only runs on cache miss.
97
+ const signingKey = await getSigningMaterial(normalizedCert, privateKeyPkcs8Base64);
98
+ const certSerialDec = parseCertSerialDec(normalizedCert);
97
99
  const host = `${SERVICE}.${region}.amazonaws.com`;
98
100
  const iso = new Date().toISOString();
99
101
  const amzDate = isoToAmzDate(iso);
@@ -109,19 +111,12 @@ export async function issueAwsCredentials(input) {
109
111
  const { canonicalHeaders, signedHeaders } = canonicalizeHeaders(baseHeaders);
110
112
  const credentialScope = `${dateStamp}/${region}/${SERVICE}/aws4_request`;
111
113
  const canonicalRequest = buildCanonicalRequest({
112
- method: "POST",
113
- canonicalUri: PATH,
114
- query: "",
115
- canonicalHeaders,
116
- signedHeaders,
117
- payloadHash,
114
+ method: "POST", canonicalUri: PATH, query: "",
115
+ canonicalHeaders, signedHeaders, payloadHash,
118
116
  });
119
- const canonicalRequestHash = await sha256Hex(canonicalRequest);
120
117
  const stringToSign = buildStringToSign({
121
- algorithm: ALGORITHM,
122
- amzDate,
123
- credentialScope,
124
- canonicalRequestHash,
118
+ algorithm: ALGORITHM, amzDate, credentialScope,
119
+ canonicalRequestHash: await sha256Hex(canonicalRequest),
125
120
  });
126
121
  const signatureHex = await signStringToSign(stringToSign, signingKey);
127
122
  const finalHeaders = new Headers({
@@ -130,57 +125,52 @@ export async function issueAwsCredentials(input) {
130
125
  "X-Amz-X509": normalizedCert,
131
126
  "Authorization": `${ALGORITHM} Credential=${certSerialDec}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signatureHex}`,
132
127
  });
133
- const issuedAt = Date.now(); // snapshot before the network round-trip
134
- return getCachedOrFetch(cacheKey, async () => {
135
- const res = await fetch(`https://${host}${PATH}`, {
128
+ // FIX 4: catch network errors as aws_unreachable.
129
+ let res;
130
+ try {
131
+ res = await fetch(`https://${host}${PATH}`, {
136
132
  method: "POST",
137
133
  headers: finalHeaders,
138
134
  body,
139
135
  });
140
- if (!res.ok) {
141
- console.warn("[aws-rejected]", { status: res.status, region, profile });
142
- throw new InternalError("aws_rejected");
143
- }
144
- const json = await res.json();
145
- const creds = json?.credentialSet?.[0]?.credentials;
146
- if (!creds?.accessKeyId || !creds?.secretAccessKey || !creds?.sessionToken) {
147
- console.warn("[issueAwsCredentials] malformed AWS credential response");
148
- throw new InternalError("aws_malformed_credentials");
149
- }
150
- const value = {
151
- accessKeyId: creds.accessKeyId,
152
- secretAccessKey: creds.secretAccessKey,
153
- sessionToken: creds.sessionToken,
154
- expiration: creds.expiration,
155
- };
156
- // derive TTL = 1/3 lifetime
157
- const expiresAtMs = Date.parse(creds.expiration);
158
- const credLifetimeSec = Math.floor((expiresAtMs - issuedAt) / 1000);
159
- if (Number.isFinite(expiresAtMs) && credLifetimeSec > 0) {
160
- const edgeCacheTtlSec = Math.floor(credLifetimeSec / 3);
161
- const edgeCacheExpiry = new Date(issuedAt + edgeCacheTtlSec * 1000).toISOString();
162
- return wrapResult(value, edgeCacheExpiry);
163
- }
164
- return value;
165
- }, {
166
- ttlSec: 60,
167
- ...(forceRefresh !== undefined ? { forceRefresh } : {})
168
- });
136
+ }
137
+ catch (err) {
138
+ console.warn("[aws-unreachable]", { region, err });
139
+ throw new InternalError("aws_unreachable");
140
+ }
141
+ if (!res.ok) {
142
+ console.warn("[aws-rejected]", { status: res.status, region });
143
+ throw new InternalError("aws_rejected");
144
+ }
145
+ const json = await res.json();
146
+ const creds = json?.credentialSet?.[0]?.credentials;
147
+ if (!creds?.accessKeyId || !creds?.secretAccessKey || !creds?.sessionToken) {
148
+ console.warn("[issueAwsCredentials] malformed AWS credential response");
149
+ throw new InternalError("aws_malformed_credentials");
150
+ }
151
+ const value = {
152
+ accessKeyId: creds.accessKeyId,
153
+ secretAccessKey: creds.secretAccessKey,
154
+ sessionToken: creds.sessionToken,
155
+ expiration: creds.expiration,
156
+ };
157
+ // FIX 3: no unsafe cast fetcher typed correctly.
158
+ const receivedAt = Date.now();
159
+ const expiresAtMs = Date.parse(creds.expiration);
160
+ const credLifetimeSec = Math.floor((expiresAtMs - receivedAt) / 1000);
161
+ if (Number.isFinite(expiresAtMs) && credLifetimeSec > 0) {
162
+ const cacheTtlSec = Math.floor(credLifetimeSec / 3);
163
+ const cacheExpiry = new Date(receivedAt + cacheTtlSec * 1000).toISOString();
164
+ return wrapResult(value, cacheExpiry);
165
+ }
166
+ return value;
169
167
  }
170
- // ---------------------------------------------------------------------------
171
- // Helpers
172
- // ---------------------------------------------------------------------------
173
- /** Strip whitespace and reject PEM-wrapped input. */
168
+ // ── Helpers ────────────────────────────────────────────────────────────────
174
169
  function normalizeCert(raw) {
175
- if (raw.includes("BEGIN CERTIFICATE")) {
170
+ if (raw.includes("BEGIN CERTIFICATE"))
176
171
  throw new InternalError("pem_not_allowed");
177
- }
178
172
  return raw.replace(/\s+/g, "");
179
173
  }
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
- */
184
174
  function parseCertSerialDec(normalizedCertBase64) {
185
175
  try {
186
176
  const der = base64ToBytes(normalizedCertBase64);
@@ -205,11 +195,9 @@ function parseCertSerialDec(normalizedCertBase64) {
205
195
  if (der[offset++] !== 0x30)
206
196
  throw new Error("bad tbs");
207
197
  readLen();
208
- // Skip optional [0] EXPLICIT version field.
209
198
  if (der[offset] === 0xa0) {
210
199
  offset++;
211
- const vLen = readLen();
212
- offset += vLen;
200
+ offset += readLen();
213
201
  }
214
202
  if (der[offset++] !== 0x02)
215
203
  throw new Error("bad serial tag");
@@ -217,15 +205,11 @@ function parseCertSerialDec(normalizedCertBase64) {
217
205
  if (offset + serialLen > der.length)
218
206
  throw new Error("DER overflow");
219
207
  let serial = der.slice(offset, offset + serialLen);
220
- // Strip ASN.1 sign-extension padding byte.
221
- if (serial.length > 1 && serial[0] === 0x00) {
208
+ if (serial.length > 1 && serial[0] === 0x00)
222
209
  serial = serial.slice(1);
223
- }
224
- // Accumulate directly to BigInt — avoids intermediate hex string allocation.
225
210
  let serialBig = 0n;
226
- for (let i = 0; i < serial.length; i++) {
211
+ for (let i = 0; i < serial.length; i++)
227
212
  serialBig = (serialBig << 8n) | BigInt(serial[i]);
228
- }
229
213
  return serialBig.toString();
230
214
  }
231
215
  catch (e) {
@@ -233,26 +217,16 @@ function parseCertSerialDec(normalizedCertBase64) {
233
217
  throw new InternalError("invalid_cert_der");
234
218
  }
235
219
  }
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
- */
240
220
  function isoToAmzDate(iso) {
241
- return (iso.slice(0, 4) +
242
- iso.slice(5, 7) +
243
- iso.slice(8, 10) +
221
+ return (iso.slice(0, 4) + iso.slice(5, 7) + iso.slice(8, 10) +
244
222
  "T" +
245
- iso.slice(11, 13) +
246
- iso.slice(14, 16) +
247
- iso.slice(17, 19) +
223
+ iso.slice(11, 13) + iso.slice(14, 16) + iso.slice(17, 19) +
248
224
  "Z");
249
225
  }
250
- /** Faster base64 → Uint8Array using V8's vectorized atob path. */
251
226
  function base64ToBytes(base64) {
252
227
  const binary = atob(base64);
253
228
  const bytes = new Uint8Array(binary.length);
254
- for (let i = 0; i < binary.length; i++) {
229
+ for (let i = 0; i < binary.length; i++)
255
230
  bytes[i] = binary.charCodeAt(i);
256
- }
257
231
  return bytes;
258
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizamodo/aws-sts-core",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "Pure AWS STS + SigV4 (X509 Roles Anywhere) core logic",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",