@intentius/chant-lexicon-aws 0.44.9 → 0.44.12
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/agentcore/trace-fetch.d.ts +332 -0
- package/dist/agentcore/trace-fetch.d.ts.map +1 -0
- package/dist/agentcore/trace-render.d.ts +256 -0
- package/dist/agentcore/trace-render.d.ts.map +1 -0
- package/dist/api/read-client.d.ts +43 -5
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/sigv4.d.ts +82 -0
- package/dist/api/sigv4.d.ts.map +1 -0
- package/dist/identity-observe.d.ts +23 -0
- package/dist/identity-observe.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +10 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/agentcore/trace-fetch.test.ts +588 -0
- package/src/agentcore/trace-fetch.ts +754 -0
- package/src/agentcore/trace-render.test.ts +366 -0
- package/src/agentcore/trace-render.ts +589 -0
- package/src/api/read-client.test.ts +87 -4
- package/src/api/read-client.ts +95 -21
- package/src/api/sigv4.test.ts +260 -0
- package/src/api/sigv4.ts +243 -0
- package/src/identity-observe.test.ts +146 -0
- package/src/identity-observe.ts +137 -0
- package/src/index.ts +13 -0
- package/src/lifecycle-integration.test.ts +69 -0
- package/src/op/activities/index.ts +15 -0
- package/src/plugin.ts +19 -5
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
xmlError,
|
|
20
20
|
type AwsReadHttp,
|
|
21
21
|
} from "./read-client";
|
|
22
|
+
import { EMPTY_PAYLOAD_SHA256 } from "./sigv4";
|
|
22
23
|
|
|
23
24
|
const respond = (text: string, status = 200): ReturnType<AwsReadHttp> => Promise.resolve({ status, text });
|
|
24
25
|
|
|
@@ -210,25 +211,107 @@ describe("the region an endpoint override cannot carry in its host", () => {
|
|
|
210
211
|
// snapshot recorded that region as holding nothing.
|
|
211
212
|
test("the request names the region in its credential scope", async () => {
|
|
212
213
|
const { http, calls } = recording(() => respond(stackXml));
|
|
213
|
-
await describeStackResources("web", { endpoint: "http://localhost:4566", region: "us-west-1", http });
|
|
214
|
+
await describeStackResources("web", { endpoint: "http://localhost:4566", region: "us-west-1", http, env: {} });
|
|
214
215
|
expect(calls[0].headers.authorization).toContain("/us-west-1/cloudformation/aws4_request");
|
|
215
216
|
});
|
|
216
217
|
|
|
217
218
|
test("Cloud Control carries it too, scoped to its own service", async () => {
|
|
218
219
|
const { http, calls } = recording(() => respond(JSON.stringify({ ResourceDescription: {} })));
|
|
219
|
-
await getResource("AWS::EC2::VPC", "vpc-01", { endpoint: "http://localhost:4566", region: "eu-west-1", http });
|
|
220
|
+
await getResource("AWS::EC2::VPC", "vpc-01", { endpoint: "http://localhost:4566", region: "eu-west-1", http, env: {} });
|
|
220
221
|
expect(calls[0].headers.authorization).toContain("/eu-west-1/cloudcontrolapi/aws4_request");
|
|
221
222
|
});
|
|
222
223
|
|
|
223
224
|
test("no region, no header — nothing invents one", async () => {
|
|
224
225
|
const { http, calls } = recording(() => respond(stackXml));
|
|
225
|
-
await describeStackResources("web", { endpoint: "http://localhost:4566", http });
|
|
226
|
+
await describeStackResources("web", { endpoint: "http://localhost:4566", http, env: {} });
|
|
226
227
|
expect(calls[0].headers.authorization).toBeUndefined();
|
|
227
228
|
});
|
|
228
229
|
|
|
229
230
|
test("it is a scope, not a signature — the placeholder says so", async () => {
|
|
230
231
|
const { http, calls } = recording(() => respond(stackXml));
|
|
231
|
-
await describeStackResources("web", { region: "us-west-1", http });
|
|
232
|
+
await describeStackResources("web", { region: "us-west-1", http, env: {} });
|
|
232
233
|
expect(calls[0].headers.authorization).toContain("Signature=unsigned");
|
|
233
234
|
});
|
|
234
235
|
});
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The signing decision, as the client makes it. `./sigv4.test.ts` proves the
|
|
239
|
+
* signature is correct against AWS's own vectors; these prove the client only
|
|
240
|
+
* produces one when it should.
|
|
241
|
+
*/
|
|
242
|
+
describe("SigV4 on the read path", () => {
|
|
243
|
+
const credentials = { accessKeyId: "AKIDEXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" };
|
|
244
|
+
const now = new Date("2015-08-30T12:36:00Z");
|
|
245
|
+
|
|
246
|
+
test("credentials and a real host produce a signed request, not the placeholder", async () => {
|
|
247
|
+
const { http, calls } = recording(() => respond(stackXml));
|
|
248
|
+
await describeStackResources("web", { region: "us-west-1", credentials, now, http, env: {} });
|
|
249
|
+
|
|
250
|
+
const auth = calls[0].headers.authorization;
|
|
251
|
+
expect(auth).toContain("Credential=AKIDEXAMPLE/20150830/us-west-1/cloudformation/aws4_request");
|
|
252
|
+
expect(auth).not.toContain("Signature=unsigned");
|
|
253
|
+
expect(auth).toMatch(/Signature=[0-9a-f]{64}$/);
|
|
254
|
+
expect(calls[0].headers["x-amz-date"]).toBe("20150830T123600Z");
|
|
255
|
+
// The body hash, not the empty-payload constant — the Query call has a body.
|
|
256
|
+
expect(calls[0].headers["x-amz-content-sha256"]).toMatch(/^[0-9a-f]{64}$/);
|
|
257
|
+
expect(calls[0].headers["x-amz-content-sha256"]).not.toBe(EMPTY_PAYLOAD_SHA256);
|
|
258
|
+
// `host` is signed but never emitted: fetch computes it and forbids the override.
|
|
259
|
+
expect(calls[0].headers.host).toBeUndefined();
|
|
260
|
+
expect(auth).toContain("SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("an endpoint override is not signed, so the emulator lanes need no credentials", async () => {
|
|
264
|
+
const { http, calls } = recording(() => respond(stackXml));
|
|
265
|
+
await describeStackResources("web", {
|
|
266
|
+
endpoint: "http://localhost:4566",
|
|
267
|
+
region: "us-west-1",
|
|
268
|
+
credentials,
|
|
269
|
+
http,
|
|
270
|
+
env: {},
|
|
271
|
+
});
|
|
272
|
+
expect(calls[0].headers.authorization).toContain("Signature=unsigned");
|
|
273
|
+
expect(calls[0].headers["x-amz-date"]).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("signEndpointOverride signs one anyway — for an override that is real AWS", async () => {
|
|
277
|
+
const { http, calls } = recording(() => respond(stackXml));
|
|
278
|
+
await describeStackResources("web", {
|
|
279
|
+
endpoint: "https://vpce-1234.cloudformation.us-west-1.vpce.amazonaws.com",
|
|
280
|
+
region: "us-west-1",
|
|
281
|
+
credentials,
|
|
282
|
+
signEndpointOverride: true,
|
|
283
|
+
now,
|
|
284
|
+
http,
|
|
285
|
+
env: {},
|
|
286
|
+
});
|
|
287
|
+
expect(calls[0].headers.authorization).toMatch(/Signature=[0-9a-f]{64}$/);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("no credentials leaves the old unsigned path exactly as it was", async () => {
|
|
291
|
+
const { http, calls } = recording(() => respond(stackXml));
|
|
292
|
+
await describeStackResources("web", { region: "us-west-1", http, env: {} });
|
|
293
|
+
expect(calls[0].headers.authorization).toContain("Signature=unsigned");
|
|
294
|
+
expect(calls[0].headers["x-amz-date"]).toBeUndefined();
|
|
295
|
+
expect(calls[0].headers["x-amz-content-sha256"]).toBeUndefined();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("the environment is the fallback source, and Cloud Control signs from it too", async () => {
|
|
299
|
+
const { http, calls } = recording(() => respond(JSON.stringify({ ResourceDescription: {} })));
|
|
300
|
+
await getResource("AWS::EC2::VPC", "vpc-01", {
|
|
301
|
+
region: "eu-west-1",
|
|
302
|
+
now,
|
|
303
|
+
http,
|
|
304
|
+
env: { AWS_ACCESS_KEY_ID: "AKIDENV", AWS_SECRET_ACCESS_KEY: "s", AWS_SESSION_TOKEN: "tok" },
|
|
305
|
+
});
|
|
306
|
+
expect(calls[0].headers.authorization).toContain("Credential=AKIDENV/20150830/eu-west-1/cloudcontrolapi/");
|
|
307
|
+
expect(calls[0].headers["x-amz-security-token"]).toBe("tok");
|
|
308
|
+
expect(calls[0].headers["x-amz-target"]).toBe("CloudApiService.GetResource");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("signing without a named region falls back to the same default the host does", async () => {
|
|
312
|
+
const { http, calls } = recording(() => respond(stackXml));
|
|
313
|
+
await describeStackResources("web", { credentials, now, http, env: {} });
|
|
314
|
+
expect(calls[0].url).toBe("https://cloudformation.us-east-1.amazonaws.com/");
|
|
315
|
+
expect(calls[0].headers.authorization).toContain("/20150830/us-east-1/cloudformation/aws4_request");
|
|
316
|
+
});
|
|
317
|
+
});
|
package/src/api/read-client.ts
CHANGED
|
@@ -19,12 +19,23 @@
|
|
|
19
19
|
*
|
|
20
20
|
* ## Signing
|
|
21
21
|
*
|
|
22
|
-
* Requests are
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* Requests are signed with SigV4 (#1686) when credentials resolve — see
|
|
23
|
+
* `./sigv4.ts`, which holds the implementation so that cedar's AVP client can
|
|
24
|
+
* adopt the same one rather than growing a second. Two cases stay unsigned, and
|
|
25
|
+
* both are deliberate:
|
|
26
|
+
*
|
|
27
|
+
* - **No credentials.** The caller's existing no-credentials path is
|
|
28
|
+
* untouched: the request still goes out carrying only the credential scope
|
|
29
|
+
* of {@link regionScope}, which is what the emulator lanes have always
|
|
30
|
+
* sent.
|
|
31
|
+
* - **An endpoint override.** Floci does not verify signatures, and signing
|
|
32
|
+
* against it would mean every local lane suddenly needs credentials to read
|
|
33
|
+
* what it just deployed. `signEndpointOverride` opts back in for an
|
|
34
|
+
* override that *is* real AWS — a VPC endpoint, a signing proxy.
|
|
27
35
|
*/
|
|
36
|
+
import { resolveCredentials, signRequest, type AwsCredentialSource } from "./sigv4";
|
|
37
|
+
|
|
38
|
+
export type { AwsCredentials, AwsCredentialResolver, AwsCredentialSource } from "./sigv4";
|
|
28
39
|
|
|
29
40
|
const DEFAULT_REGION = "us-east-1";
|
|
30
41
|
const CFN_API_VERSION = "2010-05-15";
|
|
@@ -62,6 +73,18 @@ export interface AwsReadClientOptions {
|
|
|
62
73
|
region?: string;
|
|
63
74
|
http?: AwsReadHttp;
|
|
64
75
|
signal?: AbortSignal;
|
|
76
|
+
/**
|
|
77
|
+
* What to sign with: literal credentials, or a resolver that decides. Omitted,
|
|
78
|
+
* the environment answers; when it has nothing, the request goes out unsigned
|
|
79
|
+
* exactly as it did before signing existed.
|
|
80
|
+
*/
|
|
81
|
+
credentials?: AwsCredentialSource;
|
|
82
|
+
/** Environment the credential fallback reads. Defaults to `process.env`; injectable for tests. */
|
|
83
|
+
env?: Record<string, string | undefined>;
|
|
84
|
+
/** Sign even against an endpoint override — for an override that is real AWS. */
|
|
85
|
+
signEndpointOverride?: boolean;
|
|
86
|
+
/** Signing clock. Injected by tests so a signature is reproducible. */
|
|
87
|
+
now?: Date;
|
|
65
88
|
}
|
|
66
89
|
|
|
67
90
|
/** Service host for `service`, honouring an endpoint override. */
|
|
@@ -84,15 +107,19 @@ export function serviceUrl(service: string, endpoint?: string, region = DEFAULT_
|
|
|
84
107
|
* an empty observation and the snapshot recorded the region as holding nothing.
|
|
85
108
|
* A three-region estate snapshotted as one region and nothing said so.
|
|
86
109
|
*
|
|
87
|
-
* This is NOT SigV4. The signature is a placeholder and real AWS rejects it
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
110
|
+
* This is NOT SigV4. The signature is a placeholder and real AWS rejects it. It
|
|
111
|
+
* is what an unsigned request still has to carry so an emulator learns the
|
|
112
|
+
* region, and it is only ever sent when no credentials resolved — the moment
|
|
113
|
+
* they do, {@link requestHeaders} sends a real signature instead.
|
|
91
114
|
*/
|
|
92
|
-
function regionScope(
|
|
115
|
+
function regionScope(
|
|
116
|
+
service: string,
|
|
117
|
+
region: string | undefined,
|
|
118
|
+
env: Record<string, string | undefined>,
|
|
119
|
+
): Record<string, string> {
|
|
93
120
|
if (!region) return {};
|
|
94
121
|
const day = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
95
|
-
const key =
|
|
122
|
+
const key = env.AWS_ACCESS_KEY_ID || "chant";
|
|
96
123
|
return {
|
|
97
124
|
authorization:
|
|
98
125
|
`AWS4-HMAC-SHA256 Credential=${key}/${day}/${region}/${service}/aws4_request, ` +
|
|
@@ -100,6 +127,44 @@ function regionScope(service: string, region?: string): Record<string, string> {
|
|
|
100
127
|
};
|
|
101
128
|
}
|
|
102
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The headers one request goes out with — signed when there is something to
|
|
132
|
+
* sign with and the target is real AWS, scope-only otherwise.
|
|
133
|
+
*
|
|
134
|
+
* Signing needs a region even when the caller named none, because the scope
|
|
135
|
+
* string has a slot for one; it borrows the same `us-east-1` default that
|
|
136
|
+
* {@link serviceUrl} already used to build the host, so the signature agrees
|
|
137
|
+
* with the endpoint it is sent to.
|
|
138
|
+
*
|
|
139
|
+
* Exported because this decision — sign, or carry the scope and no signature —
|
|
140
|
+
* belongs to the lexicon's read transport rather than to any one API on it.
|
|
141
|
+
* `agentcore/trace-fetch.ts` reads `bedrock-agentcore` through the same seam,
|
|
142
|
+
* and a second copy of this would be a second place for the emulator carve-out
|
|
143
|
+
* to drift.
|
|
144
|
+
*/
|
|
145
|
+
export function requestHeaders(
|
|
146
|
+
service: string,
|
|
147
|
+
url: string,
|
|
148
|
+
body: string,
|
|
149
|
+
base: Record<string, string>,
|
|
150
|
+
options: AwsReadClientOptions,
|
|
151
|
+
): Record<string, string> {
|
|
152
|
+
const env = options.env ?? process.env;
|
|
153
|
+
const credentials = resolveCredentials(options.credentials, env);
|
|
154
|
+
const signable = credentials && (!options.endpoint || options.signEndpointOverride === true);
|
|
155
|
+
if (!signable) return { ...base, ...regionScope(service, options.region, env) };
|
|
156
|
+
return signRequest({
|
|
157
|
+
method: "POST",
|
|
158
|
+
url,
|
|
159
|
+
headers: base,
|
|
160
|
+
body,
|
|
161
|
+
service,
|
|
162
|
+
region: options.region ?? DEFAULT_REGION,
|
|
163
|
+
credentials,
|
|
164
|
+
...(options.now ? { now: options.now } : {}),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
103
168
|
/* ── CloudFormation Query ─────────────────────────────────────────────────── */
|
|
104
169
|
|
|
105
170
|
/**
|
|
@@ -162,10 +227,13 @@ export async function cfnQuery(
|
|
|
162
227
|
const res = await http(
|
|
163
228
|
url,
|
|
164
229
|
{
|
|
165
|
-
headers:
|
|
166
|
-
"
|
|
167
|
-
|
|
168
|
-
|
|
230
|
+
headers: requestHeaders(
|
|
231
|
+
"cloudformation",
|
|
232
|
+
url,
|
|
233
|
+
body,
|
|
234
|
+
{ "content-type": "application/x-www-form-urlencoded" },
|
|
235
|
+
options,
|
|
236
|
+
),
|
|
169
237
|
body,
|
|
170
238
|
},
|
|
171
239
|
options.signal,
|
|
@@ -238,15 +306,21 @@ async function cloudControl(
|
|
|
238
306
|
): Promise<Record<string, unknown>> {
|
|
239
307
|
const http = options.http ?? defaultHttp;
|
|
240
308
|
const url = serviceUrl("cloudcontrolapi", options.endpoint, options.region);
|
|
309
|
+
const payloadJson = JSON.stringify(payload);
|
|
241
310
|
const res = await http(
|
|
242
311
|
url,
|
|
243
312
|
{
|
|
244
|
-
headers:
|
|
245
|
-
"
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
313
|
+
headers: requestHeaders(
|
|
314
|
+
"cloudcontrolapi",
|
|
315
|
+
url,
|
|
316
|
+
payloadJson,
|
|
317
|
+
{
|
|
318
|
+
"content-type": "application/x-amz-json-1.0",
|
|
319
|
+
"x-amz-target": `${CLOUD_CONTROL_TARGET_PREFIX}.${operation}`,
|
|
320
|
+
},
|
|
321
|
+
options,
|
|
322
|
+
),
|
|
323
|
+
body: payloadJson,
|
|
250
324
|
},
|
|
251
325
|
options.signal,
|
|
252
326
|
);
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SigV4 (#1686), checked against AWS's own published vectors.
|
|
3
|
+
*
|
|
4
|
+
* A signing implementation that is only tested against itself proves nothing —
|
|
5
|
+
* it will happily agree with its own mistake. Three of the fixtures below are
|
|
6
|
+
* values AWS publishes: the derived signing key from the documentation's
|
|
7
|
+
* "deriving the signing key" example, and the canonical request hash and
|
|
8
|
+
* signature of `get-vanilla` from the `aws-sig-v4-test-suite`. If a refactor
|
|
9
|
+
* breaks canonicalization, those three stop matching.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here touches the network; there is nothing to touch. Signing is a
|
|
12
|
+
* pure function of the request and the clock.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, test, expect } from "vitest";
|
|
15
|
+
import { createHmac } from "node:crypto";
|
|
16
|
+
import {
|
|
17
|
+
EMPTY_PAYLOAD_SHA256,
|
|
18
|
+
amzDate,
|
|
19
|
+
canonicalHeaders,
|
|
20
|
+
canonicalRequest,
|
|
21
|
+
resolveCredentials,
|
|
22
|
+
sha256Hex,
|
|
23
|
+
signRequest,
|
|
24
|
+
signingKey,
|
|
25
|
+
stringToSign,
|
|
26
|
+
} from "./sigv4";
|
|
27
|
+
|
|
28
|
+
/** The example secret AWS uses throughout its Signature Version 4 documentation. */
|
|
29
|
+
const SECRET = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
|
|
30
|
+
const ACCESS_KEY = "AKIDEXAMPLE";
|
|
31
|
+
|
|
32
|
+
describe("the published vectors", () => {
|
|
33
|
+
test("the signing key matches the documented derivation", () => {
|
|
34
|
+
// AWS, "Examples of how to derive a signing key for Signature Version 4":
|
|
35
|
+
// 20120215 / us-east-1 / iam.
|
|
36
|
+
expect(signingKey(SECRET, "20120215", "us-east-1", "iam").toString("hex")).toBe(
|
|
37
|
+
"f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("get-vanilla canonicalizes and signs exactly as the test suite says", () => {
|
|
42
|
+
const { canonical, signed } = canonicalRequest(
|
|
43
|
+
"GET",
|
|
44
|
+
new URL("https://example.amazonaws.com/"),
|
|
45
|
+
{ host: "example.amazonaws.com", "x-amz-date": "20150830T123600Z" },
|
|
46
|
+
EMPTY_PAYLOAD_SHA256,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(canonical).toBe(
|
|
50
|
+
[
|
|
51
|
+
"GET",
|
|
52
|
+
"/",
|
|
53
|
+
"",
|
|
54
|
+
"host:example.amazonaws.com",
|
|
55
|
+
"x-amz-date:20150830T123600Z",
|
|
56
|
+
"",
|
|
57
|
+
"host;x-amz-date",
|
|
58
|
+
EMPTY_PAYLOAD_SHA256,
|
|
59
|
+
].join("\n"),
|
|
60
|
+
);
|
|
61
|
+
expect(signed).toBe("host;x-amz-date");
|
|
62
|
+
|
|
63
|
+
const scope = "20150830/us-east-1/service/aws4_request";
|
|
64
|
+
const sts = stringToSign("20150830T123600Z", scope, canonical);
|
|
65
|
+
expect(sts).toBe(
|
|
66
|
+
"AWS4-HMAC-SHA256\n20150830T123600Z\n" +
|
|
67
|
+
`${scope}\nbb579772317eb040ac9ed261061d46c1f17a8133879d6129b6e1c25292927e63`,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const key = signingKey(SECRET, "20150830", "us-east-1", "service");
|
|
71
|
+
expect(createHmac("sha256", key).update(sts, "utf8").digest("hex")).toBe(
|
|
72
|
+
"5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the empty-payload constant is what SHA-256 of nothing actually is", () => {
|
|
77
|
+
expect(sha256Hex("")).toBe(EMPTY_PAYLOAD_SHA256);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("header canonicalization", () => {
|
|
82
|
+
test("names lowercase and sort, values trim and collapse inner runs", () => {
|
|
83
|
+
expect(
|
|
84
|
+
canonicalHeaders({
|
|
85
|
+
"X-Amz-Date": "20150830T123600Z",
|
|
86
|
+
"Content-Type": " application/json ",
|
|
87
|
+
Host: "example.amazonaws.com",
|
|
88
|
+
"My-Header": "a b\tc",
|
|
89
|
+
}),
|
|
90
|
+
).toEqual({
|
|
91
|
+
canonical:
|
|
92
|
+
"content-type:application/json\nhost:example.amazonaws.com\n" +
|
|
93
|
+
"my-header:a b c\nx-amz-date:20150830T123600Z\n",
|
|
94
|
+
signed: "content-type;host;my-header;x-amz-date",
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("sorting is by byte, so an uppercase name still sorts by its lowercase form", () => {
|
|
99
|
+
expect(canonicalHeaders({ Zeta: "1", alpha: "2", MIDDLE: "3" }).signed).toBe("alpha;middle;zeta");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("the unsignable headers are left out — signing what a proxy rewrites is an intermittent 403", () => {
|
|
103
|
+
const { signed } = canonicalHeaders({
|
|
104
|
+
authorization: "stale",
|
|
105
|
+
"user-agent": "chant",
|
|
106
|
+
"x-amzn-trace-id": "Root=1-x",
|
|
107
|
+
connection: "keep-alive",
|
|
108
|
+
expect: "100-continue",
|
|
109
|
+
host: "example.amazonaws.com",
|
|
110
|
+
});
|
|
111
|
+
expect(signed).toBe("host");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a bodyless request hashes to the empty-payload constant, not to a hash of nothing-in-particular", () => {
|
|
115
|
+
const headers = signRequest({
|
|
116
|
+
method: "POST",
|
|
117
|
+
url: "https://cloudformation.us-east-1.amazonaws.com/",
|
|
118
|
+
headers: {},
|
|
119
|
+
body: "",
|
|
120
|
+
service: "cloudformation",
|
|
121
|
+
region: "us-east-1",
|
|
122
|
+
credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET },
|
|
123
|
+
now: new Date("2015-08-30T12:36:00Z"),
|
|
124
|
+
});
|
|
125
|
+
expect(headers["x-amz-content-sha256"]).toBe(EMPTY_PAYLOAD_SHA256);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("the query string sorts by name then value, RFC 3986 encoded", () => {
|
|
129
|
+
const { canonical } = canonicalRequest(
|
|
130
|
+
"GET",
|
|
131
|
+
new URL("https://example.amazonaws.com/?b=2&a=z&a=a&c=hi%20there*"),
|
|
132
|
+
{ host: "example.amazonaws.com" },
|
|
133
|
+
EMPTY_PAYLOAD_SHA256,
|
|
134
|
+
);
|
|
135
|
+
expect(canonical.split("\n")[2]).toBe("a=a&a=z&b=2&c=hi%20there%2A");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("amzDate is ISO-8601 basic, which is what X-Amz-Date wants", () => {
|
|
139
|
+
expect(amzDate(new Date("2015-08-30T12:36:00.123Z"))).toBe("20150830T123600Z");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("signRequest", () => {
|
|
144
|
+
const credentials = { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET };
|
|
145
|
+
const now = new Date("2015-08-30T12:36:00Z");
|
|
146
|
+
|
|
147
|
+
test("adds its own headers, keeps the caller's, and never emits host", () => {
|
|
148
|
+
const headers = signRequest({
|
|
149
|
+
method: "POST",
|
|
150
|
+
url: "https://cloudformation.us-west-2.amazonaws.com/",
|
|
151
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
152
|
+
body: "Action=DescribeStacks",
|
|
153
|
+
service: "cloudformation",
|
|
154
|
+
region: "us-west-2",
|
|
155
|
+
credentials,
|
|
156
|
+
now,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(headers["content-type"]).toBe("application/x-www-form-urlencoded");
|
|
160
|
+
expect(headers["x-amz-date"]).toBe("20150830T123600Z");
|
|
161
|
+
expect(headers["x-amz-content-sha256"]).toBe(sha256Hex("Action=DescribeStacks"));
|
|
162
|
+
expect(headers.host).toBeUndefined();
|
|
163
|
+
// A fixed clock makes the whole header a fixture; the primitives it is
|
|
164
|
+
// composed from are the ones checked against AWS's vectors above.
|
|
165
|
+
expect(headers.authorization).toBe(
|
|
166
|
+
"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-west-2/cloudformation/aws4_request, " +
|
|
167
|
+
"SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date, " +
|
|
168
|
+
"Signature=3160e6fcf0cce7e8ff03c128e5e2bb6572ca59e843d3a88c26e45203288fc462",
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("host is signed even though it is not emitted — a different host is a different signature", () => {
|
|
173
|
+
const request = {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: {},
|
|
176
|
+
body: "",
|
|
177
|
+
service: "cloudformation",
|
|
178
|
+
region: "us-east-1",
|
|
179
|
+
credentials,
|
|
180
|
+
now,
|
|
181
|
+
} as const;
|
|
182
|
+
const one = signRequest({ ...request, url: "https://cloudformation.us-east-1.amazonaws.com/" });
|
|
183
|
+
const other = signRequest({ ...request, url: "https://cloudformation.us-east-2.amazonaws.com/" });
|
|
184
|
+
expect(one.authorization).not.toBe(other.authorization);
|
|
185
|
+
expect(one.authorization).toContain("SignedHeaders=host;x-amz-content-sha256;x-amz-date");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("a session token is both sent and signed", () => {
|
|
189
|
+
const headers = signRequest({
|
|
190
|
+
method: "POST",
|
|
191
|
+
url: "https://cloudcontrolapi.us-east-1.amazonaws.com/",
|
|
192
|
+
headers: {},
|
|
193
|
+
body: "{}",
|
|
194
|
+
service: "cloudcontrolapi",
|
|
195
|
+
region: "us-east-1",
|
|
196
|
+
credentials: { ...credentials, sessionToken: "FwoGZXIvYXdzEXAMPLE" },
|
|
197
|
+
now,
|
|
198
|
+
});
|
|
199
|
+
expect(headers["x-amz-security-token"]).toBe("FwoGZXIvYXdzEXAMPLE");
|
|
200
|
+
expect(headers.authorization).toContain("x-amz-security-token");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("the same request at a different second is a different signature", () => {
|
|
204
|
+
const request = {
|
|
205
|
+
method: "POST",
|
|
206
|
+
url: "https://cloudformation.us-east-1.amazonaws.com/",
|
|
207
|
+
headers: {},
|
|
208
|
+
body: "",
|
|
209
|
+
service: "cloudformation",
|
|
210
|
+
region: "us-east-1",
|
|
211
|
+
credentials,
|
|
212
|
+
} as const;
|
|
213
|
+
expect(signRequest({ ...request, now }).authorization).not.toBe(
|
|
214
|
+
signRequest({ ...request, now: new Date("2015-08-30T12:36:01Z") }).authorization,
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("credential resolution", () => {
|
|
220
|
+
const env = { AWS_ACCESS_KEY_ID: "AKIDENV", AWS_SECRET_ACCESS_KEY: "envsecret", AWS_SESSION_TOKEN: "envtoken" };
|
|
221
|
+
|
|
222
|
+
test("explicit credentials beat the environment", () => {
|
|
223
|
+
expect(resolveCredentials({ accessKeyId: "AKIDEXPLICIT", secretAccessKey: "s" }, env)).toEqual({
|
|
224
|
+
accessKeyId: "AKIDEXPLICIT",
|
|
225
|
+
secretAccessKey: "s",
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("the environment answers when nothing was passed, session token included", () => {
|
|
230
|
+
expect(resolveCredentials(undefined, env)).toEqual({
|
|
231
|
+
accessKeyId: "AKIDENV",
|
|
232
|
+
secretAccessKey: "envsecret",
|
|
233
|
+
sessionToken: "envtoken",
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("no session token in the environment means no session token in the result", () => {
|
|
238
|
+
expect(resolveCredentials(undefined, { AWS_ACCESS_KEY_ID: "a", AWS_SECRET_ACCESS_KEY: "b" })).toEqual({
|
|
239
|
+
accessKeyId: "a",
|
|
240
|
+
secretAccessKey: "b",
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("a half-set environment is absent, not a signature that cannot verify", () => {
|
|
245
|
+
expect(resolveCredentials(undefined, { AWS_ACCESS_KEY_ID: "a" })).toBeUndefined();
|
|
246
|
+
expect(resolveCredentials(undefined, { AWS_SECRET_ACCESS_KEY: "b" })).toBeUndefined();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("an empty environment is absent", () => {
|
|
250
|
+
expect(resolveCredentials(undefined, {})).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("a resolver decides, and its refusal is not overruled by the environment", () => {
|
|
254
|
+
expect(resolveCredentials(() => ({ accessKeyId: "AKIDRESOLVED", secretAccessKey: "s" }), env)).toEqual({
|
|
255
|
+
accessKeyId: "AKIDRESOLVED",
|
|
256
|
+
secretAccessKey: "s",
|
|
257
|
+
});
|
|
258
|
+
expect(resolveCredentials(() => undefined, env)).toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
});
|