@vizamodo/aws-sts-core 0.4.5 → 0.4.6

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.
@@ -9,12 +9,4 @@ export interface IssueAwsCredentialsInput {
9
9
  profile: string;
10
10
  forceRefresh?: boolean;
11
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
12
  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";
2
1
  import { canonicalizeHeaders } from "../sigv4/headers";
3
2
  import { buildCanonicalRequest } from "../sigv4/canonical";
4
3
  import { buildStringToSign } from "../sigv4/string-to-sign";
5
4
  import { signStringToSign } from "./signer";
6
5
  import { InternalError } from "./errors";
7
6
  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,
@@ -22,88 +18,59 @@ const PROFILE_TTL = {
22
18
  BillingAccountant: 3 * 60 * 60,
23
19
  };
24
20
  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.
21
+ const MIN_SESSION_TTL = 45 * 60;
22
+ const MAX_SESSION_TTL = 12 * 60 * 60;
23
+ // ── Isolate-level Caches (L1) ─────────────────────────────────────────────
24
+ // Cache vật liệu để tránh re-import CryptoKey liên tục
29
25
  let signingKeyPromise = null;
30
26
  let cachedSigningKey = null;
31
27
  let cachedCertBase64 = null;
32
28
  let cachedPrivateKeyBase64 = null;
33
- // ── Test utilities ─────────────────────────────────────────────────────────
34
- /**
35
- * Reset isolate-level signing material and cert serial caches.
36
- * ONLY for use in tests — forces re-import of signing key on next call.
37
- *
38
- * L1 cache is bypassed in tests via `forceRefresh: true` on each call —
39
- * there is no need to expose a cache-clear API from the core library.
40
- */
41
- export function resetIsolateCache() {
42
- signingKeyPromise = null;
43
- cachedSigningKey = null;
44
- cachedCertBase64 = null;
45
- cachedPrivateKeyBase64 = null;
46
- }
47
- // ── Signing material ───────────────────────────────────────────────────────
48
- async function getSigningMaterial(certBase64, privateKeyPkcs8Base64) {
49
- // Fast path: same material already imported in this isolate.
50
- if (cachedSigningKey &&
51
- cachedCertBase64 === certBase64 &&
52
- cachedPrivateKeyBase64 === privateKeyPkcs8Base64) {
53
- return cachedSigningKey;
54
- }
55
- // Material changed or first call — reset and re-import.
56
- // Cache vars are updated inside .then() so concurrent callers
57
- // awaiting the same promise all see consistent state after resolve.
58
- signingKeyPromise = null;
59
- cachedSigningKey = null;
60
- cachedCertBase64 = null;
61
- cachedPrivateKeyBase64 = null;
62
- signingKeyPromise = crypto.subtle
63
- .importKey("pkcs8", base64ToBytes(privateKeyPkcs8Base64), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"])
64
- .then((key) => {
65
- // Update cache atomically after successful import.
66
- // All concurrent callers awaiting this promise will see
67
- // consistent state and hit the fast path on next call.
68
- cachedSigningKey = key;
69
- cachedCertBase64 = certBase64;
70
- cachedPrivateKeyBase64 = privateKeyPkcs8Base64;
71
- return key;
72
- })
73
- .catch(() => {
74
- signingKeyPromise = null; // allow retry on next call
75
- throw new InternalError("invalid_signing_material");
76
- });
77
- return signingKeyPromise;
78
- }
79
- // ── Session TTL ────────────────────────────────────────────────────────────
80
- function resolveSessionTtl(profile) {
81
- const ttl = PROFILE_TTL[profile] ?? DEFAULT_SESSION_TTL;
82
- return Math.min(Math.max(ttl, MIN_SESSION_TTL), MAX_SESSION_TTL);
83
- }
84
- // ── Main export ────────────────────────────────────────────────────────────
29
+ // Cache Serial Number để tránh redundant DER walks (CPU-bound)
30
+ let cachedCertSerialDec = null;
31
+ let cachedCertSerialSource = null;
32
+ // ── Main Export ────────────────────────────────────────────────────────────
85
33
  export async function issueAwsCredentials(input) {
86
34
  const { roleArn, profileArn, trustAnchorArn, region, certBase64, privateKeyPkcs8Base64, profile, forceRefresh, } = input;
87
35
  const sessionTtl = resolveSessionTtl(profile);
88
36
  const normalizedCert = normalizeCert(certBase64);
89
- // Cert serial — use isolate cache to avoid redundant DER walks.
90
37
  const certSerialDec = getCertSerialDec(normalizedCert);
91
- const cacheKey = `${region}|${roleArn}|${profileArn}|${trustAnchorArn}|${certSerialDec}`;
92
- return getCachedOrFetch(cacheKey, () => fetchCredentials({
93
- roleArn, profileArn, trustAnchorArn,
94
- region, normalizedCert, privateKeyPkcs8Base64,
95
- sessionTtl,
96
- }), { ttlSec: 60, forceRefresh });
38
+ // Cache Key duy nhất dựa trên định danh của Role và Certificate
39
+ const cacheKey = `aws|${region}|${roleArn}|${certSerialDec}`;
40
+ return getCachedOrFetch(cacheKey, async () => {
41
+ return fetchAwsCredentials({
42
+ roleArn, profileArn, trustAnchorArn,
43
+ region, normalizedCert, privateKeyPkcs8Base64,
44
+ sessionTtl,
45
+ certSerialDec
46
+ });
47
+ }, {
48
+ // Cache L2 (Edge) sẽ được refresh khi còn 1/3 thời gian hiệu lực
49
+ // hoặc cưỡng bức refresh qua forceRefresh
50
+ ttlSec: 60,
51
+ forceRefresh
52
+ });
97
53
  }
98
- async function fetchCredentials(input) {
99
- const { roleArn, profileArn, trustAnchorArn, region, normalizedCert, privateKeyPkcs8Base64, sessionTtl, } = input;
54
+ // ── Core Logic ─────────────────────────────────────────────────────────────
55
+ async function fetchAwsCredentials(params) {
56
+ const { roleArn, profileArn, trustAnchorArn, region, normalizedCert, privateKeyPkcs8Base64, sessionTtl, certSerialDec } = params;
57
+ // 1. Chuẩn bị Signing Material (Isolate-cached)
100
58
  const signingKey = await getSigningMaterial(normalizedCert, privateKeyPkcs8Base64);
59
+ // 2. AWS SigV4 Metadata
101
60
  const host = `${SERVICE}.${region}.amazonaws.com`;
102
- const iso = new Date().toISOString();
103
- const amzDate = isoToAmzDate(iso);
104
- const dateStamp = iso.slice(0, 4) + iso.slice(5, 7) + iso.slice(8, 10);
105
- const body = JSON.stringify({ trustAnchorArn, profileArn, roleArn, durationSeconds: sessionTtl });
61
+ const now = new Date();
62
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ""); // e.g. 20260318T220520Z
63
+ const dateStamp = amzDate.slice(0, 8);
64
+ const credentialScope = `${dateStamp}/${region}/${SERVICE}/aws4_request`;
65
+ // 3. Request Body & Payload Hash
66
+ const body = JSON.stringify({
67
+ trustAnchorArn,
68
+ profileArn,
69
+ roleArn,
70
+ durationSeconds: sessionTtl
71
+ });
106
72
  const payloadHash = await sha256Hex(body);
73
+ // 4. Canonical Request & StringToSign
107
74
  const baseHeaders = {
108
75
  "content-type": "application/json",
109
76
  "host": host,
@@ -111,7 +78,6 @@ async function fetchCredentials(input) {
111
78
  "x-amz-x509": normalizedCert,
112
79
  };
113
80
  const { canonicalHeaders, signedHeaders } = canonicalizeHeaders(baseHeaders);
114
- const credentialScope = `${dateStamp}/${region}/${SERVICE}/aws4_request`;
115
81
  const canonicalRequest = buildCanonicalRequest({
116
82
  method: "POST",
117
83
  canonicalUri: PATH,
@@ -120,147 +86,128 @@ async function fetchCredentials(input) {
120
86
  signedHeaders,
121
87
  payloadHash,
122
88
  });
123
- const canonicalRequestHash = await sha256Hex(canonicalRequest);
124
89
  const stringToSign = buildStringToSign({
125
90
  algorithm: ALGORITHM,
126
91
  amzDate,
127
92
  credentialScope,
128
- canonicalRequestHash,
93
+ canonicalRequestHash: await sha256Hex(canonicalRequest),
129
94
  });
95
+ // 5. Signature
130
96
  const signatureHex = await signStringToSign(stringToSign, signingKey);
131
- const certSerialDec = getCertSerialDec(normalizedCert);
132
- const finalHeaders = new Headers({
133
- "Content-Type": "application/json",
134
- "X-Amz-Date": amzDate,
135
- "X-Amz-X509": normalizedCert,
136
- "Authorization": `${ALGORITHM} Credential=${certSerialDec}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signatureHex}`,
97
+ // 6. Execution
98
+ const res = await fetch(`https://${host}${PATH}`, {
99
+ method: "POST",
100
+ headers: {
101
+ "Content-Type": "application/json",
102
+ "X-Amz-Date": amzDate,
103
+ "X-Amz-X509": normalizedCert,
104
+ "Authorization": `${ALGORITHM} Credential=${certSerialDec}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signatureHex}`,
105
+ },
106
+ body,
137
107
  });
138
- let res;
139
- try {
140
- res = await fetch(`https://${host}${PATH}`, {
141
- method: "POST",
142
- headers: finalHeaders,
143
- body,
144
- });
145
- }
146
- catch (err) {
147
- console.warn("[aws-unreachable]", { region, err });
148
- throw new InternalError("aws_unreachable");
149
- }
150
108
  if (!res.ok) {
151
- console.warn("[aws-rejected]", { status: res.status, region });
109
+ const errorText = await res.text().catch(() => "unknown");
110
+ console.error("[aws-rejected]", { status: res.status, body: errorText });
152
111
  throw new InternalError("aws_rejected");
153
112
  }
154
- const json = await res.json();
155
- const creds = json?.credentialSet?.[0]?.credentials;
156
- if (!creds?.accessKeyId || !creds?.secretAccessKey || !creds?.sessionToken) {
157
- console.warn("[issueAwsCredentials] malformed AWS credential response");
113
+ const data = await res.json();
114
+ const creds = data?.credentialSet?.[0]?.credentials;
115
+ if (!creds?.accessKeyId) {
158
116
  throw new InternalError("aws_malformed_credentials");
159
117
  }
160
- const value = {
118
+ const result = {
161
119
  accessKeyId: creds.accessKeyId,
162
120
  secretAccessKey: creds.secretAccessKey,
163
121
  sessionToken: creds.sessionToken,
164
122
  expiration: creds.expiration,
165
123
  };
166
- // Cache for 1/3 of the credential lifetime so it's refreshed well before expiry.
167
- // deriveEdgeTtlSec in cache.ts will further subtract EDGE_BUFFER_SEC (5 min).
124
+ // 7. Cache Control (L2 Edge Cache)
125
+ // Chiến thuật: Cache trong 1/3 thời gian hiệu lực của AWS Credential
168
126
  const expiresAtMs = Date.parse(creds.expiration);
169
- const receivedAt = Date.now();
170
- const credLifetimeSec = Math.floor((expiresAtMs - receivedAt) / 1000);
171
- if (Number.isFinite(expiresAtMs) && credLifetimeSec > 0) {
127
+ const credLifetimeSec = Math.floor((expiresAtMs - Date.now()) / 1000);
128
+ if (credLifetimeSec > 300) { // Chỉ cache nếu còn hiệu lực trên 5 phút
172
129
  const cacheTtlSec = Math.floor(credLifetimeSec / 3);
173
- const cacheExpiry = new Date(receivedAt + cacheTtlSec * 1000).toISOString();
174
- return wrapResult(value, cacheExpiry);
130
+ const cacheExpiry = new Date(Date.now() + cacheTtlSec * 1000).toISOString();
131
+ return wrapResult(result, cacheExpiry);
175
132
  }
176
- return value;
133
+ return result;
177
134
  }
178
135
  // ── Helpers ────────────────────────────────────────────────────────────────
179
- /** Strip whitespace; reject PEM-wrapped input. */
136
+ function resolveSessionTtl(profile) {
137
+ const ttl = PROFILE_TTL[profile] ?? DEFAULT_SESSION_TTL;
138
+ return Math.min(Math.max(ttl, MIN_SESSION_TTL), MAX_SESSION_TTL);
139
+ }
180
140
  function normalizeCert(raw) {
181
- if (raw.includes("BEGIN CERTIFICATE")) {
141
+ if (raw.includes("BEGIN CERTIFICATE"))
182
142
  throw new InternalError("pem_not_allowed");
183
- }
184
143
  return raw.replace(/\s+/g, "");
185
144
  }
186
- /** Extract cert serial as decimal string from DER-encoded base64 cert. */
187
145
  function getCertSerialDec(normalizedCert) {
188
- return parseCertSerialDec(normalizedCert);
189
- }
190
- /**
191
- * Minimal DER walk to extract the certificate serial number as a decimal string.
192
- * Throws InternalError("invalid_cert_der") on any parse failure.
193
- */
194
- function parseCertSerialDec(normalizedCertBase64) {
195
- try {
196
- const der = base64ToBytes(normalizedCertBase64);
197
- let offset = 0;
198
- function readLen() {
199
- if (offset >= der.length)
200
- throw new Error("DER overflow");
201
- const b = der[offset++];
202
- if ((b & 0x80) === 0)
203
- return b;
204
- const n = b & 0x7f;
205
- let len = 0;
206
- if (offset + n > der.length)
207
- throw new Error("DER overflow");
208
- for (let i = 0; i < n; i++)
209
- len = (len << 8) | der[offset++];
210
- return len;
211
- }
212
- if (der[offset++] !== 0x30)
213
- throw new Error("bad cert");
214
- readLen();
215
- if (der[offset++] !== 0x30)
216
- throw new Error("bad tbs");
217
- readLen();
218
- // Skip optional [0] EXPLICIT version field.
219
- if (der[offset] === 0xa0) {
220
- offset++;
221
- offset += readLen();
222
- }
223
- if (der[offset++] !== 0x02)
224
- throw new Error("bad serial tag");
225
- const serialLen = readLen();
226
- if (offset + serialLen > der.length)
227
- throw new Error("DER overflow");
228
- let serial = der.slice(offset, offset + serialLen);
229
- // Strip ASN.1 sign-extension padding byte.
230
- if (serial.length > 1 && serial[0] === 0x00) {
231
- serial = serial.slice(1);
232
- }
233
- let serialBig = 0n;
234
- for (let i = 0; i < serial.length; i++) {
235
- serialBig = (serialBig << 8n) | BigInt(serial[i]);
236
- }
237
- return serialBig.toString();
146
+ if (cachedCertSerialDec && cachedCertSerialSource === normalizedCert) {
147
+ return cachedCertSerialDec;
238
148
  }
239
- catch (e) {
240
- console.error("[parseCertSerialDec] failed", e);
241
- throw new InternalError("invalid_cert_der");
149
+ const der = base64ToBytes(normalizedCert);
150
+ let offset = 0;
151
+ const readLen = () => {
152
+ const b = der[offset++];
153
+ if ((b & 0x80) === 0)
154
+ return b;
155
+ let n = b & 0x7f;
156
+ let len = 0;
157
+ while (n--)
158
+ len = (len << 8) | der[offset++];
159
+ return len;
160
+ };
161
+ // Strict DER Walk (Sửa lỗi skip sai INTEGER của bản cũ)
162
+ if (der[offset++] !== 0x30)
163
+ throw new Error("Not a SEQUENCE"); // Cert
164
+ readLen();
165
+ if (der[offset++] !== 0x30)
166
+ throw new Error("Not a SEQUENCE"); // TBS
167
+ readLen();
168
+ if (der[offset] === 0xa0) { // Version tag
169
+ offset++;
170
+ offset += readLen();
242
171
  }
172
+ if (der[offset++] !== 0x02)
173
+ throw new Error("Serial not an INTEGER");
174
+ const sLen = readLen();
175
+ let serial = der.slice(offset, offset + sLen);
176
+ // Strip ASN.1 padding byte 0x00
177
+ if (serial.length > 1 && serial[0] === 0x00)
178
+ serial = serial.slice(1);
179
+ let big = 0n;
180
+ for (const b of serial)
181
+ big = (big << 8n) | BigInt(b);
182
+ cachedCertSerialDec = big.toString();
183
+ cachedCertSerialSource = normalizedCert;
184
+ return cachedCertSerialDec;
243
185
  }
244
- /**
245
- * Convert ISO-8601 to compact AMZ date-time format.
246
- * e.g. "2026-03-07T12:00:00.000Z" → "20260307T120000Z"
247
- */
248
- function isoToAmzDate(iso) {
249
- return (iso.slice(0, 4) +
250
- iso.slice(5, 7) +
251
- iso.slice(8, 10) +
252
- "T" +
253
- iso.slice(11, 13) +
254
- iso.slice(14, 16) +
255
- iso.slice(17, 19) +
256
- "Z");
186
+ async function getSigningMaterial(cert, pk) {
187
+ if (cachedSigningKey && cachedCertBase64 === cert && cachedPrivateKeyBase64 === pk) {
188
+ return cachedSigningKey;
189
+ }
190
+ const pkBytes = base64ToBytes(pk);
191
+ signingKeyPromise = crypto.subtle.importKey("pkcs8",
192
+ // LẤY BUFFER VÀ ÉP KIỂU:
193
+ // Uint8Array.buffer thể là ArrayBuffer | SharedArrayBuffer
194
+ // Web Crypto chỉ chấp nhận ArrayBuffer.
195
+ pkBytes.buffer, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]).then(key => {
196
+ cachedSigningKey = key;
197
+ cachedCertBase64 = cert;
198
+ cachedPrivateKeyBase64 = pk;
199
+ return key;
200
+ }).catch(e => {
201
+ signingKeyPromise = null;
202
+ throw new InternalError("invalid_signing_material");
203
+ });
204
+ return signingKeyPromise;
257
205
  }
258
- /** base64 → Uint8Array via V8's vectorized atob path. */
259
- function base64ToBytes(base64) {
260
- const binary = atob(base64);
261
- const bytes = new Uint8Array(binary.length);
262
- for (let i = 0; i < binary.length; i++) {
263
- bytes[i] = binary.charCodeAt(i);
206
+ function base64ToBytes(b64) {
207
+ const bin = atob(b64);
208
+ const bytes = new Uint8Array(bin.length);
209
+ for (let i = 0; i < bin.length; i++) {
210
+ bytes[i] = bin.charCodeAt(i);
264
211
  }
265
212
  return bytes;
266
213
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizamodo/aws-sts-core",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Pure AWS STS + SigV4 (X509 Roles Anywhere) core logic",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",