@vizamodo/aws-sts-core 0.4.7 → 0.4.9

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 interface IssueAwsCredentialsInput {
2
+ export declare function issueAwsCredentials(input: {
3
3
  roleArn: string;
4
4
  profileArn: string;
5
5
  trustAnchorArn: string;
@@ -8,13 +8,4 @@ export interface IssueAwsCredentialsInput {
8
8
  privateKeyPkcs8Base64: string;
9
9
  profile: string;
10
10
  forceRefresh?: boolean;
11
- }
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.
18
- */
19
- export declare function resetIsolateCache(): void;
20
- export declare function issueAwsCredentials(input: IssueAwsCredentialsInput): Promise<AwsCredentialResult>;
11
+ }): 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
- // ── Constants ──────────────────────────────────────────────────────────────
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,89 +17,83 @@ 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
27
- // ── Isolate-level signing-material cache ───────────────────────────────────
28
- // Single Promise slot prevents concurrent cold-start import races.
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.
29
25
  let signingKeyPromise = null;
30
26
  let cachedSigningKey = null;
31
27
  let cachedCertBase64 = null;
32
28
  let cachedPrivateKeyBase64 = null;
33
- // ── Isolate-level cert-serial cache ───────────────────────────────────────
34
- // DER walk is CPU-bound; the cert rarely rotates within an isolate lifetime.
29
+ // ---- certificate serial cache (DER walk is CPU-bound, cert rarely rotates) ----
35
30
  let cachedCertSerialDec = null;
36
31
  let cachedCertSerialSource = null;
37
- // ── Test utilities ─────────────────────────────────────────────────────────
38
- /**
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.
44
- */
45
- export function resetIsolateCache() {
46
- signingKeyPromise = null;
47
- cachedSigningKey = null;
48
- cachedCertBase64 = null;
49
- cachedPrivateKeyBase64 = null;
50
- cachedCertSerialDec = null;
51
- cachedCertSerialSource = null;
52
- }
53
- // ── Signing material ───────────────────────────────────────────────────────
54
- async function getSigningMaterial(certBase64, privateKeyPkcs8Base64) {
55
- // Fast path: same material already imported in this isolate.
32
+ // ---------------------------------------------------------------------------
33
+ // Signing material
34
+ // ---------------------------------------------------------------------------
35
+ async function getSigningMaterial(input) {
36
+ // Fast path: same material already imported.
56
37
  if (cachedSigningKey &&
57
- cachedCertBase64 === certBase64 &&
58
- cachedPrivateKeyBase64 === privateKeyPkcs8Base64) {
59
- return 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
+ }
60
55
  }
61
- // 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.
64
- signingKeyPromise = null;
65
- cachedSigningKey = null;
66
- cachedCertBase64 = null;
67
- cachedPrivateKeyBase64 = null;
68
- signingKeyPromise = crypto.subtle
69
- .importKey("pkcs8", base64ToBytes(privateKeyPkcs8Base64), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"])
70
- .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
- cachedSigningKey = key;
75
- cachedCertBase64 = certBase64;
76
- cachedPrivateKeyBase64 = privateKeyPkcs8Base64;
77
- return key;
78
- })
79
- .catch(() => {
56
+ try {
57
+ cachedSigningKey = await signingKeyPromise;
58
+ cachedCertBase64 = input.certBase64;
59
+ cachedPrivateKeyBase64 = input.privateKeyPkcs8Base64;
60
+ return { signingKey: cachedSigningKey, certBase64: cachedCertBase64 };
61
+ }
62
+ catch {
80
63
  signingKeyPromise = null; // allow retry on next call
81
64
  throw new InternalError("invalid_signing_material");
82
- });
83
- return signingKeyPromise;
65
+ }
84
66
  }
