@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.
- package/dist/sts/issue.d.ts +2 -3
- package/dist/sts/issue.js +195 -150
- 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 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
|
-
|
|
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
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
-
//
|
|
29
|
+
// ---- certificate serial cache (DER walk is CPU-bound, cert rarely rotates) ----
|
|
30
30
|
let cachedCertSerialDec = null;
|
|
31
31
|
let cachedCertSerialSource = null;
|
|
32
|
-
//
|
|
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 =
|
|
79
|
+
const sessionTtl = resolveSessionTtlByProfile(profile);
|
|
36
80
|
const normalizedCert = normalizeCert(certBase64);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
62
|
-
const amzDate =
|
|
63
|
-
const dateStamp =
|
|
64
|
-
const
|
|
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
|
|
124
|
+
canonicalRequestHash,
|
|
94
125
|
});
|
|
95
|
-
// 5. Signature
|
|
96
126
|
const signatureHex = await signStringToSign(stringToSign, signingKey);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
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
|
}
|