@vizamodo/aws-sts-core 0.4.6 → 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,5 +8,4 @@ export interface IssueAwsCredentialsInput {
8
8
  privateKeyPkcs8Base64: string;
9
9
  profile: string;
10
10
  forceRefresh?: boolean;
11
- }
12
- export declare function issueAwsCredentials(input: IssueAwsCredentialsInput): Promise<AwsCredentialResult>;
11
+ }): Promise<AwsCredentialResult>;
package/dist/sts/issue.js CHANGED
@@ -1,11 +1,11 @@
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";
@@ -17,60 +17,89 @@ const PROFILE_TTL = {
17
17
  BillingAdmin: 3 * 60 * 60,
18
18
  BillingAccountant: 3 * 60 * 60,
19
19
  };
20
- const DEFAULT_SESSION_TTL = 2 * 60 * 60;
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
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.
25
25
  let signingKeyPromise = null;
26
26
  let cachedSigningKey = null;
27
27
  let cachedCertBase64 = null;
28
28
  let cachedPrivateKeyBase64 = null;
29
- // Cache Serial Number để tránh redundant DER walks (CPU-bound)
29
+ // ---- certificate serial cache (DER walk is CPU-bound, cert rarely rotates) ----
30
30
  let cachedCertSerialDec = null;
31
31
  let cachedCertSerialSource = null;
32
- // ── Main Export ────────────────────────────────────────────────────────────
32
+ // ---------------------------------------------------------------------------
33
+ // Signing material
34
+ // ---------------------------------------------------------------------------
35
+ async function getSigningMaterial(input) {
36
+ // Fast path: same material already imported.
37
+ 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 };
61
+ }
62
+ catch {
63
+ signingKeyPromise = null; // allow retry on next call
64
+ throw new InternalError("invalid_signing_material");
65
+ }
66
+ }
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);
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // Main export
76
+ // ---------------------------------------------------------------------------
33
77
  export async function issueAwsCredentials(input) {
34
78
  const { roleArn, profileArn, trustAnchorArn, region, certBase64, privateKeyPkcs8Base64, profile, forceRefresh, } = input;
35
- const sessionTtl = resolveSessionTtl(profile);
79
+ const sessionTtl = resolveSessionTtlByProfile(profile);
36
80
  const normalizedCert = normalizeCert(certBase64);
37
- const certSerialDec = getCertSerialDec(normalizedCert);
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
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
+ }
91
+ const cacheKey = `${region}|${roleArn}|${profileArn}|${trustAnchorArn}|${certSerialDec}`;
92
+ // ---- Build SigV4 request ----
93
+ const { signingKey } = await getSigningMaterial({
94
+ certBase64: normalizedCert,
95
+ privateKeyPkcs8Base64,
52
96
  });
53
- }
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)
58
- const signingKey = await getSigningMaterial(normalizedCert, privateKeyPkcs8Base64);
59
- // 2. AWS SigV4 Metadata
60
97
  const host = `${SERVICE}.${region}.amazonaws.com`;
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
- });
98
+ const iso = new Date().toISOString();
99
+ const amzDate = isoToAmzDate(iso);
100
+ const dateStamp = iso.slice(0, 4) + iso.slice(5, 7) + iso.slice(8, 10);
101
+ const body = JSON.stringify({ trustAnchorArn, profileArn, roleArn, durationSeconds: sessionTtl });
72
102
  const payloadHash = await sha256Hex(body);
73
- // 4. Canonical Request & StringToSign
74
103
  const baseHeaders = {
75
104
  "content-type": "application/json",
76
105
  "host": host,
@@ -78,6 +107,7 @@ async function fetchAwsCredentials(params) {
78
107
  "x-amz-x509": normalizedCert,
79
108
  };
80
109
  const { canonicalHeaders, signedHeaders } = canonicalizeHeaders(baseHeaders);
110
+ const credentialScope = `${dateStamp}/${region}/${SERVICE}/aws4_request`;
81
111
  const canonicalRequest = buildCanonicalRequest({
82
112
  method: "POST",
83
113
  canonicalUri: PATH,
@@ -86,128 +116,143 @@ async function fetchAwsCredentials(params) {
86
116
  signedHeaders,
87
117
  payloadHash,
88
118
  });
119
+ const canonicalRequestHash = await sha256Hex(canonicalRequest);
89
120
  const stringToSign = buildStringToSign({
90
121
  algorithm: ALGORITHM,
91
122
  amzDate,
92
123
  credentialScope,
93
- canonicalRequestHash: await sha256Hex(canonicalRequest),
124
+ canonicalRequestHash,
94
125
  });
95
- // 5. Signature
96
126
  const signatureHex = await signStringToSign(stringToSign, signingKey);
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,
127
+ const finalHeaders = new Headers({
128
+ "Content-Type": "application/json",
129
+ "X-Amz-Date": amzDate,
130
+ "X-Amz-X509": normalizedCert,
131
+ "Authorization": `${ALGORITHM} Credential=${certSerialDec}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signatureHex}`,
132
+ });
133
+ const issuedAt = Date.now(); // snapshot before the network round-trip
134
+ return getCachedOrFetch(cacheKey, async () => {
135
+ const res = await fetch(`https://${host}${PATH}`, {
136
+ method: "POST",
137
+ headers: finalHeaders,
138
+ body,
139
+ });
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 } : {})
107
168
  });
