sigflint 0.0.0 → 1.0.0

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/package.json CHANGED
@@ -1,19 +1,83 @@
1
1
  {
2
2
  "name": "sigflint",
3
- "version": "0.0.0",
4
- "description": "Placeholder that reserves the name. sigflint 1.0.0, an AWS Signature Version 4 signing library, follows.",
3
+ "version": "1.0.0",
4
+ "description": "Fast, dependency-free AWS Signature Version 4 signing for every AWS service: headers, presigned URLs, SigV4a, chunked uploads and event streams. Node.js, Bun, Deno, Cloudflare Workers and browsers.",
5
+ "keywords": [
6
+ "aws",
7
+ "s3",
8
+ "sigv4",
9
+ "sigv4a",
10
+ "presigned-url",
11
+ "signature",
12
+ "signing",
13
+ "authorization",
14
+ "bun",
15
+ "deno",
16
+ "cloudflare-workers"
17
+ ],
5
18
  "license": "MIT",
6
19
  "author": "bompus",
20
+ "type": "module",
21
+ "sideEffects": false,
22
+ "engines": {
23
+ "node": ">=22.12.0"
24
+ },
25
+ "main": "./dist/node.js",
26
+ "types": "./index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./index.d.ts",
30
+ "workerd": "./src/web.js",
31
+ "worker": "./src/web.js",
32
+ "browser": "./src/web.js",
33
+ "bun": "./dist/bun.js",
34
+ "node": "./dist/node.js",
35
+ "default": "./src/node.js"
36
+ },
37
+ "./web": {
38
+ "types": "./types/web.d.ts",
39
+ "default": "./src/web.js"
40
+ },
41
+ "./package.json": "./package.json"
42
+ },
43
+ "files": [
44
+ "src",
45
+ "dist",
46
+ "types",
47
+ "index.d.ts",
48
+ "README.md",
49
+ "LICENSE",
50
+ "CHANGELOG.md"
51
+ ],
52
+ "scripts": {
53
+ "build": "esbuild src/node.js src/bun.js --bundle --format=esm --platform=node --target=es2022 --outdir=dist --log-level=warning",
54
+ "pretest": "npm run build",
55
+ "test": "node --test \"test/*.test.js\"",
56
+ "pretest:bun": "npm run build",
57
+ "test:bun": "bun test test/",
58
+ "types": "tsc && tsc -p test/types",
59
+ "conformance": "node conformance/build.mjs && node conformance/build-more.mjs",
60
+ "prepack": "tsc && npm run build",
61
+ "lint": "npx --yes oxlint@1.85.0 --deny-warnings src test conformance index.d.ts",
62
+ "format": "npx --yes oxfmt@0.70.0 .",
63
+ "check:style": "npm run lint && npx --yes oxfmt@0.70.0 --check .",
64
+ "check": "npm test && npm run types && npm run check:package && npm run check:style",
65
+ "check:benchmarked": "node bench/check-benchmarked.mjs",
66
+ "check:package": "tsc && npm run build && publint && attw --pack . --profile esm-only"
67
+ },
68
+ "devDependencies": {
69
+ "@arethetypeswrong/cli": "^0.18.5",
70
+ "@types/node": "^26.6.5",
71
+ "esbuild": "^0.28.2",
72
+ "publint": "^0.3.25",
73
+ "typescript": "^7.0.2"
74
+ },
7
75
  "repository": {
8
76
  "type": "git",
9
77
  "url": "git+https://github.com/bompus/sigflint.git"
10
78
  },
11
- "homepage": "https://github.com/bompus/sigflint#readme",
12
79
  "bugs": {
13
80
  "url": "https://github.com/bompus/sigflint/issues"
14
81
  },
15
- "files": [
16
- "README.md",
17
- "LICENSE"
18
- ]
82
+ "homepage": "https://github.com/bompus/sigflint#readme"
19
83
  }