85
- // ── Session TTL ────────────────────────────────────────────────────────────
86
- 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);
67
+ // ---------------------------------------------------------------------------
68
+ // Profile TTL resolution
69
+ // ---------------------------------------------------------------------------
70
+ function resolveSessionTtlByProfile(profile) {
71
+ const ttl = PROFILE_TTL[profile] ?? DEFAULT_TTL;
72
+ return Math.min(Math.max(ttl, MIN_PROFILE_TTL), MAX_PROFILE_TTL);
89
73
  }
90
- // ── Main export ────────────────────────────────────────────────────────────
74
+ // ---------------------------------------------------------------------------
75
+ // Main export
76
+ // ---------------------------------------------------------------------------
91
77
  export async function issueAwsCredentials(input) {
92
78
  const { roleArn, profileArn, trustAnchorArn, region, certBase64, privateKeyPkcs8Base64, profile, forceRefresh, } = input;
93
- const sessionTtl = resolveSessionTtl(profile);
79
+ const sessionTtl = resolveSessionTtlByProfile(profile);
94
80
  const normalizedCert = normalizeCert(certBase64);
95
- // Cert serial use isolate cache to avoid redundant DER walks.
96
- const certSerialDec = getCertSerialDec(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
+ }
97
91
  const cacheKey = `${region}|${roleArn}|${profileArn}|${trustAnchorArn}|${certSerialDec}`;
98
- return getCachedOrFetch(cacheKey, () => fetchCredentials({
99
- roleArn, profileArn, trustAnchorArn,
100
- region, normalizedCert, privateKeyPkcs8Base64,
101
- sessionTtl,
102
- }), { ttlSec: 60, forceRefresh });
103
- }
104
- async function fetchCredentials(input) {
105
- const { roleArn, profileArn, trustAnchorArn, region, normalizedCert, privateKeyPkcs8Base64, sessionTtl, } = input;
106
- const signingKey = await getSigningMaterial(normalizedCert, privateKeyPkcs8Base64);
92
+ // ---- Build SigV4 request ----
93
+ const { signingKey } = await getSigningMaterial({
94
+ certBase64: normalizedCert,
95
+ privateKeyPkcs8Base64,
96
+ });
107
97
  const host = `${SERVICE}.${region}.amazonaws.com`;
108
98
  const iso = new Date().toISOString();
109
99
  const amzDate = isoToAmzDate(iso);
@@ -134,105 +124,59 @@ async function fetchCredentials(input) {
134
124
  canonicalRequestHash,
135
125
  });
136
126
  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
127
  const finalHeaders = new Headers({
168
128
  "Content-Type": "application/json",
169
129
  "X-Amz-Date": amzDate,
170
130
  "X-Amz-X509": normalizedCert,
171
131
  "Authorization": `${ALGORITHM} Credential=${certSerialDec}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signatureHex}`,
172
132
  });
173
- let res;
174
- try {
175
- res = await fetch(`https://${host}${PATH}`, {
133
+ const issuedAt = Date.now(); // snapshot before the network round-trip
134
+ return getCachedOrFetch(cacheKey, async () => {
135
+ const res = await fetch(`https://${host}${PATH}`, {
176
136
  method: "POST",
177
137
  headers: finalHeaders,
178
138
  body,
179
139
  });
180
- }
181
- catch (err) {
182
- console.warn("[aws-unreachable]", { region, err });
183
- throw new InternalError("aws_unreachable");
184
- }
185
- 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
- });
192
- throw new InternalError("aws_rejected");
193
- }
194
- const json = await res.json();
195
- const creds = json?.credentialSet?.[0]?.credentials;
196
- if (!creds?.accessKeyId || !creds?.secretAccessKey || !creds?.sessionToken) {
197
- console.warn("[issueAwsCredentials] malformed AWS credential response");
198
- throw new InternalError("aws_malformed_credentials");
199
- }
200
- const value = {
201
- accessKeyId: creds.accessKeyId,
202
- secretAccessKey: creds.secretAccessKey,
203
- sessionToken: creds.sessionToken,
204
- expiration: creds.expiration,
205
- };
206
- // 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);
209
- const receivedAt = Date.now();
210
- const credLifetimeSec = Math.floor((expiresAtMs - receivedAt) / 1000);
211
- if (Number.isFinite(expiresAtMs) && credLifetimeSec > 0) {
212
- const cacheTtlSec = Math.floor(credLifetimeSec / 3);
213
- const cacheExpiry = new Date(receivedAt + cacheTtlSec * 1000).toISOString();
214
- return wrapResult(value, cacheExpiry);
215
- }
216
- return value;
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
+ });
217
169
  }
