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