@intentius/chant-lexicon-aws 0.44.8 → 0.44.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/api/read-client.d.ts +27 -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/plugin.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/api/read-client.test.ts +87 -4
- package/src/api/read-client.ts +89 -21
- package/src/api/sigv4.test.ts +260 -0
- package/src/api/sigv4.ts +243 -0
- package/src/identity-observe.test.ts +104 -0
- package/src/identity-observe.ts +114 -0
- package/src/index.ts +13 -0
- package/src/lifecycle-integration.test.ts +69 -0
- package/src/plugin.ts +19 -5
|
@@ -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
|
+
});
|
package/src/api/sigv4.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS Signature Version 4, for the shared read transport (#1686).
|
|
3
|
+
*
|
|
4
|
+
* Two modules asked for this by name before it existed: `./read-client.ts` and
|
|
5
|
+
* cedar's `lexicons/cedar/src/avp/client.ts` both send a placeholder
|
|
6
|
+
* `Signature=unsigned` `Authorization` header that carries a credential scope
|
|
7
|
+
* and nothing else, and both say in prose that the real thing belongs here
|
|
8
|
+
* once rather than twice. Nothing else in the repo signs an AWS request — the
|
|
9
|
+
* appliers (`op/activities/aws-apply.ts`) are unsigned too, which is why the
|
|
10
|
+
* native paths have been emulator-scoped.
|
|
11
|
+
*
|
|
12
|
+
* ## No dependency
|
|
13
|
+
*
|
|
14
|
+
* SigV4 is four HMACs and two SHA-256s. `node:crypto` has both, so signing
|
|
15
|
+
* costs nothing in the dependency tree — which matters here, because the whole
|
|
16
|
+
* reason these transports are hand-written is to keep `@aws-sdk/*` and its
|
|
17
|
+
* thirty-odd transitive packages out of a lexicon.
|
|
18
|
+
*
|
|
19
|
+
* ## What is signed, and what is not
|
|
20
|
+
*
|
|
21
|
+
* Header signing only. Query-string (presigned-URL) signing has no caller: the
|
|
22
|
+
* read paths POST to a service endpoint, they do not hand out URLs.
|
|
23
|
+
*
|
|
24
|
+
* `host` is signed but never emitted. `fetch` computes `Host` from the URL and
|
|
25
|
+
* forbids overriding it, so the signer derives the same value from the URL it
|
|
26
|
+
* was given. A transport that rewrites the URL underneath the signer — a proxy
|
|
27
|
+
* that keeps the original `Host`, say — would invalidate the signature; the
|
|
28
|
+
* injectable-HTTP seam is for tests and emulators, not for URL rewriting.
|
|
29
|
+
*/
|
|
30
|
+
import { createHash, createHmac } from "node:crypto";
|
|
31
|
+
|
|
32
|
+
const ALGORITHM = "AWS4-HMAC-SHA256";
|
|
33
|
+
const TERMINATOR = "aws4_request";
|
|
34
|
+
|
|
35
|
+
/** SHA-256 of the empty string — the payload hash of every bodyless request. */
|
|
36
|
+
export const EMPTY_PAYLOAD_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Headers that must not be signed even when a caller sets them: `authorization`
|
|
40
|
+
* is the output, and the others are rewritten in transit by proxies and agents,
|
|
41
|
+
* so signing them produces intermittent 403s rather than security.
|
|
42
|
+
*/
|
|
43
|
+
const UNSIGNABLE = new Set(["authorization", "connection", "expect", "user-agent", "x-amzn-trace-id"]);
|
|
44
|
+
|
|
45
|
+
/** A resolved credential set. `sessionToken` is present for STS/role credentials. */
|
|
46
|
+
export interface AwsCredentials {
|
|
47
|
+
accessKeyId: string;
|
|
48
|
+
secretAccessKey: string;
|
|
49
|
+
sessionToken?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The credential injection seam: a function the caller supplies to decide, by
|
|
54
|
+
* whatever means it likes, what to sign with. Returning `undefined` means "this
|
|
55
|
+
* process has no credentials" and is respected as an answer, not overridden.
|
|
56
|
+
*/
|
|
57
|
+
export type AwsCredentialResolver = () => AwsCredentials | undefined;
|
|
58
|
+
|
|
59
|
+
/** Either literal credentials or a resolver for them. */
|
|
60
|
+
export type AwsCredentialSource = AwsCredentials | AwsCredentialResolver;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Credentials for a request: explicit → environment → absent.
|
|
64
|
+
*
|
|
65
|
+
* A resolver function is authoritative — if one is injected and it declines,
|
|
66
|
+
* the environment is not consulted behind its back, because the point of
|
|
67
|
+
* injecting one is to control the answer. Literal credentials are likewise
|
|
68
|
+
* final. Only the no-source case falls through to `AWS_ACCESS_KEY_ID` /
|
|
69
|
+
* `AWS_SECRET_ACCESS_KEY` (plus `AWS_SESSION_TOKEN` when set), and a half-set
|
|
70
|
+
* environment — a key id with no secret — is absent rather than a signature
|
|
71
|
+
* that cannot verify.
|
|
72
|
+
*
|
|
73
|
+
* Deliberately not implemented: the profile file, IMDS, and the container
|
|
74
|
+
* credential endpoints. Each is a separate transport with its own failure and
|
|
75
|
+
* caching story; a caller that has those can resolve them itself and pass the
|
|
76
|
+
* result in, which is what the resolver seam is for.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveCredentials(
|
|
79
|
+
source?: AwsCredentialSource,
|
|
80
|
+
env: Record<string, string | undefined> = process.env,
|
|
81
|
+
): AwsCredentials | undefined {
|
|
82
|
+
if (typeof source === "function") return source();
|
|
83
|
+
if (source) return source;
|
|
84
|
+
const accessKeyId = env.AWS_ACCESS_KEY_ID;
|
|
85
|
+
const secretAccessKey = env.AWS_SECRET_ACCESS_KEY;
|
|
86
|
+
if (!accessKeyId || !secretAccessKey) return undefined;
|
|
87
|
+
return {
|
|
88
|
+
accessKeyId,
|
|
89
|
+
secretAccessKey,
|
|
90
|
+
...(env.AWS_SESSION_TOKEN ? { sessionToken: env.AWS_SESSION_TOKEN } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One request to sign. `headers` are the caller's; the signer adds its own. */
|
|
95
|
+
export interface SigV4Request {
|
|
96
|
+
method: string;
|
|
97
|
+
/** Absolute URL. Its host is signed and its path/query are canonicalized. */
|
|
98
|
+
url: string;
|
|
99
|
+
headers: Record<string, string>;
|
|
100
|
+
body: string;
|
|
101
|
+
/** Service name as it appears in the credential scope (`cloudformation`, `cloudcontrolapi`, …). */
|
|
102
|
+
service: string;
|
|
103
|
+
region: string;
|
|
104
|
+
credentials: AwsCredentials;
|
|
105
|
+
/** Signing clock. Injected by tests; otherwise now. */
|
|
106
|
+
now?: Date;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `YYYYMMDDTHHMMSSZ` — the `X-Amz-Date` format, which is ISO-8601 basic. */
|
|
110
|
+
export function amzDate(date: Date): string {
|
|
111
|
+
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Lowercase hex SHA-256. */
|
|
115
|
+
export function sha256Hex(payload: string): string {
|
|
116
|
+
return createHash("sha256").update(payload, "utf8").digest("hex");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hmac(key: Buffer | string, data: string): Buffer {
|
|
120
|
+
return createHmac("sha256", key).update(data, "utf8").digest();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The signing key: four chained HMACs from the secret, so the key on the wire
|
|
125
|
+
* is scoped to one day, one region and one service rather than being the
|
|
126
|
+
* account secret itself.
|
|
127
|
+
*/
|
|
128
|
+
export function signingKey(secretAccessKey: string, day: string, region: string, service: string): Buffer {
|
|
129
|
+
const dateKey = hmac(`AWS4${secretAccessKey}`, day);
|
|
130
|
+
const regionKey = hmac(dateKey, region);
|
|
131
|
+
const serviceKey = hmac(regionKey, service);
|
|
132
|
+
return hmac(serviceKey, TERMINATOR);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* RFC 3986 percent-encoding. `encodeURIComponent` leaves `!'()*` alone and AWS
|
|
137
|
+
* does not, which is the difference between a signature that verifies and one
|
|
138
|
+
* that does not for any path or parameter containing them.
|
|
139
|
+
*/
|
|
140
|
+
function encodeRfc3986(value: string): string {
|
|
141
|
+
return encodeURIComponent(value).replace(
|
|
142
|
+
/[!'()*]/g,
|
|
143
|
+
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The canonical path.
|
|
149
|
+
*
|
|
150
|
+
* Every caller of this module POSTs to `/`, so the one place implementations
|
|
151
|
+
* genuinely diverge — S3 signs the path once-encoded, every other service signs
|
|
152
|
+
* it twice-encoded — never arises. Segments are encoded once; a future caller
|
|
153
|
+
* with a real path on a non-S3 service is the moment to revisit that.
|
|
154
|
+
*/
|
|
155
|
+
function canonicalPath(pathname: string): string {
|
|
156
|
+
if (!pathname || pathname === "/") return "/";
|
|
157
|
+
return pathname.split("/").map(encodeRfc3986).join("/");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Query parameters sorted by name, then by value, each side encoded. */
|
|
161
|
+
function canonicalQuery(search: URLSearchParams): string {
|
|
162
|
+
const pairs: Array<[string, string]> = [];
|
|
163
|
+
search.forEach((value, name) => pairs.push([encodeRfc3986(name), encodeRfc3986(value)]));
|
|
164
|
+
pairs.sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) : a[0] < b[0] ? -1 : 1));
|
|
165
|
+
return pairs.map(([name, value]) => `${name}=${value}`).join("&");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Header names lowercased, values trimmed with internal whitespace runs
|
|
170
|
+
* collapsed, sorted by name. Returns the canonical block and the `;`-joined
|
|
171
|
+
* signed-header list that has to agree with it exactly.
|
|
172
|
+
*/
|
|
173
|
+
export function canonicalHeaders(headers: Record<string, string>): { canonical: string; signed: string } {
|
|
174
|
+
const entries = Object.entries(headers)
|
|
175
|
+
.map(([name, value]) => [name.toLowerCase().trim(), value.trim().replace(/\s+/g, " ")] as const)
|
|
176
|
+
.filter(([name]) => !UNSIGNABLE.has(name))
|
|
177
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
178
|
+
return {
|
|
179
|
+
canonical: entries.map(([name, value]) => `${name}:${value}\n`).join(""),
|
|
180
|
+
signed: entries.map(([name]) => name).join(";"),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The canonical request, verbatim as it is hashed into the string to sign. */
|
|
185
|
+
export function canonicalRequest(
|
|
186
|
+
method: string,
|
|
187
|
+
url: URL,
|
|
188
|
+
headers: Record<string, string>,
|
|
189
|
+
payloadHash: string,
|
|
190
|
+
): { canonical: string; signed: string } {
|
|
191
|
+
const { canonical: headerBlock, signed } = canonicalHeaders(headers);
|
|
192
|
+
return {
|
|
193
|
+
canonical: [
|
|
194
|
+
method.toUpperCase(),
|
|
195
|
+
canonicalPath(url.pathname),
|
|
196
|
+
canonicalQuery(url.searchParams),
|
|
197
|
+
headerBlock,
|
|
198
|
+
signed,
|
|
199
|
+
payloadHash,
|
|
200
|
+
].join("\n"),
|
|
201
|
+
signed,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The string to sign: algorithm, timestamp, scope, hashed canonical request. */
|
|
206
|
+
export function stringToSign(timestamp: string, scope: string, canonical: string): string {
|
|
207
|
+
return [ALGORITHM, timestamp, scope, sha256Hex(canonical)].join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The caller's headers plus everything SigV4 adds: `x-amz-date`,
|
|
212
|
+
* `x-amz-content-sha256`, `x-amz-security-token` when the credentials are
|
|
213
|
+
* temporary, and the `Authorization` header itself.
|
|
214
|
+
*
|
|
215
|
+
* `host` is signed (AWS requires it) but not returned — see the module header.
|
|
216
|
+
*/
|
|
217
|
+
export function signRequest(request: SigV4Request): Record<string, string> {
|
|
218
|
+
const url = new URL(request.url);
|
|
219
|
+
const timestamp = amzDate(request.now ?? new Date());
|
|
220
|
+
const day = timestamp.slice(0, 8);
|
|
221
|
+
const payloadHash = request.body.length === 0 ? EMPTY_PAYLOAD_SHA256 : sha256Hex(request.body);
|
|
222
|
+
|
|
223
|
+
const signable: Record<string, string> = {
|
|
224
|
+
...request.headers,
|
|
225
|
+
host: url.host,
|
|
226
|
+
"x-amz-date": timestamp,
|
|
227
|
+
"x-amz-content-sha256": payloadHash,
|
|
228
|
+
...(request.credentials.sessionToken ? { "x-amz-security-token": request.credentials.sessionToken } : {}),
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const { canonical, signed } = canonicalRequest(request.method, url, signable, payloadHash);
|
|
232
|
+
const scope = `${day}/${request.region}/${request.service}/${TERMINATOR}`;
|
|
233
|
+
const key = signingKey(request.credentials.secretAccessKey, day, request.region, request.service);
|
|
234
|
+
const signature = hmac(key, stringToSign(timestamp, scope, canonical)).toString("hex");
|
|
235
|
+
|
|
236
|
+
const { host: _host, ...emitted } = signable;
|
|
237
|
+
return {
|
|
238
|
+
...emitted,
|
|
239
|
+
authorization:
|
|
240
|
+
`${ALGORITHM} Credential=${request.credentials.accessKeyId}/${scope}, ` +
|
|
241
|
+
`SignedHeaders=${signed}, Signature=${signature}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity fallback (#1647) — unit half. The carve state in miniature: a
|
|
3
|
+
* declared Bucket absent from every stack, present live, named precisely by
|
|
4
|
+
* its own `BucketName`. The Cloud Control edge is the injected `http` seam
|
|
5
|
+
* `read-client` already exposes.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect } from "vitest";
|
|
8
|
+
import { declaredIdentifier, observeByIdentity } from "./identity-observe";
|
|
9
|
+
|
|
10
|
+
const entity = (entityType: string, props: Record<string, unknown>) => ({ entityType, props });
|
|
11
|
+
|
|
12
|
+
describe("declaredIdentifier (#1647)", () => {
|
|
13
|
+
test("a scalar primary identifier reads straight off the props", () => {
|
|
14
|
+
expect(declaredIdentifier("AWS::S3::Bucket", { BucketName: "acme-platform-assets-prod" })).toBe(
|
|
15
|
+
"acme-platform-assets-prod",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("absent, empty, or non-scalar identifier parts refuse — a Ref is not an identity", () => {
|
|
20
|
+
expect(declaredIdentifier("AWS::S3::Bucket", {})).toBeUndefined();
|
|
21
|
+
expect(declaredIdentifier("AWS::S3::Bucket", { BucketName: "" })).toBeUndefined();
|
|
22
|
+
expect(declaredIdentifier("AWS::S3::Bucket", { BucketName: { Ref: "Other" } })).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("an unknown type has no identifier to spell", () => {
|
|
26
|
+
expect(declaredIdentifier("AWS::Made::Up", { Name: "x" })).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** A Cloud Control `GetResource` answer, double-encoded the way the API is. */
|
|
31
|
+
const ccFound = (identifier: string, properties: Record<string, unknown>) => ({
|
|
32
|
+
status: 200,
|
|
33
|
+
text: JSON.stringify({
|
|
34
|
+
ResourceDescription: { Identifier: identifier, Properties: JSON.stringify(properties) },
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const ccError = (type: string, message: string) => ({
|
|
39
|
+
status: 400,
|
|
40
|
+
text: JSON.stringify({ __type: type, message }),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("observeByIdentity (#1647)", () => {
|
|
44
|
+
const bucket = new Map([["assets", entity("AWS::S3::Bucket", { BucketName: "acme-platform-assets-prod" })]]);
|
|
45
|
+
|
|
46
|
+
test("a live identifier-named resource reads OBSERVED — external, foreign, never absent", async () => {
|
|
47
|
+
const calls: Array<{ url: string; body: string }> = [];
|
|
48
|
+
const { resources, queried } = await observeByIdentity(["assets"], bucket, {}, {
|
|
49
|
+
http: async (url, init) => {
|
|
50
|
+
calls.push({ url, body: init.body });
|
|
51
|
+
return ccFound("acme-platform-assets-prod", { BucketName: "acme-platform-assets-prod", SecretToken: "s3cr3t" });
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
expect(calls).toHaveLength(1);
|
|
55
|
+
expect(calls[0].url).toContain("cloudcontrol");
|
|
56
|
+
expect(resources.assets).toMatchObject({
|
|
57
|
+
type: "AWS::S3::Bucket",
|
|
58
|
+
physicalId: "acme-platform-assets-prod",
|
|
59
|
+
status: "EXTERNAL",
|
|
60
|
+
ownership: "foreign",
|
|
61
|
+
});
|
|
62
|
+
// Live properties ride along, sensitive keys scrubbed like stack outputs.
|
|
63
|
+
expect(resources.assets.attributes).toMatchObject({ BucketName: "acme-platform-assets-prod", SecretToken: "[REDACTED]" });
|
|
64
|
+
// #1620: the attempted address is on the wire whatever the verdict.
|
|
65
|
+
expect(queried.assets).toContain("AWS::S3::Bucket");
|
|
66
|
+
expect(queried.assets).toContain("acme-platform-assets-prod");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a genuine miss keeps the stack's absent verdict, with the address still recorded", async () => {
|
|
70
|
+
const { resources, queried } = await observeByIdentity(["assets"], bucket, {}, {
|
|
71
|
+
http: async () => ccError("ResourceNotFoundException", "no such bucket"),
|
|
72
|
+
});
|
|
73
|
+
expect(resources.assets).toBeUndefined();
|
|
74
|
+
expect(queried.assets).toBeDefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("an emulator without Cloud Control keeps the absent verdict — never a hole, or pre-first-apply plans stop proposing create", async () => {
|
|
78
|
+
const { resources } = await observeByIdentity(["assets"], bucket, {}, {
|
|
79
|
+
http: async () => ccError("UnsupportedOperation", "Operation GetResource is not supported."),
|
|
80
|
+
});
|
|
81
|
+
expect(resources.assets).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("entities the stack already answered for are never re-read", async () => {
|
|
85
|
+
let called = 0;
|
|
86
|
+
const already = { assets: { type: "AWS::S3::Bucket", physicalId: "b", status: "CREATE_COMPLETE" } };
|
|
87
|
+
const { resources } = await observeByIdentity(["assets"], bucket, already, {
|
|
88
|
+
http: async () => ((called += 1), ccFound("x", {})),
|
|
89
|
+
});
|
|
90
|
+
expect(called).toBe(0);
|
|
91
|
+
expect(resources).toEqual({});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("entities with no spellable identifier are skipped without a call", async () => {
|
|
95
|
+
let called = 0;
|
|
96
|
+
const anonymous = new Map([["q", entity("AWS::SQS::Queue", {})]]);
|
|
97
|
+
const { resources, queried } = await observeByIdentity(["q"], anonymous, {}, {
|
|
98
|
+
http: async () => ((called += 1), ccFound("x", {})),
|
|
99
|
+
});
|
|
100
|
+
expect(called).toBe(0);
|
|
101
|
+
expect(resources).toEqual({});
|
|
102
|
+
expect(queried).toEqual({});
|
|
103
|
+
});
|
|
104
|
+
});
|