@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.
- package/dist/sts/issue.d.ts +8 -2
- package/dist/sts/issue.js +108 -131
- package/package.json +1 -1
package/dist/sts/issue.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AwsCredentialResult } from "../types";
|
|
2
|
-
export
|
|
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
|
-
}
|
|
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
|
-
//
|
|
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
|
|
22
|
-
const MAX_PROFILE_TTL = 12 * 60 * 60; // 12 h
|
|
23
|
-
//
|
|
24
|
-
// Single Promise
|
|
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
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 ===
|
|
39
|
-
cachedPrivateKeyBase64 ===
|
|
40
|
-
return
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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 =
|
|
79
|
+
const sessionTtl = resolveSessionTtl(profile);
|
|
80
80
|
const normalizedCert = normalizeCert(certBase64);
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|