package/src/base.js ADDED
@@ -0,0 +1,341 @@
1
+ // AWS Signature Version 4, header based, for S3 and every other service (request.service, default 's3'). No imports: the hash and HMAC primitives come from the
2
+ // runtime entry. Presigned URLs, SigV4a and streamed bodies live in their own modules so a consumer only bundles what it imports.
3
+ // The canonical request follows the specification:
4
+ // - path: S3 percent-decodes ('+' is a space) and encodes every byte but A-Z a-z 0-9 - . _ ~ and '/'; every other service normalises the path
5
+ // (empty segments dropped, '.' and '..' resolved) and encodes it a second time.
6
+ // - query: split on '&', decode each name and value, encode every byte but A-Z a-z 0-9 - . _ ~, sort by name then value, valueless names get "name=".
7
+ // - headers: lowercase names, trimmed values with runs of spaces and tabs collapsed, sorted by name.
8
+ // The request (including its URL) is never modified.
9
+
10
+ export const emptyBodyHash = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
11
+
12
+ /** The scheme of a signature: its algorithm name and how the credential scope is written. SigV4a (v4a.js) has its own. */
13
+ export const symmetric = { algorithm: 'AWS4-HMAC-SHA256', scope: (date, region, service) => `${date}/${region}/${service}/aws4_request` };
14
+
15
+ const reSafePath = /^[A-Za-z0-9\-._~/]*$/;
16
+ const reSafeQuery = /^[A-Za-z0-9\-._~=&]*$/;
17
+ const reSpaces = /[ \t]+/g;
18
+ const reDirty = /\t| /;
19
+
20
+ // ASCII code -> what it is written as in a canonical path / query. url.pathname and url.search are always ASCII (the URL parser percent-encodes everything else).
21
+ // Built on first use, so importing this module costs nothing.
22
+ let pathTable;
23
+ let queryTable;
24
+ function tables() {
25
+ if (pathTable !== undefined) return;
26
+ pathTable = new Array(256);
27
+ queryTable = new Array(256);
28
+ for (let b = 0; b < 256; b++) {
29
+ const c = String.fromCharCode(b);
30
+ const unreserved = /[A-Za-z0-9\-._~]/.test(c);
31
+ const escaped = (b < 16 ? '%0' : '%') + b.toString(16).toUpperCase();
32
+ pathTable[b] = unreserved || c === '/' ? c : escaped;
33
+ queryTable[b] = unreserved ? c : escaped;
34
+ }
35
+ }
36
+
37
+ function hexValue(code) {
38
+ if (code >= 48 && code <= 57) return code - 48; // 0-9
39
+ if (code >= 65 && code <= 70) return code - 55; // A-F
40
+ if (code >= 97 && code <= 102) return code - 87; // a-f
41
+ return -1;
42
+ }
43
+
44
+ // Decodes %XX escapes to bytes (a '%' that does not start an escape is the byte 0x25) and encodes each byte with the table; '+' is a space.
45
+ // Runs of characters that are already canonical are copied as one piece.
46
+ function canonicalEncode(string, table) {
47
+ let out = '';
48
+ let start = 0;
49
+ for (let i = 0; i < string.length; i++) {
50
+ const code = string.charCodeAt(i);
51
+ const same = table[code];
52
+ if (same.length === 1 && code !== 43) continue;
53
+ out += string.slice(start, i);
54
+ start = i + 1;
55
+ if (code === 37) {
56
+ const hi = hexValue(string.charCodeAt(i + 1));
57
+ const lo = hexValue(string.charCodeAt(i + 2));
58
+ if (hi >= 0 && lo >= 0) {
59
+ out += table[(hi << 4) | lo];
60
+ i += 2;
61
+ start = i + 1;
62
+ } else {
63
+ out += '%25';
64
+ }
65
+ } else {
66
+ out += code === 43 ? '%20' : same;
67
+ }
68
+ }
69
+ return start === 0 ? string : out + string.slice(start);
70
+ }
71
+
72
+ function canonicalPathGeneral(pathname) {
73
+ const parts = pathname.split('/');
74
+ const kept = [];
75
+ for (let i = 0; i < parts.length; i++) {
76
+ const part = parts[i];
77
+ if (part === '' || part === '.') continue;
78
+ if (part === '..') kept.pop();
79
+ else kept.push(part);
80
+ }
81
+ const path = (pathname.startsWith('/') ? '/' : '') + kept.join('/') + (pathname.endsWith('/') && kept.length > 0 ? '/' : '');
82
+ if (reSafePath.test(path)) return path === '' ? '/' : path;
83
+ tables();
84
+ let out = '';
85
+ for (let i = 0; i < path.length; i++) out += queryTable[path.charCodeAt(i)] === path[i] || path[i] === '/' ? path[i] : queryTable[path.charCodeAt(i)];
86
+ return out;
87
+ }
88
+
89
+ function canonicalPathS3(pathname) {
90
+ if (reSafePath.test(pathname)) return pathname === '' ? '/' : pathname;
91
+ tables();
92
+ return canonicalEncode(pathname, pathTable);
93
+ }
94
+
95
+ /** The canonical path for a service. */
96
+ export const canonicalPath = (service, pathname) => (service === 's3' ? canonicalPathS3(pathname) : canonicalPathGeneral(pathname));
97
+
98
+ function comparePairs(a, b) {
99
+ return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0;
100
+ }
101
+
102
+ /** search is url.search, which is '' or starts with '?' */
103
+ export function canonicalQuery(search) {
104
+ if (search.length < 2) return '';
105
+ const query = search.slice(1);
106
+ const simple = reSafeQuery.test(query);
107
+ if (simple) {
108
+ // Plain characters, one '=' per parameter, in order: the canonical query is the query (a name without a value gets a '='). Anything else, including two
109
+ // parameters with the same name and value, is left to the general code below.
110
+ let out = '';
111
+ let previousName = '';
112
+ let previousValue = '';
113
+ let start = 0;
114
+ for (;;) {
115
+ let end = query.indexOf('&', start);
116
+ if (end === -1) end = query.length;
117
+ let eq = query.indexOf('=', start);
118
+ if (eq === -1 || eq > end) eq = end;
119
+ const name = query.slice(start, eq);
120
+ const value = query.slice(eq + 1, end);
121
+ if (end === start || eq === start || value.includes('=') || (start > 0 && (name < previousName || (name === previousName && value <= previousValue)))) break;
122
+ out += (start === 0 ? '' : '&') + name + '=' + value;
123
+ if (end === query.length) return out;
124
+ previousName = name;
125
+ previousValue = value;
126
+ start = end + 1;
127
+ }
128
+ }
129
+ tables();
130
+ const pairs = [];
131
+ for (const part of query.split('&')) {
132
+ if (part === '') continue;
133
+ const eq = part.indexOf('=');
134
+ const rawName = eq === -1 ? part : part.slice(0, eq);
135
+ const rawValue = eq === -1 ? '' : part.slice(eq + 1);
136
+ // a '=' inside a value must be encoded, so the plain-characters shortcut applies only to values without one
137
+ pairs.push(simple && !rawValue.includes('=') ? [rawName, rawValue] : [canonicalEncode(rawName, queryTable), canonicalEncode(rawValue, queryTable)]);
138
+ }
139
+ if (pairs.length > 1) pairs.sort(comparePairs);
140
+ let out = '';
141
+ for (let i = 0; i < pairs.length; i++) out += (i === 0 ? '' : '&') + pairs[i][0] + '=' + pairs[i][1];
142
+ return out;
143
+ }
144
+
145
+ /** inserts into the name-ordered lists, replacing an entry with the same name */
146
+ export function insert(names, values, name, value) {
147
+ let i = names.length;
148
+ while (i > 0 && names[i - 1] > name) i--;
149
+ if (i > 0 && names[i - 1] === name) {
150
+ values[i - 1] = value;
151
+ return;
152
+ }
153
+ for (let j = names.length; j > i; j--) {
154
+ names[j] = names[j - 1];
155
+ values[j] = values[j - 1];
156
+ }
157
+ names[i] = name;
158
+ values[i] = value;
159
+ }
160
+
161
+ /**
162
+ * Refuses a signHeaders entry that cannot be signed as given: x-amz-date and authorization are set by the signer, `__proto__` cannot be a property of the
163
+ * returned headers, and a line break in a value would start another canonical header line. Returns the lowercase name.
164
+ */
165
+ export function signHeaderName(name, value) {
166
+ const lowerName = name.toLowerCase();
167
+ if (lowerName === 'x-amz-date' || lowerName === 'authorization' || lowerName === '__proto__') throw new TypeError(`signHeaders cannot contain ${lowerName}`);
168
+ if (/[\r\n]/.test(value)) throw new TypeError(`signHeaders ${lowerName} has a line break in its value`);
169
+ return lowerName;
170
+ }
171
+
172
+ /** A header value as it is signed: trimmed, runs of spaces and tabs collapsed. */
173
+ export const headerValue = (value) => {
174
+ const trimmed = String(value).trim();
175
+ return reDirty.test(trimmed) ? trimmed.replace(reSpaces, ' ') : trimmed;
176
+ };
177
+
178
+ // 20130524T000000Z, recomputed only when the second changes
179
+ let lastSecond = NaN;
180
+ let lastDatetime = '';
181
+ export function amzDatetime(now) {
182
+ const second = Math.floor(now / 1000);
183
+ if (second !== lastSecond) {
184
+ const iso = new Date(second * 1000).toISOString(); // 2013-05-24T00:00:00.000Z
185
+ lastDatetime = iso.slice(0, 4) + iso.slice(5, 7) + iso.slice(8, 10) + 'T' + iso.slice(11, 13) + iso.slice(14, 16) + iso.slice(17, 19) + 'Z';
186
+ lastSecond = second;
187
+ }
188
+ return lastDatetime;
189
+ }
190
+
191
+ /**
192
+ * @typedef {Object} SignRequest
193
+ * @property {string} method - GET, HEAD, PUT, POST, DELETE
194
+ * @property {URL} url
195
+ * @property {string} [service] - default 's3'
196
+ * @property {string} [region] - for example us-west-2
197
+ * @property {string} [body] - hashed into x-amz-content-sha256; an empty or missing body is the hash of an empty string
198
+ * @property {boolean} [unsignedPayload] - send UNSIGNED-PAYLOAD as x-amz-content-sha256 instead of hashing the body
199
+ * @property {Record<string, string>} [signHeaders] - extra headers to sign and return; names are lowercased in the result
200
+ * @property {number} [now] - signing time in milliseconds since the epoch; defaults to the current time
201
+ * @property {number} [expires] - presigned URLs: lifetime in seconds, default 3600
202
+ * @property {string} [regionSet] - SigV4a: the regions the signature is valid for, 'us-east-1,us-west-2' or '*'
203
+ */
204
+
205
+ /**
206
+ * @typedef {Object} Credentials
207
+ * @property {string} accessKeyId
208
+ * @property {string} secretAccessKey
209
+ * @property {string} [sessionToken] - temporary credentials: signed and returned as x-amz-security-token
210
+ */
211
+
212
+ /** @typedef {Record<string, string>} AuthHeaders */
213
+
214
+ /**
215
+ * @typedef {Object} Primitives
216
+ * @property {(data: string | Uint8Array) => string} hexHash - SHA-256 as lowercase hex
217
+ * @property {(key: string | Uint8Array, data: string) => Uint8Array} hmac - HMAC-SHA-256 as bytes
218
+ * @property {(key: Uint8Array, data: string) => string} hmacHex - HMAC-SHA-256 as lowercase hex
219
+ * @property {ReturnType<typeof createKeys>} getSigningKey - the entry's one signing-key cache, shared by every symmetric signer
220
+ */
221
+
222
+ /** The payload hash for x-amz-content-sha256. The hash function may return a promise, which is then the result. */
223
+ export function payloadHashOf(request, hexHash) {
224
+ const body = request.body ?? '';
225
+ return request.unsignedPayload ? 'UNSIGNED-PAYLOAD' : body.length === 0 ? emptyBodyHash : hexHash(body);
226
+ }
227
+
228
+ /** Everything up to the hashing: the headers to return, the canonical request and the credential scope. */
229
+ export function prepare(request, credentials, payloadHash, scheme = symmetric) {
230
+ const { method, url, signHeaders } = request;
231
+ const region = request.region ?? '';
232
+ const service = request.service ?? 's3';
233
+ // S3 always sends x-amz-content-sha256; other services only when the payload is unsigned
234
+ const withHash = service === 's3' || request.unsignedPayload === true;
235
+ const sessionToken = credentials.sessionToken;
236
+ const datetime = amzDatetime(request.now ?? Date.now());
237
+ const date = datetime.slice(0, 8);
238
+
239
+ const headers = withHash ? { 'x-amz-date': datetime, 'x-amz-content-sha256': payloadHash } : { 'x-amz-date': datetime }; // as returned
240
+
241
+ const extraNames = signHeaders === undefined ? [] : Object.keys(signHeaders);
242
+ let canonicalHeaders;
243
+ let signedHeaders;
244
+ if (extraNames.length === 0 && !sessionToken) {
245
+ // the usual request: only the headers that are always signed, already in sorted order
246
+ canonicalHeaders = withHash ? `host:${url.host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${datetime}\n` : `host:${url.host}\nx-amz-date:${datetime}\n`;
247
+ signedHeaders = withHash ? 'host;x-amz-content-sha256;x-amz-date' : 'host;x-amz-date';
248
+ } else {
249
+ // the extra headers (and the session token) in name order, then merged with the ones that are always signed;
250
+ // a header named like one of those, or like an earlier extra, replaces it
251
+ const names = [];
252
+ const values = [];
253
+ if (sessionToken) {
254
+ headers['x-amz-security-token'] = sessionToken;
255
+ insert(names, values, 'x-amz-security-token', sessionToken.trim());
256
+ }
257
+ for (const name of extraNames) {
258
+ const value = /** @type {Record<string, string>} */ (signHeaders)[name];
259
+ const lowerName = signHeaderName(name, value);
260
+ headers[lowerName] = value;
261
+ insert(names, values, lowerName, headerValue(value));
262
+ }
263
+ const baseNames = withHash ? ['host', 'x-amz-content-sha256', 'x-amz-date'] : ['host', 'x-amz-date'];
264
+ const baseValues = withHash ? [url.host, payloadHash, datetime] : [url.host, datetime];
265
+ const nBase = baseNames.length;
266
+ canonicalHeaders = '';
267
+ signedHeaders = '';
268
+ let i = 0;
269
+ let j = 0;
270
+ while (i < names.length || j < nBase) {
271
+ let name;
272
+ let value;
273
+ if (j === nBase || (i < names.length && names[i] <= baseNames[j])) {
274
+ name = names[i];
275
+ value = values[i++];
276
+ if (j < nBase && name === baseNames[j]) j++;
277
+ } else {
278
+ name = baseNames[j];
279
+ value = baseValues[j++];
280
+ }
281
+ if (name === 'x-amz-content-sha256') payloadHash = value; // a caller-supplied one replaces the computed one
282
+ canonicalHeaders += `${name}:${value}\n`;
283
+ signedHeaders += signedHeaders === '' ? name : ';' + name;
284
+ }
285
+ }
286
+
287
+ const canonicalRequest = `${method}\n${canonicalPath(service, url.pathname)}\n${canonicalQuery(url.search)}\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`;
288
+ return { headers, canonicalRequest, datetime, date, region, service, credentialScope: scheme.scope(date, region, service), signedHeaders, algorithm: scheme.algorithm };
289
+ }
290
+
291
+ export const stringToSign = (prepared, canonicalRequestHash) => `${prepared.algorithm}\n${prepared.datetime}\n${prepared.credentialScope}\n${canonicalRequestHash}`;
292
+
293
+ /** Adds the authorization header to the headers to return. */
294
+ export function authorize(prepared, accessKeyId, signature) {
295
+ prepared.headers.authorization = `${prepared.algorithm} Credential=${accessKeyId}/${prepared.credentialScope}, SignedHeaders=${prepared.signedHeaders}, Signature=${signature}`;
296
+ return prepared.headers;
297
+ }
298
+
299
+ export function createKeys(hmac) {
300
+ const signingKeys = new Map();
301
+ const maxSigningKeys = 1000;
302
+
303
+ // the key used last is checked first, which skips building the lookup string on repeated calls
304
+ let lastSecret;
305
+ let lastDate;
306
+ let lastRegion;
307
+ let lastService;
308
+ let lastKey;
309
+
310
+ return function getSigningKey(secretAccessKey, date, region, service) {
311
+ if (secretAccessKey === lastSecret && date === lastDate && region === lastRegion && service === lastService) return lastKey;
312
+ const cacheKey = `${date}\n${region}\n${service}\n${secretAccessKey}`; // a region or service cannot contain a line break, so two scopes never share a key
313
+ let signingKey = signingKeys.get(cacheKey);
314
+ if (signingKey === undefined) {
315
+ signingKey = hmac(hmac(hmac(hmac('AWS4' + secretAccessKey, date), region), service), 'aws4_request');
316
+ if (signingKeys.size >= maxSigningKeys) signingKeys.delete(signingKeys.keys().next().value);
317
+ signingKeys.set(cacheKey, signingKey);
318
+ }
319
+ lastSecret = secretAccessKey;
320
+ lastDate = date;
321
+ lastRegion = region;
322
+ lastService = service;
323
+ lastKey = signingKey;
324
+ return signingKey;
325
+ };
326
+ }
327
+
328
+ /**
329
+ * @param {Primitives} primitives
330
+ * @returns {(request: SignRequest, credentials: Credentials) => AuthHeaders}
331
+ */
332
+ export function createSigner({ hexHash, hmacHex, getSigningKey }) {
333
+ return function getAuthHeaders(request, credentials) {
334
+ const prepared = prepare(request, credentials, payloadHashOf(request, hexHash));
335
+ return authorize(
336
+ prepared,
337
+ credentials.accessKeyId,
338
+ hmacHex(getSigningKey(credentials.secretAccessKey, prepared.date, prepared.region, prepared.service), stringToSign(prepared, hexHash(prepared.canonicalRequest))),
339
+ );
340
+ };
341
+ }
package/src/bun.js ADDED
@@ -0,0 +1,37 @@
1
+ // Bun: its own SHA-256 and HMAC, which are faster than node:crypto there.
2
+ import crypto from 'node:crypto';
3
+ import { createSigner, createKeys } from './base.js';
4
+ import { createPresigner } from './presign.js';
5
+ import { createSignerV4a, createPresignerV4a } from './v4a.js';
6
+ import { ecdsa } from './ecdsa-node.js';
7
+ import { createChunkSigner } from './chunks.js';
8
+ import { createEventSigner } from './events.js';
9
+
10
+ const bun = /** @type {any} */ (globalThis).Bun;
11
+
12
+ // the HMAC state for the signing key in use is built once and cloned for each signature
13
+ let baseKey;
14
+ let base;
15
+
16
+ const hmac = (key, data) => crypto.createHmac('sha256', key).update(data).digest();
17
+ const getSigningKey = /* @__PURE__ */ createKeys(hmac);
18
+ const hexHash = (data) => bun.SHA256.hash(data, 'hex');
19
+ const primitives = {
20
+ hexHash,
21
+ hmac,
22
+ hmacHex: (key, data) => {
23
+ if (key !== baseKey) {
24
+ baseKey = key;
25
+ base = new bun.CryptoHasher('sha256', key);
26
+ }
27
+ return base.copy().update(data).digest('hex');
28
+ },
29
+ getSigningKey,
30
+ };
31
+
32
+ export const getAuthHeaders = /* @__PURE__ */ createSigner(primitives);
33
+ export const presign = /* @__PURE__ */ createPresigner(primitives);
34
+ export const getAuthHeadersV4a = /* @__PURE__ */ createSignerV4a({ hexHash, hmac, ecdsa });
35
+ export const presignV4a = /* @__PURE__ */ createPresignerV4a({ hexHash, hmac, ecdsa });
36
+ export const chunkSigner = /* @__PURE__ */ createChunkSigner(primitives);
37
+ export const eventSigner = /* @__PURE__ */ createEventSigner(primitives);
package/src/chunks.js ADDED
@@ -0,0 +1,82 @@
1
+ // aws-chunked uploads (x-amz-content-sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD[-TRAILER]): every chunk is signed with the previous signature, starting from the
2
+ // one in the request's authorization header. The pure helpers are shared by the synchronous entries (createChunkSigner) and the WebCrypto one.
3
+ import { emptyBodyHash as emptyHash } from './base.js';
4
+
5
+ export const utf8 = /* @__PURE__ */ new TextEncoder();
6
+
7
+ /** The seed signature, timestamp and scope of a signed request, from the headers getAuthHeaders returned. */
8
+ export function seedOf(authHeaders) {
9
+ const auth = authHeaders.authorization;
10
+ if (typeof auth !== 'string' || !auth.startsWith('AWS4-HMAC-SHA256 '))
11
+ throw new TypeError('authHeaders must be the headers getAuthHeaders returned (a SigV4a signature cannot start a chunk or event stream)');
12
+ const scope = auth.slice(auth.indexOf('Credential=') + 11, auth.indexOf(',')).split('/'); // access key, date, region, service, aws4_request
13
+ return {
14
+ signature: auth.slice(auth.lastIndexOf('Signature=') + 10),
15
+ datetime: authHeaders['x-amz-date'],
16
+ date: scope[1],
17
+ region: scope[2],
18
+ service: scope[3],
19
+ scope: scope.slice(1).join('/'),
20
+ };
21
+ }
22
+
23
+ export const chunkStringToSign = (datetime, scope, previous, payloadHash) => `AWS4-HMAC-SHA256-PAYLOAD\n${datetime}\n${scope}\n${previous}\n${emptyHash}\n${payloadHash}`;
24
+ export const trailerStringToSign = (datetime, scope, previous, trailerHash) => `AWS4-HMAC-SHA256-TRAILER\n${datetime}\n${scope}\n${previous}\n${trailerHash}`;
25
+
26
+ /** Trailing headers in canonical form: lowercase names in name order, each line ending in a newline. */
27
+ export function canonicalTrailers(trailers) {
28
+ return Object.keys(trailers)
29
+ .map((name) => [name.toLowerCase(), String(trailers[name]).trim()])
30
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
31
+ .map(([name, value]) => `${name}:${value}\n`)
32
+ .join('');
33
+ }
34
+
35
+ /** The bytes of one chunk on the wire: size in hex, signature, the data. */
36
+ export function frameChunk(signature, data) {
37
+ const head = utf8.encode(`${data.length.toString(16)};chunk-signature=${signature}\r\n`);
38
+ const out = new Uint8Array(head.length + data.length + 2);
39
+ out.set(head);
40
+ out.set(data, head.length);
41
+ out[out.length - 2] = 13;
42
+ out[out.length - 1] = 10;
43
+ return out;
44
+ }
45
+
46
+ /** The last chunk (no data), with the signed trailing headers when there are any. */
47
+ export function frameEnd(signature, trailers, trailerSignature) {
48
+ return utf8.encode(
49
+ trailers === undefined
50
+ ? `0;chunk-signature=${signature}\r\n\r\n`
51
+ : `0;chunk-signature=${signature}\r\n${trailers.replace(/\n/g, '\r\n')}x-amz-trailer-signature:${trailerSignature}\r\n\r\n`,
52
+ );
53
+ }
54
+
55
+ /**
56
+ * @typedef {Object} ChunkSigner
57
+ * @property {(data: Uint8Array) => Uint8Array} chunk - the bytes to send for one chunk of the body
58
+ * @property {(trailers?: Record<string, string>) => Uint8Array} end - the last chunk, with the signed trailing headers when there are any
59
+ */
60
+
61
+ /**
62
+ * @param {import('./base.js').Primitives} primitives
63
+ * @returns {(authHeaders: import('./base.js').AuthHeaders, credentials: import('./base.js').Credentials) => ChunkSigner}
64
+ */
65
+ export function createChunkSigner({ hexHash, hmacHex, getSigningKey }) {
66
+ return function chunkSigner(authHeaders, credentials) {
67
+ const seed = seedOf(authHeaders);
68
+ const key = getSigningKey(credentials.secretAccessKey, seed.date, seed.region, seed.service);
69
+ let previous = seed.signature;
70
+ const sign = (data) => (previous = hmacHex(key, chunkStringToSign(seed.datetime, seed.scope, previous, hexHash(data))));
71
+ return {
72
+ chunk: (data) => (data.length === 0 ? data : frameChunk(sign(data), data)), // a zero-length chunk would end the upload; it is not sent
73
+ end(trailers) {
74
+ const signature = sign(new Uint8Array(0));
75
+ if (trailers === undefined) return frameEnd(signature);
76
+ const text = canonicalTrailers(trailers);
77
+ const trailerSignature = hmacHex(key, trailerStringToSign(seed.datetime, seed.scope, signature, hexHash(text)));
78
+ return frameEnd(signature, text, trailerSignature);
79
+ },
80
+ };
81
+ };
82
+ }
@@ -0,0 +1,14 @@
1
+ // SigV4a's ECDSA P-256 for the entries that have node:crypto (Node, Deno, Bun). The key is rebuilt from the derived scalar; ECDH supplies the public point that a JWK needs.
2
+ import crypto from 'node:crypto';
3
+
4
+ const b64url = (bytes) => Buffer.from(bytes).toString('base64url');
5
+
6
+ export const ecdsa = {
7
+ key(d) {
8
+ const ecdh = crypto.createECDH('prime256v1');
9
+ ecdh.setPrivateKey(d);
10
+ const point = ecdh.getPublicKey();
11
+ return crypto.createPrivateKey({ key: { kty: 'EC', crv: 'P-256', d: b64url(d), x: b64url(point.subarray(1, 33)), y: b64url(point.subarray(33)) }, format: 'jwk' });
12
+ },
13
+ sign: (key, text) => crypto.sign('sha256', text, { key, dsaEncoding: 'der' }).toString('hex'),
14
+ };
package/src/events.js ADDED
@@ -0,0 +1,85 @@
1
+ // Event streams (Transcribe, Lex, Bedrock, Kinesis, ...): each message is wrapped in an outer message that carries a :date and a :chunk-signature header.
2
+ import { amzDatetime } from './base.js';
3
+ import { seedOf, utf8 } from './chunks.js';
4
+
5
+ let crcTable;
6
+ function crc32(bytes, start, end) {
7
+ if (crcTable === undefined) {
8
+ crcTable = new Int32Array(256);
9
+ for (let n = 0; n < 256; n++) {
10
+ let c = n;
11
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
12
+ crcTable[n] = c;
13
+ }
14
+ }
15
+ let crc = -1;
16
+ for (let i = start; i < end; i++) crc = crcTable[(crc ^ bytes[i]) & 255] ^ (crc >>> 8);
17
+ return ~crc >>> 0;
18
+ }
19
+
20
+ /** The :date header of an event: name, type 8 (timestamp), milliseconds since the epoch as 8 bytes. */
21
+ export function dateHeader(ms) {
22
+ const out = new Uint8Array(15);
23
+ out[0] = 5;
24
+ out.set(utf8.encode(':date'), 1);
25
+ out[6] = 8;
26
+ new DataView(out.buffer).setBigInt64(7, BigInt(Math.floor(ms)));
27
+ return out;
28
+ }
29
+
30
+ /** The signed event: prelude, the :date and :chunk-signature headers, the inner message as payload, checksums. */
31
+ export function frameEvent(dateBytes, signatureHex, inner) {
32
+ const headersLength = dateBytes.length + 52;
33
+ const out = new Uint8Array(16 + headersLength + inner.length);
34
+ const view = new DataView(out.buffer);
35
+ view.setUint32(0, out.length);
36
+ view.setUint32(4, headersLength);
37
+ view.setUint32(8, crc32(out, 0, 8));
38
+ out.set(dateBytes, 12);
39
+ let at = 12 + dateBytes.length;
40
+ out[at++] = 16;
41
+ out.set(utf8.encode(':chunk-signature'), at);
42
+ at += 16;
43
+ out[at++] = 6;
44
+ view.setUint16(at, 32);
45
+ at += 2;
46
+ for (let i = 0; i < 32; i++) out[at + i] = parseInt(signatureHex.slice(2 * i, 2 * i + 2), 16);
47
+ at += 32;
48
+ out.set(inner, at);
49
+ view.setUint32(out.length - 4, crc32(out, 0, out.length - 4));
50
+ return out;
51
+ }
52
+
53
+ export const eventStringToSign = (datetime, scope, previous, headersHash, payloadHash) =>
54
+ `AWS4-HMAC-SHA256-PAYLOAD\n${datetime}\n${scope}\n${previous}\n${headersHash}\n${payloadHash}`;
55
+ export const eventScope = (ms, region, service) => {
56
+ const datetime = amzDatetime(ms);
57
+ return { datetime, scope: `${datetime.slice(0, 8)}/${region}/${service}/aws4_request`, date: datetime.slice(0, 8) };
58
+ };
59
+
60
+ /**
61
+ * @typedef {Object} EventSigner
62
+ * @property {(message: Uint8Array, now?: number) => Uint8Array} sign - the signed event to send for one encoded message
63
+ */
64
+
65
+ /**
66
+ * @param {import('./base.js').Primitives} primitives
67
+ * @returns {(authHeaders: import('./base.js').AuthHeaders, credentials: import('./base.js').Credentials) => EventSigner}
68
+ */
69
+ export function createEventSigner({ hexHash, hmacHex, getSigningKey }) {
70
+ return function eventSigner(authHeaders, credentials) {
71
+ const seed = seedOf(authHeaders);
72
+ let previous = seed.signature;
73
+ return {
74
+ sign(message, now = Date.now()) {
75
+ const { datetime, scope, date } = eventScope(now, seed.region, seed.service);
76
+ const header = dateHeader(now);
77
+ previous = hmacHex(
78
+ getSigningKey(credentials.secretAccessKey, date, seed.region, seed.service),
79
+ eventStringToSign(datetime, scope, previous, hexHash(header), hexHash(message)),
80
+ );
81
+ return frameEvent(header, previous, message);
82
+ },
83
+ };
84
+ };
85
+ }