108
- if (!res.ok) {
109
- const errorText = await res.text().catch(() => "unknown");
110
- console.error("[aws-rejected]", { status: res.status, body: errorText });
111
- throw new InternalError("aws_rejected");
112
- }
113
- const data = await res.json();
114
- const creds = data?.credentialSet?.[0]?.credentials;
115
- if (!creds?.accessKeyId) {
116
- throw new InternalError("aws_malformed_credentials");
117
- }
118
- const result = {
119
- accessKeyId: creds.accessKeyId,
120
- secretAccessKey: creds.secretAccessKey,
121
- sessionToken: creds.sessionToken,
122
- expiration: creds.expiration,
123
- };
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
126
- const expiresAtMs = Date.parse(creds.expiration);
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
129
- const cacheTtlSec = Math.floor(credLifetimeSec / 3);
130
- const cacheExpiry = new Date(Date.now() + cacheTtlSec * 1000).toISOString();
131
- return wrapResult(result, cacheExpiry);
132
- }
133
- return result;
134
- }
135
- // ── Helpers ────────────────────────────────────────────────────────────────
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
169
  }
170
+ // ---------------------------------------------------------------------------
171
+ // Helpers
172
+ // ---------------------------------------------------------------------------
173
+ /** Strip whitespace and reject PEM-wrapped input. */
140
174
  function normalizeCert(raw) {
141
- if (raw.includes("BEGIN CERTIFICATE"))
175
+ if (raw.includes("BEGIN CERTIFICATE")) {
142
176
  throw new InternalError("pem_not_allowed");
177
+ }
143
178
  return raw.replace(/\s+/g, "");
144
179
  }
145
- function getCertSerialDec(normalizedCert) {
146
- if (cachedCertSerialDec && cachedCertSerialSource === normalizedCert) {
147
- return cachedCertSerialDec;
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
+ function parseCertSerialDec(normalizedCertBase64) {
185
+ try {
186
+ const der = base64ToBytes(normalizedCertBase64);
187
+ let offset = 0;
188
+ function readLen() {
189
+ if (offset >= der.length)
190
+ throw new Error("DER overflow");
191
+ const b = der[offset++];
192
+ if ((b & 0x80) === 0)
193
+ return b;
194
+ const n = b & 0x7f;
195
+ let len = 0;
196
+ if (offset + n > der.length)
197
+ throw new Error("DER overflow");
198
+ for (let i = 0; i < n; i++)
199
+ len = (len << 8) | der[offset++];
200
+ return len;
201
+ }
202
+ if (der[offset++] !== 0x30)
203
+ throw new Error("bad cert");
204
+ readLen();
205
+ if (der[offset++] !== 0x30)
206
+ throw new Error("bad tbs");
207
+ readLen();
208
+ // Skip optional [0] EXPLICIT version field.
209
+ if (der[offset] === 0xa0) {
210
+ offset++;
211
+ const vLen = readLen();
212
+ offset += vLen;
213
+ }
214
+ if (der[offset++] !== 0x02)
215
+ throw new Error("bad serial tag");
216
+ const serialLen = readLen();
217
+ if (offset + serialLen > der.length)
218
+ throw new Error("DER overflow");
219
+ let serial = der.slice(offset, offset + serialLen);
220
+ // Strip ASN.1 sign-extension padding byte.
221
+ if (serial.length > 1 && serial[0] === 0x00) {
222
+ serial = serial.slice(1);
223
+ }
224
+ // Accumulate directly to BigInt — avoids intermediate hex string allocation.
225
+ let serialBig = 0n;
226
+ for (let i = 0; i < serial.length; i++) {
227
+ serialBig = (serialBig << 8n) | BigInt(serial[i]);
228
+ }
229
+ return serialBig.toString();
148
230
  }
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();
231
+ catch (e) {
232
+ console.error("[parseCertSerialDec] failed", e);
233
+ throw new InternalError("invalid_cert_der");
171
234
  }
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;
185
235
  }
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;
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
+ function isoToAmzDate(iso) {
241
+ return (iso.slice(0, 4) +
242
+ iso.slice(5, 7) +
243
+ iso.slice(8, 10) +
244
+ "T" +
245
+ iso.slice(11, 13) +
246
+ iso.slice(14, 16) +
247
+ iso.slice(17, 19) +
248
+ "Z");
205
249
  }
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);
250
+ /** Faster base64 → Uint8Array using V8's vectorized atob path. */
251
+ function base64ToBytes(base64) {
252
+ const binary = atob(base64);
253
+ const bytes = new Uint8Array(binary.length);
254
+ for (let i = 0; i < binary.length; i++) {
255
+ bytes[i] = binary.charCodeAt(i);
211
256
  }
212
257
  return bytes;
213
258
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizamodo/aws-sts-core",
3
- "version": "0.4.6",
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",