sigflint 1.0.0 → 1.0.1
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/CHANGELOG.md +7 -0
- package/README.md +17 -3
- package/dist/bun.js +11 -5
- package/dist/node.js +11 -5
- package/package.json +1 -1
- package/src/base.js +3 -2
- package/src/chunks.js +8 -4
- package/src/presign.js +2 -1
- package/src/web.js +2 -2
- package/types/base.d.ts +2 -2
- package/types/chunks.d.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.1 - 2026-10-18
|
|
4
|
+
|
|
5
|
+
- A lowercase `method` (`get`) is signed as upper case (`GET`), as botocore does. `fetch` sends `get`, `put`, `post`, `delete`, `head` and `options` in upper case, so before this the signature did not match and the service answered `SignatureDoesNotMatch`. Node's `fetch` sends `patch` as written, so pass `PATCH` in upper case to both sigflint and `fetch`.
|
|
6
|
+
- `end(trailers)` on a chunk signer throws a `TypeError` when a trailer name or value holds a line break. Before, the break was signed as another trailer line.
|
|
7
|
+
- Correction to the 1.0.0 entry: a raw `+` in an S3 path is signed as a space, but "as S3 reads it" was not verified. Nothing has been tried against AWS S3, and a local `s3s-fs` 0.17.0 server reads a raw `+` as a plus sign and rejected the signature. Write `%2B` in the URL to mean a plus: that server accepted it from sigflint and from botocore. The signing is unchanged (`docs/guide.md`).
|
|
8
|
+
- Correction to the 1.0.0 entry: a bundler that picks the `node` or `bun` condition gets the built file (`dist/`) and drops unused signers just as well; the per-signer modules (`src/`) go to the `workerd`, `worker` and `browser` conditions and to bundlers that match none of them.
|
|
9
|
+
|
|
3
10
|
## 1.0.0 - 2026-10-16
|
|
4
11
|
|
|
5
12
|
First version: AWS Signature Version 4 signing for every AWS service.
|
package/README.md
CHANGED
|
@@ -5,6 +5,20 @@
|
|
|
5
5
|
</picture>
|
|
6
6
|
</h1>
|
|
7
7
|
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://github.com/bompus/sigflint/actions/workflows/ci.yml"><img src="https://github.com/bompus/sigflint/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
|
|
10
|
+
<a href="https://github.com/bompus/sigflint/releases/latest"><img src="https://badgen.net/github/release/bompus/sigflint" alt="Latest GitHub release"></a>
|
|
11
|
+
<a href="https://www.npmjs.com/package/sigflint"><img src="https://badgen.net/npm/v/sigflint" alt="Latest sigflint version on npm"></a>
|
|
12
|
+
<a href="LICENSE"><img src="https://badgen.net/badge/license/MIT/blue" alt="MIT license"></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="docs/guide.md#usage">Headers and presigned URLs</a> ·
|
|
17
|
+
<a href="docs/guide.md#sigv4a-multi-region">SigV4a</a> ·
|
|
18
|
+
<a href="docs/guide.md#chunked-uploads">Chunked uploads and event streams</a> ·
|
|
19
|
+
<a href="package.json">No dependencies</a>
|
|
20
|
+
</p>
|
|
21
|
+
|
|
8
22
|
Fast AWS Signature Version 4 request signing for every AWS service, with no dependencies. It runs on Node.js, Bun, Deno, Cloudflare Workers and browsers.
|
|
9
23
|
|
|
10
24
|
```js
|
|
@@ -20,8 +34,8 @@ const response = await fetch(url, { headers });
|
|
|
20
34
|
|
|
21
35
|
It signs requests with the `Authorization` header or as a presigned URL, for S3 and for any other service (`service: 'dynamodb'`, `'sqs'`, `'execute-api'`, ...), and it also does multi-region signatures (SigV4a), chunked uploads and event streams.
|
|
22
36
|
|
|
23
|
-
- **Correct.** It returns the same signature as botocore for all 3,360 S3 requests in its test corpus, and for 1,622 requests to other services and 1,600 presigned URLs. For S3, botocore signs the request after this repository's own path and query normalization (`conformance/oracle.py`), so that step is not checked independently by the corpus; the four S3 examples published by AWS, which are in it, are. SigV4a signatures verify against the AWS CRT's, and chunk and event
|
|
24
|
-
- **Fast.** 1.3 to 3.
|
|
37
|
+
- **Correct.** It returns the same signature as botocore for all 3,360 S3 requests in its test corpus, and for 1,622 requests to other services and 1,600 presigned URLs. For S3, botocore signs the request after this repository's own path and query normalization (`conformance/oracle.py`), so that step is not checked independently by the corpus; the four S3 examples published by AWS, which are in it, are. SigV4a signatures verify against the AWS CRT's, and every chunk and event signature in the test corpus matches the AWS SDK's. See [how it is tested](docs/benchmarks.md#how-it-is-tested).
|
|
38
|
+
- **Fast.** 1.3 to 3.6 µs per header or presigned-URL signature on Node and 1.4 to 5.0 µs on Bun. The other libraries take 2.6 to 552 times as long on the requests they sign correctly (five Bun presigned-URL comparisons, with `aws4`, `@smithy/signature-v4` and `minio`, are not clear and are left out). See [speed](docs/benchmarks.md#speed).
|
|
25
39
|
- **Small, and you pay only for what you import.** Signing headers is 5.7 kB minified, 2.8 kB gzipped; presigned URLs, SigV4a, chunked uploads and event streams each add to a bundle only when imported. Nothing to install with it. See [size](docs/benchmarks.md#size).
|
|
26
40
|
- **Everywhere.** One import on Node, Bun, Deno and Cloudflare Workers, with or without `nodejs_compat`. See [runtimes](#runtimes).
|
|
27
41
|
|
|
@@ -58,7 +72,7 @@ For a large or binary body, skip hashing and send `unsignedPayload: true`. S3 ac
|
|
|
58
72
|
const headers = await getAuthHeaders({ method: 'PUT', url, region, unsignedPayload: true }, credentials);
|
|
59
73
|
```
|
|
60
74
|
|
|
61
|
-
For another S3-compatible service, pass its region name and URL (`https://us-sea-1.linodeobjects.com/bucket/key`, `http://127.0.0.1:9000/bucket/key`). These are examples of the form, not endpoints that were tried
|
|
75
|
+
For another S3-compatible service, pass its region name and URL (`https://us-sea-1.linodeobjects.com/bucket/key`, `http://127.0.0.1:9000/bucket/key`). These are examples of the form, not endpoints that were tried. Signatures are checked against botocore, the AWS CRT, the AWS SDK's signer and the AWS documentation examples. Nothing has been sent to AWS S3, so a service that canonicalizes requests differently from S3 may reject them. The only server tried is a local `s3s-fs` 0.17.0 test server on loopback, once, with header-signed requests (create a bucket, `PUT`, `GET`, list): it accepted them, except that it rejected sigflint's signature for a raw `+` in the path, so write `%2B` ([how the path is read](docs/guide.md#how-it-reads-the-url)).
|
|
62
76
|
|
|
63
77
|
More in the [guide](docs/guide.md): [other services](docs/guide.md#other-services), [presigned URLs](docs/guide.md#presigned-urls), [SigV4a](docs/guide.md#sigv4a-multi-region), [chunked uploads](docs/guide.md#chunked-uploads), [event streams](docs/guide.md#event-streams), why to always `await`, and the [full API](docs/guide.md#api).
|
|
64
78
|
|
package/dist/bun.js
CHANGED
|
@@ -156,7 +156,8 @@ function payloadHashOf(request, hexHash2) {
|
|
|
156
156
|
return request.unsignedPayload ? "UNSIGNED-PAYLOAD" : body.length === 0 ? emptyBodyHash : hexHash2(body);
|
|
157
157
|
}
|
|
158
158
|
function prepare(request, credentials, payloadHash, scheme = symmetric) {
|
|
159
|
-
const {
|
|
159
|
+
const { url, signHeaders } = request;
|
|
160
|
+
const method = request.method.toUpperCase();
|
|
160
161
|
const region = request.region ?? "";
|
|
161
162
|
const service = request.service ?? "s3";
|
|
162
163
|
const withHash = service === "s3" || request.unsignedPayload === true;
|
|
@@ -273,7 +274,8 @@ function createSigner({ hexHash: hexHash2, hmacHex, getSigningKey: getSigningKey
|
|
|
273
274
|
// src/presign.js
|
|
274
275
|
var encodeQueryPart = (text) => encodeURIComponent(text).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
|
|
275
276
|
function preparePresign(request, credentials, payloadHash, scheme = symmetric, extra = "") {
|
|
276
|
-
const {
|
|
277
|
+
const { url, signHeaders } = request;
|
|
278
|
+
const method = request.method.toUpperCase();
|
|
277
279
|
if (url.protocol !== "https:" && url.protocol !== "http:") throw new TypeError("presign needs an http or https URL");
|
|
278
280
|
if (url.search.length > 1 && (url.search.includes("X-Amz-Signature=") || url.search.includes("X-Amz-Algorithm="))) throw new TypeError("the URL is already presigned");
|
|
279
281
|
const expires = request.expires ?? 3600;
|
|
@@ -416,7 +418,11 @@ ${scope}
|
|
|
416
418
|
${previous}
|
|
417
419
|
${trailerHash}`;
|
|
418
420
|
function canonicalTrailers(trailers) {
|
|
419
|
-
return Object.keys(trailers).map((name) =>
|
|
421
|
+
return Object.keys(trailers).map((name) => {
|
|
422
|
+
const value = String(trailers[name]);
|
|
423
|
+
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) throw new TypeError("a trailer name or value cannot hold a line break");
|
|
424
|
+
return [name.toLowerCase(), value.trim()];
|
|
425
|
+
}).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0).map(([name, value]) => `${name}:${value}
|
|
420
426
|
`).join("");
|
|
421
427
|
}
|
|
422
428
|
function frameChunk(signature, data) {
|
|
@@ -449,9 +455,9 @@ function createChunkSigner({ hexHash: hexHash2, hmacHex, getSigningKey: getSigni
|
|
|
449
455
|
chunk: (data) => data.length === 0 ? data : frameChunk(sign(data), data),
|
|
450
456
|
// a zero-length chunk would end the upload; it is not sent
|
|
451
457
|
end(trailers) {
|
|
458
|
+
const text = trailers === void 0 ? void 0 : canonicalTrailers(trailers);
|
|
452
459
|
const signature = sign(new Uint8Array(0));
|
|
453
|
-
if (
|
|
454
|
-
const text = canonicalTrailers(trailers);
|
|
460
|
+
if (text === void 0) return frameEnd(signature);
|
|
455
461
|
const trailerSignature = hmacHex(key, trailerStringToSign(seed.datetime, seed.scope, signature, hexHash2(text)));
|
|
456
462
|
return frameEnd(signature, text, trailerSignature);
|
|
457
463
|
}
|
package/dist/node.js
CHANGED
|
@@ -156,7 +156,8 @@ function payloadHashOf(request, hexHash2) {
|
|
|
156
156
|
return request.unsignedPayload ? "UNSIGNED-PAYLOAD" : body.length === 0 ? emptyBodyHash : hexHash2(body);
|
|
157
157
|
}
|
|
158
158
|
function prepare(request, credentials, payloadHash, scheme = symmetric) {
|
|
159
|
-
const {
|
|
159
|
+
const { url, signHeaders } = request;
|
|
160
|
+
const method = request.method.toUpperCase();
|
|
160
161
|
const region = request.region ?? "";
|
|
161
162
|
const service = request.service ?? "s3";
|
|
162
163
|
const withHash = service === "s3" || request.unsignedPayload === true;
|
|
@@ -273,7 +274,8 @@ function createSigner({ hexHash: hexHash2, hmacHex: hmacHex2, getSigningKey: get
|
|
|
273
274
|
// src/presign.js
|
|
274
275
|
var encodeQueryPart = (text) => encodeURIComponent(text).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
|
|
275
276
|
function preparePresign(request, credentials, payloadHash, scheme = symmetric, extra = "") {
|
|
276
|
-
const {
|
|
277
|
+
const { url, signHeaders } = request;
|
|
278
|
+
const method = request.method.toUpperCase();
|
|
277
279
|
if (url.protocol !== "https:" && url.protocol !== "http:") throw new TypeError("presign needs an http or https URL");
|
|
278
280
|
if (url.search.length > 1 && (url.search.includes("X-Amz-Signature=") || url.search.includes("X-Amz-Algorithm="))) throw new TypeError("the URL is already presigned");
|
|
279
281
|
const expires = request.expires ?? 3600;
|
|
@@ -416,7 +418,11 @@ ${scope}
|
|
|
416
418
|
${previous}
|
|
417
419
|
${trailerHash}`;
|
|
418
420
|
function canonicalTrailers(trailers) {
|
|
419
|
-
return Object.keys(trailers).map((name) =>
|
|
421
|
+
return Object.keys(trailers).map((name) => {
|
|
422
|
+
const value = String(trailers[name]);
|
|
423
|
+
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) throw new TypeError("a trailer name or value cannot hold a line break");
|
|
424
|
+
return [name.toLowerCase(), value.trim()];
|
|
425
|
+
}).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0).map(([name, value]) => `${name}:${value}
|
|
420
426
|
`).join("");
|
|
421
427
|
}
|
|
422
428
|
function frameChunk(signature, data2) {
|
|
@@ -449,9 +455,9 @@ function createChunkSigner({ hexHash: hexHash2, hmacHex: hmacHex2, getSigningKey
|
|
|
449
455
|
chunk: (data2) => data2.length === 0 ? data2 : frameChunk(sign(data2), data2),
|
|
450
456
|
// a zero-length chunk would end the upload; it is not sent
|
|
451
457
|
end(trailers) {
|
|
458
|
+
const text = trailers === void 0 ? void 0 : canonicalTrailers(trailers);
|
|
452
459
|
const signature = sign(new Uint8Array(0));
|
|
453
|
-
if (
|
|
454
|
-
const text = canonicalTrailers(trailers);
|
|
460
|
+
if (text === void 0) return frameEnd(signature);
|
|
455
461
|
const trailerSignature = hmacHex2(key, trailerStringToSign(seed.datetime, seed.scope, signature, hexHash2(text)));
|
|
456
462
|
return frameEnd(signature, text, trailerSignature);
|
|
457
463
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sigflint",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"aws",
|
package/src/base.js
CHANGED
|
@@ -190,7 +190,7 @@ export function amzDatetime(now) {
|
|
|
190
190
|
|
|
191
191
|
/**
|
|
192
192
|
* @typedef {Object} SignRequest
|
|
193
|
-
* @property {string} method - GET, HEAD, PUT, POST, DELETE
|
|
193
|
+
* @property {string} method - GET, HEAD, PUT, POST, DELETE; any case, signed in upper case (as botocore does)
|
|
194
194
|
* @property {URL} url
|
|
195
195
|
* @property {string} [service] - default 's3'
|
|
196
196
|
* @property {string} [region] - for example us-west-2
|
|
@@ -227,7 +227,8 @@ export function payloadHashOf(request, hexHash) {
|
|
|
227
227
|
|
|
228
228
|
/** Everything up to the hashing: the headers to return, the canonical request and the credential scope. */
|
|
229
229
|
export function prepare(request, credentials, payloadHash, scheme = symmetric) {
|
|
230
|
-
const {
|
|
230
|
+
const { url, signHeaders } = request;
|
|
231
|
+
const method = request.method.toUpperCase(); // 'get' is signed as GET, which is what fetch sends and what botocore signs
|
|
231
232
|
const region = request.region ?? '';
|
|
232
233
|
const service = request.service ?? 's3';
|
|
233
234
|
// S3 always sends x-amz-content-sha256; other services only when the payload is unsigned
|
package/src/chunks.js
CHANGED
|
@@ -23,10 +23,14 @@ export function seedOf(authHeaders) {
|
|
|
23
23
|
export const chunkStringToSign = (datetime, scope, previous, payloadHash) => `AWS4-HMAC-SHA256-PAYLOAD\n${datetime}\n${scope}\n${previous}\n${emptyHash}\n${payloadHash}`;
|
|
24
24
|
export const trailerStringToSign = (datetime, scope, previous, trailerHash) => `AWS4-HMAC-SHA256-TRAILER\n${datetime}\n${scope}\n${previous}\n${trailerHash}`;
|
|
25
25
|
|
|
26
|
-
/** Trailing headers in canonical form: lowercase names in name order, each line ending in a newline. */
|
|
26
|
+
/** Trailing headers in canonical form: lowercase names in name order, each line ending in a newline. A line break in a name or value would start another trailer line, so it throws. */
|
|
27
27
|
export function canonicalTrailers(trailers) {
|
|
28
28
|
return Object.keys(trailers)
|
|
29
|
-
.map((name) =>
|
|
29
|
+
.map((name) => {
|
|
30
|
+
const value = String(trailers[name]);
|
|
31
|
+
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) throw new TypeError('a trailer name or value cannot hold a line break');
|
|
32
|
+
return [name.toLowerCase(), value.trim()];
|
|
33
|
+
})
|
|
30
34
|
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
31
35
|
.map(([name, value]) => `${name}:${value}\n`)
|
|
32
36
|
.join('');
|
|
@@ -71,9 +75,9 @@ export function createChunkSigner({ hexHash, hmacHex, getSigningKey }) {
|
|
|
71
75
|
return {
|
|
72
76
|
chunk: (data) => (data.length === 0 ? data : frameChunk(sign(data), data)), // a zero-length chunk would end the upload; it is not sent
|
|
73
77
|
end(trailers) {
|
|
78
|
+
const text = trailers === undefined ? undefined : canonicalTrailers(trailers); // before sign(), so a refused trailer leaves the chain where it was
|
|
74
79
|
const signature = sign(new Uint8Array(0));
|
|
75
|
-
if (
|
|
76
|
-
const text = canonicalTrailers(trailers);
|
|
80
|
+
if (text === undefined) return frameEnd(signature);
|
|
77
81
|
const trailerSignature = hmacHex(key, trailerStringToSign(seed.datetime, seed.scope, signature, hexHash(text)));
|
|
78
82
|
return frameEnd(signature, text, trailerSignature);
|
|
79
83
|
},
|
package/src/presign.js
CHANGED
|
@@ -8,7 +8,8 @@ export const encodeQueryPart = (text) => encodeURIComponent(text).replace(/[!'()
|
|
|
8
8
|
* signed; S3 URLs are always UNSIGNED-PAYLOAD, other services sign the body hash. `extra` is appended to the authentication parameters.
|
|
9
9
|
*/
|
|
10
10
|
export function preparePresign(request, credentials, payloadHash, scheme = symmetric, extra = '') {
|
|
11
|
-
const {
|
|
11
|
+
const { url, signHeaders } = request;
|
|
12
|
+
const method = request.method.toUpperCase();
|
|
12
13
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new TypeError('presign needs an http or https URL');
|
|
13
14
|
if (url.search.length > 1 && (url.search.includes('X-Amz-Signature=') || url.search.includes('X-Amz-Algorithm='))) throw new TypeError('the URL is already presigned');
|
|
14
15
|
const expires = request.expires ?? 3600;
|
package/src/web.js
CHANGED
|
@@ -153,9 +153,9 @@ export function chunkSigner(authHeaders, credentials) {
|
|
|
153
153
|
return {
|
|
154
154
|
chunk: async (data) => (data.length === 0 ? data : frameChunk(await sign(data), data)),
|
|
155
155
|
async end(trailers) {
|
|
156
|
+
const text = trailers === undefined ? undefined : canonicalTrailers(trailers); // before sign(), so a refused trailer leaves the chain where it was
|
|
156
157
|
const signature = await sign(new Uint8Array(0));
|
|
157
|
-
if (
|
|
158
|
-
const text = canonicalTrailers(trailers);
|
|
158
|
+
if (text === undefined) return frameEnd(signature);
|
|
159
159
|
return frameEnd(signature, text, await signWith(await keyPromise, trailerStringToSign(seed.datetime, seed.scope, signature, await hexHash(text))));
|
|
160
160
|
},
|
|
161
161
|
};
|
package/types/base.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export declare const headerValue: (value: any) => string;
|
|
|
20
20
|
export declare function amzDatetime(now: any): string;
|
|
21
21
|
export type SignRequest = {
|
|
22
22
|
/**
|
|
23
|
-
* - GET, HEAD, PUT, POST, DELETE
|
|
23
|
+
* - GET, HEAD, PUT, POST, DELETE; any case, signed in upper case (as botocore does)
|
|
24
24
|
*/
|
|
25
25
|
method: string;
|
|
26
26
|
url: URL;
|
|
@@ -86,7 +86,7 @@ export type Primitives = {
|
|
|
86
86
|
};
|
|
87
87
|
/**
|
|
88
88
|
* @typedef {Object} SignRequest
|
|
89
|
-
* @property {string} method - GET, HEAD, PUT, POST, DELETE
|
|
89
|
+
* @property {string} method - GET, HEAD, PUT, POST, DELETE; any case, signed in upper case (as botocore does)
|
|
90
90
|
* @property {URL} url
|
|
91
91
|
* @property {string} [service] - default 's3'
|
|
92
92
|
* @property {string} [region] - for example us-west-2
|
package/types/chunks.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export declare function seedOf(authHeaders: any): {
|
|
|
10
10
|
};
|
|
11
11
|
export declare const chunkStringToSign: (datetime: any, scope: any, previous: any, payloadHash: any) => string;
|
|
12
12
|
export declare const trailerStringToSign: (datetime: any, scope: any, previous: any, trailerHash: any) => string;
|
|
13
|
-
/** Trailing headers in canonical form: lowercase names in name order, each line ending in a newline. */
|
|
13
|
+
/** Trailing headers in canonical form: lowercase names in name order, each line ending in a newline. A line break in a name or value would start another trailer line, so it throws. */
|
|
14
14
|
export declare function canonicalTrailers(trailers: any): string;
|
|
15
15
|
/** The bytes of one chunk on the wire: size in hex, signature, the data. */
|
|
16
16
|
export declare function frameChunk(signature: any, data: any): Uint8Array<any>;
|