218
- // ── Helpers ────────────────────────────────────────────────────────────────
219
- /** Strip whitespace; reject PEM-wrapped input. */
170
+ // ---------------------------------------------------------------------------
171
+ // Helpers
172
+ // ---------------------------------------------------------------------------
173
+ /** Strip whitespace and reject PEM-wrapped input. */
220
174
  function normalizeCert(raw) {
221
175
  if (raw.includes("BEGIN CERTIFICATE")) {
222
176
  throw new InternalError("pem_not_allowed");
223
177
  }
224
178
  return raw.replace(/\s+/g, "");
225
179
  }
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
180
  /**
237
181
  * Minimal DER walk to extract the certificate serial number as a decimal string.
238
182
  * Throws InternalError("invalid_cert_der") on any parse failure.
@@ -255,30 +199,29 @@ function parseCertSerialDec(normalizedCertBase64) {
255
199
  len = (len << 8) | der[offset++];
256
200
  return len;
257
201
  }
258
- // Certificate ::= SEQUENCE
259
202
  if (der[offset++] !== 0x30)
260
203
  throw new Error("bad cert");
261
204
  readLen();
262
- // tbsCertificate ::= SEQUENCE
263
205
  if (der[offset++] !== 0x30)
264
206
  throw new Error("bad tbs");
265
207
  readLen();
266
- // Optional version [0] EXPLICIT
208
+ // Skip optional [0] EXPLICIT version field.
267
209
  if (der[offset] === 0xa0) {
268
- offset++; // tag
269
- offset += readLen(); // skip content
210
+ offset++;
211
+ const vLen = readLen();
212
+ offset += vLen;
270
213
  }
271
- // 👉 SERIAL MUST be the next INTEGER after optional version
272
214
  if (der[offset++] !== 0x02)
273
215
  throw new Error("bad serial tag");
274
- const len = readLen();
275
- if (offset + len > der.length)
216
+ const serialLen = readLen();
217
+ if (offset + serialLen > der.length)
276
218
  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
219
+ let serial = der.slice(offset, offset + serialLen);
220
+ // Strip ASN.1 sign-extension padding byte.
279
221
  if (serial.length > 1 && serial[0] === 0x00) {
280
222
  serial = serial.slice(1);
281
223
  }
224
+ // Accumulate directly to BigInt — avoids intermediate hex string allocation.
282
225
  let serialBig = 0n;
283
226
  for (let i = 0; i < serial.length; i++) {
284
227
  serialBig = (serialBig << 8n) | BigInt(serial[i]);
@@ -291,7 +234,7 @@ function parseCertSerialDec(normalizedCertBase64) {
291
234
  }
292
235
  }
293
236
  /**
294
- * Convert ISO-8601 to compact AMZ date-time format.
237
+ * Convert an ISO-8601 timestamp to the compact AMZ date-time format.
295
238
  * e.g. "2026-03-07T12:00:00.000Z" → "20260307T120000Z"
296
239
  */
297
240
  function isoToAmzDate(iso) {
@@ -304,7 +247,7 @@ function isoToAmzDate(iso) {
304
247
  iso.slice(17, 19) +
305
248
  "Z");
306
249
  }
307
- /** base64 → Uint8Array via V8's vectorized atob path. */
250
+ /** Faster base64 → Uint8Array using V8's vectorized atob path. */
308
251
  function base64ToBytes(base64) {
309
252
  const binary = atob(base64);
310
253
  const bytes = new Uint8Array(binary.length);
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.9",
4
4
  "description": "Pure AWS STS + SigV4 (X509 Roles Anywhere) core logic",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",