@crewhaus/tool-objectstore 0.7.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/README.md +190 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +256 -0
- package/dist/lib/clock.d.ts +29 -0
- package/dist/lib/clock.js +35 -0
- package/dist/lib/encode.d.ts +62 -0
- package/dist/lib/encode.js +106 -0
- package/dist/lib/request.d.ts +73 -0
- package/dist/lib/request.js +118 -0
- package/dist/lib/sigv4.d.ts +132 -0
- package/dist/lib/sigv4.js +203 -0
- package/dist/lib/store.d.ts +89 -0
- package/dist/lib/store.js +265 -0
- package/package.json +46 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Percent-encoding, SigV4's way — which is not `encodeURIComponent`'s way.
|
|
3
|
+
*
|
|
4
|
+
* This is the module that decides whether a signature verifies, and it is the
|
|
5
|
+
* one place in the package where being nearly right is worse than being
|
|
6
|
+
* obviously wrong: a mis-encoded byte produces a well-formed URL that 403s
|
|
7
|
+
* with `SignatureDoesNotMatch` and no indication of which character
|
|
8
|
+
* disagreed, at whatever hour the URL is eventually used.
|
|
9
|
+
*
|
|
10
|
+
* `encodeURIComponent` is the trap. It leaves `!`, `'`, `(`, `)` and `*`
|
|
11
|
+
* unencoded; AWS's canonical form encodes all five. So a key like
|
|
12
|
+
* `q&a (2026)/notes!.txt` signs one way and is requested another. It also
|
|
13
|
+
* lowercases nothing and uppercases nothing — it happens to emit uppercase
|
|
14
|
+
* hex, which is right — but the unreserved set is the part that differs, and
|
|
15
|
+
* that difference is silent.
|
|
16
|
+
*
|
|
17
|
+
* The rule, from RFC 3986 §2.3 as SigV4 cites it: `A-Z a-z 0-9 - _ . ~` pass
|
|
18
|
+
* through unchanged; every other byte of the UTF-8 encoding becomes `%XX`
|
|
19
|
+
* with UPPERCASE hex digits. No exceptions, no locale, no normalization.
|
|
20
|
+
*/
|
|
21
|
+
const UTF8 = new TextEncoder();
|
|
22
|
+
/**
|
|
23
|
+
* The unreserved set as a byte table. A table rather than a regex because the
|
|
24
|
+
* question is asked per BYTE of the UTF-8 encoding, not per character — `é`
|
|
25
|
+
* is two bytes and neither of them is unreserved, and a regex over characters
|
|
26
|
+
* invites exactly the reasoning error that lets a non-ASCII key through
|
|
27
|
+
* half-encoded.
|
|
28
|
+
*/
|
|
29
|
+
const UNRESERVED = (() => {
|
|
30
|
+
const table = new Array(128).fill(false);
|
|
31
|
+
const mark = (from, to) => {
|
|
32
|
+
for (let c = from.charCodeAt(0); c <= to.charCodeAt(0); c++)
|
|
33
|
+
table[c] = true;
|
|
34
|
+
};
|
|
35
|
+
mark("A", "Z");
|
|
36
|
+
mark("a", "z");
|
|
37
|
+
mark("0", "9");
|
|
38
|
+
for (const ch of "-_.~")
|
|
39
|
+
table[ch.charCodeAt(0)] = true;
|
|
40
|
+
return table;
|
|
41
|
+
})();
|
|
42
|
+
const HEX = "0123456789ABCDEF";
|
|
43
|
+
/**
|
|
44
|
+
* Percent-encode one component the way SigV4 requires.
|
|
45
|
+
*
|
|
46
|
+
* Every byte outside the unreserved set is encoded, `/` included — this is
|
|
47
|
+
* the function for query names, query values and single path SEGMENTS. Use
|
|
48
|
+
* {@link encodePath} for a whole object key, which keeps `/` as a separator.
|
|
49
|
+
*/
|
|
50
|
+
export function encodeRfc3986(value) {
|
|
51
|
+
let out = "";
|
|
52
|
+
for (const byte of UTF8.encode(value)) {
|
|
53
|
+
if (byte < 0x80 && UNRESERVED[byte] === true) {
|
|
54
|
+
out += String.fromCharCode(byte);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
out += `%${HEX[byte >> 4]}${HEX[byte & 0x0f]}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Percent-encode an object key as a canonical URI path: each segment encoded,
|
|
64
|
+
* the `/` separators left alone.
|
|
65
|
+
*
|
|
66
|
+
* S3 encodes the path ONCE. Every other AWS service encodes the canonical URI
|
|
67
|
+
* twice (the literal `%` of an already-encoded byte is itself encoded), which
|
|
68
|
+
* is why `doubleEncode` exists here rather than being assumed away: this
|
|
69
|
+
* module is the shared SigV4 primitive for the monorepo, and the next caller
|
|
70
|
+
* will not be S3. Getting it backwards for S3 turns `a b.txt` into
|
|
71
|
+
* `a%2520b.txt` — a signature over a path nobody will ever request.
|
|
72
|
+
*/
|
|
73
|
+
export function encodePath(path, options) {
|
|
74
|
+
const once = path.split("/").map(encodeRfc3986).join("/");
|
|
75
|
+
return options?.doubleEncode === true ? once.split("/").map(encodeRfc3986).join("/") : once;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build the canonical query string: encode first, then sort by byte order.
|
|
79
|
+
*
|
|
80
|
+
* The order matters and the order of the two OPERATIONS matters more. Sorting
|
|
81
|
+
* raw names and then encoding them gives a different sequence than encoding
|
|
82
|
+
* and then sorting, because encoding moves characters around the ASCII table
|
|
83
|
+
* (`~` is 0x7E raw, `%7E`-shaped forms sort near `%`). AWS specifies
|
|
84
|
+
* encode-then-sort, so that is what happens here.
|
|
85
|
+
*
|
|
86
|
+
* After encoding every byte is ASCII, so JavaScript's `<` on the encoded
|
|
87
|
+
* strings IS a byte comparison; that equivalence is why this can use plain
|
|
88
|
+
* string comparison rather than comparing `Uint8Array`s.
|
|
89
|
+
*
|
|
90
|
+
* Duplicate names are ordered by their encoded value, as the spec says.
|
|
91
|
+
*/
|
|
92
|
+
export function canonicalQuery(params) {
|
|
93
|
+
const encoded = params.map(([name, value]) => [encodeRfc3986(name), encodeRfc3986(value)]);
|
|
94
|
+
encoded.sort((a, b) => {
|
|
95
|
+
if (a[0] !== b[0])
|
|
96
|
+
return a[0] < b[0] ? -1 : 1;
|
|
97
|
+
if (a[1] !== b[1])
|
|
98
|
+
return a[1] < b[1] ? -1 : 1;
|
|
99
|
+
return 0;
|
|
100
|
+
});
|
|
101
|
+
return encoded.map(([name, value]) => `${name}=${value}`).join("&");
|
|
102
|
+
}
|
|
103
|
+
/** UTF-8 byte length — S3's key limit is 1024 BYTES, not 1024 characters. */
|
|
104
|
+
export function utf8Length(value) {
|
|
105
|
+
return UTF8.encode(value).length;
|
|
106
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What else gets signed, besides the path: the headers pinned into a PUT and
|
|
3
|
+
* the query overrides pinned into a GET.
|
|
4
|
+
*
|
|
5
|
+
* These two look symmetrical and are not, and the asymmetry is the whole
|
|
6
|
+
* reason this module exists:
|
|
7
|
+
*
|
|
8
|
+
* - A PUT pins `content-type` (and any `x-amz-meta-*`) as a signed HEADER.
|
|
9
|
+
* It therefore lands in `SignedHeaders`, and the eventual upload must
|
|
10
|
+
* send that header with that exact value or the store answers 403. The
|
|
11
|
+
* tool that mints the URL is the only thing that knows the value, so it
|
|
12
|
+
* hands it back and says to send it verbatim.
|
|
13
|
+
* - A GET pins its overrides as signed QUERY parameters. They are already
|
|
14
|
+
* in the URL, so the caller sends them by doing nothing at all.
|
|
15
|
+
*
|
|
16
|
+
* Mixing the two — a `content-type` on a GET — is refused rather than
|
|
17
|
+
* quietly dropped or quietly signed, because both of those produce a URL that
|
|
18
|
+
* fails much later for a reason that does not mention content types.
|
|
19
|
+
*/
|
|
20
|
+
import type { QueryParam } from "./encode";
|
|
21
|
+
import { type Header } from "./sigv4";
|
|
22
|
+
export declare const OPERATIONS: readonly ["get", "put"];
|
|
23
|
+
export type Operation = (typeof OPERATIONS)[number];
|
|
24
|
+
/** S3's ceiling on user metadata, counted over the names and values together. */
|
|
25
|
+
export declare const MAX_METADATA_BYTES = 2048;
|
|
26
|
+
/**
|
|
27
|
+
* Refuse a header value this package cannot sign honestly.
|
|
28
|
+
*
|
|
29
|
+
* A CR or LF is a request-splitting attempt or a stray paste; neither belongs
|
|
30
|
+
* in a signature. The second check is subtler: SigV4 canonicalization trims
|
|
31
|
+
* the ends of a header value and collapses internal whitespace runs, so a
|
|
32
|
+
* value with a doubled space is signed in a form the caller did not write.
|
|
33
|
+
* The server applies the same reduction and it would in fact verify — but the
|
|
34
|
+
* tool's promise is "send this back byte for byte", and a value that is not
|
|
35
|
+
* its own canonical form makes that promise false. The spec's quoted-string
|
|
36
|
+
* exemption lives in the same gap. Refusing is one sentence; explaining the
|
|
37
|
+
* exemption to whoever hits the 403 is not.
|
|
38
|
+
*/
|
|
39
|
+
export declare function checkHeaderValue(name: string, value: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Turn `{ owner: "ops" }` into `[["x-amz-meta-owner", "ops"]]`.
|
|
42
|
+
*
|
|
43
|
+
* Names are lowercased here because S3 lowercases them on arrival: signing
|
|
44
|
+
* `X-Amz-Meta-Owner` and receiving `x-amz-meta-owner` is a mismatch the
|
|
45
|
+
* caller cannot see and cannot fix.
|
|
46
|
+
*/
|
|
47
|
+
export declare function metadataHeaders(metadata: Record<string, string>): Header[];
|
|
48
|
+
export type Overrides = {
|
|
49
|
+
responseContentType?: string;
|
|
50
|
+
responseContentDisposition?: string;
|
|
51
|
+
responseCacheControl?: string;
|
|
52
|
+
versionId?: string;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The GET-side pins, as signed query parameters.
|
|
56
|
+
*
|
|
57
|
+
* `response-content-disposition` is the reason this exists: it is how a
|
|
58
|
+
* presigned link downloads as `invoice-2026-01.pdf` instead of as the key,
|
|
59
|
+
* and it only works if it is signed in — adding it to a finished URL
|
|
60
|
+
* invalidates the signature.
|
|
61
|
+
*/
|
|
62
|
+
export declare function responseOverrides(overrides: Overrides): QueryParam[];
|
|
63
|
+
/**
|
|
64
|
+
* Refuse the cross-operation mistakes before anything is signed.
|
|
65
|
+
*
|
|
66
|
+
* Each of these would otherwise produce a URL that looks correct and fails
|
|
67
|
+
* for a reason the 403 does not name.
|
|
68
|
+
*/
|
|
69
|
+
export declare function checkOperationFields(operation: Operation, fields: {
|
|
70
|
+
contentType?: string;
|
|
71
|
+
metadata?: Record<string, string>;
|
|
72
|
+
overrides: Overrides;
|
|
73
|
+
}): void;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { canonicalHeaderValue } from "./sigv4";
|
|
2
|
+
import { PresignError } from "./store";
|
|
3
|
+
export const OPERATIONS = ["get", "put"];
|
|
4
|
+
/** S3's ceiling on user metadata, counted over the names and values together. */
|
|
5
|
+
export const MAX_METADATA_BYTES = 2048;
|
|
6
|
+
/**
|
|
7
|
+
* Refuse a header value this package cannot sign honestly.
|
|
8
|
+
*
|
|
9
|
+
* A CR or LF is a request-splitting attempt or a stray paste; neither belongs
|
|
10
|
+
* in a signature. The second check is subtler: SigV4 canonicalization trims
|
|
11
|
+
* the ends of a header value and collapses internal whitespace runs, so a
|
|
12
|
+
* value with a doubled space is signed in a form the caller did not write.
|
|
13
|
+
* The server applies the same reduction and it would in fact verify — but the
|
|
14
|
+
* tool's promise is "send this back byte for byte", and a value that is not
|
|
15
|
+
* its own canonical form makes that promise false. The spec's quoted-string
|
|
16
|
+
* exemption lives in the same gap. Refusing is one sentence; explaining the
|
|
17
|
+
* exemption to whoever hits the 403 is not.
|
|
18
|
+
*/
|
|
19
|
+
export function checkHeaderValue(name, value) {
|
|
20
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: refusing C0 controls is the point.
|
|
21
|
+
if (/[\x00-\x1f\x7f]/.test(value)) {
|
|
22
|
+
throw new PresignError(`${name} contains a control character (a CR, LF or tab). A header value that is signed here and sent later must be one line of printable text`);
|
|
23
|
+
}
|
|
24
|
+
if (canonicalHeaderValue(value) !== value) {
|
|
25
|
+
throw new PresignError(`${name} "${value}" has leading, trailing or repeated whitespace. SigV4 signs the whitespace-collapsed form, so the value handed back would not be the value signed — pass "${canonicalHeaderValue(value)}" if that is what you meant`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** A metadata name must be an HTTP token; S3 lowercases it on the way in. */
|
|
29
|
+
const TOKEN = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
30
|
+
/**
|
|
31
|
+
* Turn `{ owner: "ops" }` into `[["x-amz-meta-owner", "ops"]]`.
|
|
32
|
+
*
|
|
33
|
+
* Names are lowercased here because S3 lowercases them on arrival: signing
|
|
34
|
+
* `X-Amz-Meta-Owner` and receiving `x-amz-meta-owner` is a mismatch the
|
|
35
|
+
* caller cannot see and cannot fix.
|
|
36
|
+
*/
|
|
37
|
+
export function metadataHeaders(metadata) {
|
|
38
|
+
const headers = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
let bytes = 0;
|
|
41
|
+
for (const [rawName, value] of Object.entries(metadata)) {
|
|
42
|
+
if (!TOKEN.test(rawName)) {
|
|
43
|
+
throw new PresignError(`metadata name "${rawName}" is not a valid HTTP header token — use letters, digits and -_.`);
|
|
44
|
+
}
|
|
45
|
+
if (rawName.toLowerCase().startsWith("x-amz-meta-")) {
|
|
46
|
+
throw new PresignError(`metadata name "${rawName}" already carries the x-amz-meta- prefix; pass the bare name ("${rawName.slice("x-amz-meta-".length)}") and the prefix is added here`);
|
|
47
|
+
}
|
|
48
|
+
const name = `x-amz-meta-${rawName.toLowerCase()}`;
|
|
49
|
+
if (seen.has(name)) {
|
|
50
|
+
throw new PresignError(`metadata names "${rawName}" and another differ only in case; S3 lowercases them, so one would silently overwrite the other`);
|
|
51
|
+
}
|
|
52
|
+
seen.add(name);
|
|
53
|
+
if (!/^[\x20-\x7e]*$/.test(value)) {
|
|
54
|
+
throw new PresignError(`metadata value for "${rawName}" is not printable US-ASCII. S3 carries user metadata in HTTP headers, which cannot hold it as-is — encode it yourself (base64, or RFC 2047) so that what is signed is what is sent`);
|
|
55
|
+
}
|
|
56
|
+
checkHeaderValue(`metadata value for "${rawName}"`, value);
|
|
57
|
+
bytes += name.length + value.length;
|
|
58
|
+
headers.push([name, value]);
|
|
59
|
+
}
|
|
60
|
+
if (bytes > MAX_METADATA_BYTES) {
|
|
61
|
+
throw new PresignError(`user metadata is ${bytes} bytes and S3's limit is ${MAX_METADATA_BYTES}; the store would reject the upload after the bytes were sent`);
|
|
62
|
+
}
|
|
63
|
+
return headers;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The GET-side pins, as signed query parameters.
|
|
67
|
+
*
|
|
68
|
+
* `response-content-disposition` is the reason this exists: it is how a
|
|
69
|
+
* presigned link downloads as `invoice-2026-01.pdf` instead of as the key,
|
|
70
|
+
* and it only works if it is signed in — adding it to a finished URL
|
|
71
|
+
* invalidates the signature.
|
|
72
|
+
*/
|
|
73
|
+
export function responseOverrides(overrides) {
|
|
74
|
+
const query = [];
|
|
75
|
+
const push = (param, value) => {
|
|
76
|
+
if (value === undefined)
|
|
77
|
+
return;
|
|
78
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: refusing C0 controls is the point.
|
|
79
|
+
if (/[\x00-\x1f\x7f]/.test(value)) {
|
|
80
|
+
throw new PresignError(`${param} contains a control character`);
|
|
81
|
+
}
|
|
82
|
+
query.push([param, value]);
|
|
83
|
+
};
|
|
84
|
+
push("response-content-type", overrides.responseContentType);
|
|
85
|
+
push("response-content-disposition", overrides.responseContentDisposition);
|
|
86
|
+
push("response-cache-control", overrides.responseCacheControl);
|
|
87
|
+
push("versionId", overrides.versionId);
|
|
88
|
+
return query;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Refuse the cross-operation mistakes before anything is signed.
|
|
92
|
+
*
|
|
93
|
+
* Each of these would otherwise produce a URL that looks correct and fails
|
|
94
|
+
* for a reason the 403 does not name.
|
|
95
|
+
*/
|
|
96
|
+
export function checkOperationFields(operation, fields) {
|
|
97
|
+
const named = (name) => {
|
|
98
|
+
throw new PresignError(operation === "get"
|
|
99
|
+
? `${name} applies to an upload, not a download. A GET cannot pin a request header the browser will not send; use responseContentType or responseContentDisposition, which are signed into the URL itself`
|
|
100
|
+
: `${name} applies to a download, not an upload: the response-* overrides tell S3 how to answer a GET. For a PUT, pass contentType and metadata, which are signed as request headers`);
|
|
101
|
+
};
|
|
102
|
+
if (operation === "get") {
|
|
103
|
+
if (fields.contentType !== undefined)
|
|
104
|
+
named("contentType");
|
|
105
|
+
if (fields.metadata !== undefined)
|
|
106
|
+
named("metadata");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (fields.overrides.responseContentType !== undefined)
|
|
110
|
+
named("responseContentType");
|
|
111
|
+
if (fields.overrides.responseContentDisposition !== undefined)
|
|
112
|
+
named("responseContentDisposition");
|
|
113
|
+
if (fields.overrides.responseCacheControl !== undefined)
|
|
114
|
+
named("responseCacheControl");
|
|
115
|
+
if (fields.overrides.versionId !== undefined) {
|
|
116
|
+
throw new PresignError("versionId identifies an existing version to read; a PUT creates a new one, and the store assigns its id");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { type QueryParam } from "./encode";
|
|
2
|
+
export declare const ALGORITHM = "AWS4-HMAC-SHA256";
|
|
3
|
+
/**
|
|
4
|
+
* The payload hash a presigned URL carries. The body does not exist yet when
|
|
5
|
+
* the URL is minted — that is the entire point of presigning — so S3 accepts
|
|
6
|
+
* this sentinel in place of a digest. Writing the SHA-256 of the empty string
|
|
7
|
+
* here instead would sign "this PUT has no body", and every real upload would
|
|
8
|
+
* then 403.
|
|
9
|
+
*/
|
|
10
|
+
export declare const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
|
|
11
|
+
/** A header as it will be signed: `[name, value]`, name in any case. */
|
|
12
|
+
export type Header = readonly [string, string];
|
|
13
|
+
/** Lowercase-hex SHA-256 of a string, the form every step of SigV4 wants. */
|
|
14
|
+
export declare function sha256Hex(value: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Derive the signing key: HMAC down the scope, starting from `AWS4` + secret.
|
|
17
|
+
*
|
|
18
|
+
* `dateStamp` is `YYYYMMDD` in UTC, and it must be the same day as the
|
|
19
|
+
* timestamp in the string to sign. A key derived from yesterday's date with
|
|
20
|
+
* today's timestamp verifies against nothing, and the error says only
|
|
21
|
+
* `SignatureDoesNotMatch`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function signingKey(secretAccessKey: string, dateStamp: string, region: string, service: string): Buffer;
|
|
24
|
+
/** `20130524T000000Z` and `20130524` for an instant, always UTC. */
|
|
25
|
+
export declare function amzTimestamps(epochMs: number): {
|
|
26
|
+
amzDate: string;
|
|
27
|
+
dateStamp: string;
|
|
28
|
+
};
|
|
29
|
+
/** `20130524/us-east-1/s3/aws4_request` — the credential scope. */
|
|
30
|
+
export declare function credentialScope(dateStamp: string, region: string, service: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Canonicalize one header value: trim the ends, collapse internal runs of
|
|
33
|
+
* whitespace to a single space.
|
|
34
|
+
*
|
|
35
|
+
* The server applies this same reduction to what it receives, so a value with
|
|
36
|
+
* doubled spaces still verifies — the collapse is not a divergence. The
|
|
37
|
+
* divergence to fear is a DIFFERENT value: an HTTP client that appends
|
|
38
|
+
* `; charset=utf-8` to a bare `text/plain` changes the signed bytes, and no
|
|
39
|
+
* canonicalization saves it. That is why the caller is handed back the exact
|
|
40
|
+
* value that was signed.
|
|
41
|
+
*
|
|
42
|
+
* Known limit, stated rather than hidden: the spec exempts whitespace inside
|
|
43
|
+
* a quoted string from the collapse. This does not implement that exemption,
|
|
44
|
+
* so a value like `attachment; filename="a b"` would be canonicalized by
|
|
45
|
+
* this code and (arguably) not by the server. Such values are refused
|
|
46
|
+
* upstream in `headers.ts` rather than signed on a guess.
|
|
47
|
+
*/
|
|
48
|
+
export declare function canonicalHeaderValue(value: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* The `SignedHeaders` value: lowercase names, sorted, semicolon-joined.
|
|
51
|
+
*
|
|
52
|
+
* It appears twice in a presigned URL — inside the canonical request and as
|
|
53
|
+
* the `X-Amz-SignedHeaders` query parameter — and the two must agree, so both
|
|
54
|
+
* come from here rather than from two list comprehensions that drift.
|
|
55
|
+
*/
|
|
56
|
+
export declare function signedHeaderList(headers: ReadonlyArray<Header>): string;
|
|
57
|
+
export type CanonicalRequest = {
|
|
58
|
+
/** The canonical request text, newline-joined, exactly as it is hashed. */
|
|
59
|
+
text: string;
|
|
60
|
+
/** `content-type;host;x-amz-meta-owner` — semicolon-joined, sorted. */
|
|
61
|
+
signedHeaders: string;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Assemble the canonical request.
|
|
65
|
+
*
|
|
66
|
+
* `canonicalUri` arrives already percent-encoded (see `encode.ts`): this
|
|
67
|
+
* function must not encode it again, because whether the path is encoded once
|
|
68
|
+
* or twice is a per-service fact the caller owns and S3's answer is "once".
|
|
69
|
+
*/
|
|
70
|
+
export declare function canonicalRequest(input: {
|
|
71
|
+
method: string;
|
|
72
|
+
canonicalUri: string;
|
|
73
|
+
query: ReadonlyArray<QueryParam>;
|
|
74
|
+
headers: ReadonlyArray<Header>;
|
|
75
|
+
payloadHash: string;
|
|
76
|
+
}): CanonicalRequest;
|
|
77
|
+
/** The string to sign: algorithm, timestamp, scope, hash of the canonical request. */
|
|
78
|
+
export declare function stringToSign(input: {
|
|
79
|
+
amzDate: string;
|
|
80
|
+
scope: string;
|
|
81
|
+
canonicalRequest: string;
|
|
82
|
+
}): string;
|
|
83
|
+
/** The signature itself: lowercase hex, 64 characters. */
|
|
84
|
+
export declare function sign(input: {
|
|
85
|
+
secretAccessKey: string;
|
|
86
|
+
dateStamp: string;
|
|
87
|
+
region: string;
|
|
88
|
+
service: string;
|
|
89
|
+
stringToSign: string;
|
|
90
|
+
}): string;
|
|
91
|
+
export type PresignInput = {
|
|
92
|
+
method: "GET" | "PUT";
|
|
93
|
+
/** `https://bucket.s3.amazonaws.com` — scheme and authority, no path. */
|
|
94
|
+
origin: string;
|
|
95
|
+
/** The exact Host header value, port included only when non-default. */
|
|
96
|
+
host: string;
|
|
97
|
+
/** Already percent-encoded, always starting with `/`. */
|
|
98
|
+
canonicalUri: string;
|
|
99
|
+
region: string;
|
|
100
|
+
service: string;
|
|
101
|
+
accessKeyId: string;
|
|
102
|
+
secretAccessKey: string;
|
|
103
|
+
sessionToken?: string;
|
|
104
|
+
epochMs: number;
|
|
105
|
+
expiresInSeconds: number;
|
|
106
|
+
/** Extra query parameters to sign, un-encoded. */
|
|
107
|
+
query?: ReadonlyArray<QueryParam>;
|
|
108
|
+
/** Headers to sign beyond `host`, un-canonicalized. */
|
|
109
|
+
headers?: ReadonlyArray<Header>;
|
|
110
|
+
};
|
|
111
|
+
export type PresignResult = {
|
|
112
|
+
url: string;
|
|
113
|
+
amzDate: string;
|
|
114
|
+
/** Sorted, semicolon-joined — what the eventual request must send. */
|
|
115
|
+
signedHeaders: string;
|
|
116
|
+
canonicalRequest: string;
|
|
117
|
+
stringToSign: string;
|
|
118
|
+
signature: string;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Mint a presigned URL: query-string authentication, SigV4.
|
|
122
|
+
*
|
|
123
|
+
* Two facts about query-string auth that the header-based form does not share,
|
|
124
|
+
* and that are easy to get wrong by analogy:
|
|
125
|
+
*
|
|
126
|
+
* - the session token travels as `X-Amz-Security-Token` in the QUERY and is
|
|
127
|
+
* signed there, not as a header;
|
|
128
|
+
* - `X-Amz-Signature` itself is the one parameter excluded from the
|
|
129
|
+
* canonical query string, because it does not exist yet when the canonical
|
|
130
|
+
* request is built. It is appended to the URL afterwards.
|
|
131
|
+
*/
|
|
132
|
+
export declare function presign(input: PresignInput): PresignResult;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS Signature Version 4, as arithmetic.
|
|
3
|
+
*
|
|
4
|
+
* Nothing in this file reaches a network, opens a socket or keeps state. It
|
|
5
|
+
* takes a request description and a secret, and returns bytes. The secret is
|
|
6
|
+
* used to derive the signing key and is referenced nowhere else — not in the
|
|
7
|
+
* result, not in an error message, not in a log line — because a signing
|
|
8
|
+
* routine that puts its key into a thrown error has published it to every
|
|
9
|
+
* transcript that ever catches one.
|
|
10
|
+
*
|
|
11
|
+
* The chain, per AWS's "Signing AWS API requests":
|
|
12
|
+
*
|
|
13
|
+
* 1. canonical request — method, URI, query, headers, signed-header list,
|
|
14
|
+
* payload hash, joined by newlines
|
|
15
|
+
* 2. string to sign — algorithm, timestamp, credential scope, and the
|
|
16
|
+
* SHA-256 of (1)
|
|
17
|
+
* 3. signing key — HMAC-SHA256 chained through date, region, service
|
|
18
|
+
* and the literal `aws4_request`
|
|
19
|
+
* 4. signature — HMAC-SHA256 of (2) under (3), lowercase hex
|
|
20
|
+
*
|
|
21
|
+
* Step 3 is why a leaked signature is not a leaked credential: it is scoped to
|
|
22
|
+
* one day, one region and one service, and it cannot be run backwards.
|
|
23
|
+
*/
|
|
24
|
+
import { createHash, createHmac } from "node:crypto";
|
|
25
|
+
import { canonicalQuery } from "./encode";
|
|
26
|
+
export const ALGORITHM = "AWS4-HMAC-SHA256";
|
|
27
|
+
/**
|
|
28
|
+
* The payload hash a presigned URL carries. The body does not exist yet when
|
|
29
|
+
* the URL is minted — that is the entire point of presigning — so S3 accepts
|
|
30
|
+
* this sentinel in place of a digest. Writing the SHA-256 of the empty string
|
|
31
|
+
* here instead would sign "this PUT has no body", and every real upload would
|
|
32
|
+
* then 403.
|
|
33
|
+
*/
|
|
34
|
+
export const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
|
|
35
|
+
/** Lowercase-hex SHA-256 of a string, the form every step of SigV4 wants. */
|
|
36
|
+
export function sha256Hex(value) {
|
|
37
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
38
|
+
}
|
|
39
|
+
function hmac(key, data) {
|
|
40
|
+
return createHmac("sha256", key).update(data, "utf8").digest();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Derive the signing key: HMAC down the scope, starting from `AWS4` + secret.
|
|
44
|
+
*
|
|
45
|
+
* `dateStamp` is `YYYYMMDD` in UTC, and it must be the same day as the
|
|
46
|
+
* timestamp in the string to sign. A key derived from yesterday's date with
|
|
47
|
+
* today's timestamp verifies against nothing, and the error says only
|
|
48
|
+
* `SignatureDoesNotMatch`.
|
|
49
|
+
*/
|
|
50
|
+
export function signingKey(secretAccessKey, dateStamp, region, service) {
|
|
51
|
+
const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp);
|
|
52
|
+
const kRegion = hmac(kDate, region);
|
|
53
|
+
const kService = hmac(kRegion, service);
|
|
54
|
+
return hmac(kService, "aws4_request");
|
|
55
|
+
}
|
|
56
|
+
/** `20130524T000000Z` and `20130524` for an instant, always UTC. */
|
|
57
|
+
export function amzTimestamps(epochMs) {
|
|
58
|
+
if (!Number.isFinite(epochMs))
|
|
59
|
+
throw new Error("the signing instant is not a finite number");
|
|
60
|
+
// toISOString throws RangeError outside ±8.64e15; let it, rather than
|
|
61
|
+
// emitting "InvalidDate" into a signature.
|
|
62
|
+
const iso = new Date(epochMs).toISOString();
|
|
63
|
+
// Sub-second precision is dropped rather than rounded: X-Amz-Date has
|
|
64
|
+
// second resolution, and rounding up could place the timestamp in the
|
|
65
|
+
// future for a store that enforces clock skew strictly.
|
|
66
|
+
const amzDate = `${iso.slice(0, 19).replace(/[-:]/g, "")}Z`;
|
|
67
|
+
if (!/^\d{8}T\d{6}Z$/.test(amzDate)) {
|
|
68
|
+
// The finite check above is not enough, and the RangeError above does not
|
|
69
|
+
// cover this. `toISOString` only throws outside ±8.64e15; for the years
|
|
70
|
+
// in between that do not fit four digits it switches to the EXPANDED
|
|
71
|
+
// form, `+010000-01-01T00:00:00.000Z`. Slicing that yields an X-Amz-Date
|
|
72
|
+
// of `+0100000101T0000Z` and a credential scope of `+0100000/...` — a URL
|
|
73
|
+
// that is well-formed, correctly signed over nonsense, and refused by
|
|
74
|
+
// every store on earth with the reason nowhere in sight. Refuse the
|
|
75
|
+
// instant instead of signing it.
|
|
76
|
+
throw new Error(`the signing instant (${iso}) is outside the years 0000-9999 that X-Amz-Date can express`);
|
|
77
|
+
}
|
|
78
|
+
return { amzDate, dateStamp: amzDate.slice(0, 8) };
|
|
79
|
+
}
|
|
80
|
+
/** `20130524/us-east-1/s3/aws4_request` — the credential scope. */
|
|
81
|
+
export function credentialScope(dateStamp, region, service) {
|
|
82
|
+
return `${dateStamp}/${region}/${service}/aws4_request`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Canonicalize one header value: trim the ends, collapse internal runs of
|
|
86
|
+
* whitespace to a single space.
|
|
87
|
+
*
|
|
88
|
+
* The server applies this same reduction to what it receives, so a value with
|
|
89
|
+
* doubled spaces still verifies — the collapse is not a divergence. The
|
|
90
|
+
* divergence to fear is a DIFFERENT value: an HTTP client that appends
|
|
91
|
+
* `; charset=utf-8` to a bare `text/plain` changes the signed bytes, and no
|
|
92
|
+
* canonicalization saves it. That is why the caller is handed back the exact
|
|
93
|
+
* value that was signed.
|
|
94
|
+
*
|
|
95
|
+
* Known limit, stated rather than hidden: the spec exempts whitespace inside
|
|
96
|
+
* a quoted string from the collapse. This does not implement that exemption,
|
|
97
|
+
* so a value like `attachment; filename="a b"` would be canonicalized by
|
|
98
|
+
* this code and (arguably) not by the server. Such values are refused
|
|
99
|
+
* upstream in `headers.ts` rather than signed on a guess.
|
|
100
|
+
*/
|
|
101
|
+
export function canonicalHeaderValue(value) {
|
|
102
|
+
return value.trim().replace(/[ \t]+/g, " ");
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The `SignedHeaders` value: lowercase names, sorted, semicolon-joined.
|
|
106
|
+
*
|
|
107
|
+
* It appears twice in a presigned URL — inside the canonical request and as
|
|
108
|
+
* the `X-Amz-SignedHeaders` query parameter — and the two must agree, so both
|
|
109
|
+
* come from here rather than from two list comprehensions that drift.
|
|
110
|
+
*/
|
|
111
|
+
export function signedHeaderList(headers) {
|
|
112
|
+
return headers
|
|
113
|
+
.map(([name]) => name.toLowerCase())
|
|
114
|
+
.sort()
|
|
115
|
+
.join(";");
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Assemble the canonical request.
|
|
119
|
+
*
|
|
120
|
+
* `canonicalUri` arrives already percent-encoded (see `encode.ts`): this
|
|
121
|
+
* function must not encode it again, because whether the path is encoded once
|
|
122
|
+
* or twice is a per-service fact the caller owns and S3's answer is "once".
|
|
123
|
+
*/
|
|
124
|
+
export function canonicalRequest(input) {
|
|
125
|
+
const lowered = input.headers.map(([name, value]) => [name.toLowerCase(), canonicalHeaderValue(value)]);
|
|
126
|
+
lowered.sort((a, b) => (a[0] === b[0] ? 0 : a[0] < b[0] ? -1 : 1));
|
|
127
|
+
const canonicalHeaders = lowered.map(([name, value]) => `${name}:${value}\n`).join("");
|
|
128
|
+
const signedHeaders = lowered.map(([name]) => name).join(";");
|
|
129
|
+
const text = [
|
|
130
|
+
input.method,
|
|
131
|
+
input.canonicalUri,
|
|
132
|
+
canonicalQuery(input.query),
|
|
133
|
+
canonicalHeaders,
|
|
134
|
+
signedHeaders,
|
|
135
|
+
input.payloadHash,
|
|
136
|
+
].join("\n");
|
|
137
|
+
return { text, signedHeaders };
|
|
138
|
+
}
|
|
139
|
+
/** The string to sign: algorithm, timestamp, scope, hash of the canonical request. */
|
|
140
|
+
export function stringToSign(input) {
|
|
141
|
+
return [ALGORITHM, input.amzDate, input.scope, sha256Hex(input.canonicalRequest)].join("\n");
|
|
142
|
+
}
|
|
143
|
+
/** The signature itself: lowercase hex, 64 characters. */
|
|
144
|
+
export function sign(input) {
|
|
145
|
+
const key = signingKey(input.secretAccessKey, input.dateStamp, input.region, input.service);
|
|
146
|
+
return hmac(key, input.stringToSign).toString("hex");
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Mint a presigned URL: query-string authentication, SigV4.
|
|
150
|
+
*
|
|
151
|
+
* Two facts about query-string auth that the header-based form does not share,
|
|
152
|
+
* and that are easy to get wrong by analogy:
|
|
153
|
+
*
|
|
154
|
+
* - the session token travels as `X-Amz-Security-Token` in the QUERY and is
|
|
155
|
+
* signed there, not as a header;
|
|
156
|
+
* - `X-Amz-Signature` itself is the one parameter excluded from the
|
|
157
|
+
* canonical query string, because it does not exist yet when the canonical
|
|
158
|
+
* request is built. It is appended to the URL afterwards.
|
|
159
|
+
*/
|
|
160
|
+
export function presign(input) {
|
|
161
|
+
const { amzDate, dateStamp } = amzTimestamps(input.epochMs);
|
|
162
|
+
const scope = credentialScope(dateStamp, input.region, input.service);
|
|
163
|
+
const headers = [["host", input.host], ...(input.headers ?? [])];
|
|
164
|
+
const signed = signedHeaderList(headers);
|
|
165
|
+
const query = [
|
|
166
|
+
...(input.query ?? []),
|
|
167
|
+
["X-Amz-Algorithm", ALGORITHM],
|
|
168
|
+
["X-Amz-Credential", `${input.accessKeyId}/${scope}`],
|
|
169
|
+
["X-Amz-Date", amzDate],
|
|
170
|
+
["X-Amz-Expires", String(input.expiresInSeconds)],
|
|
171
|
+
["X-Amz-SignedHeaders", signed],
|
|
172
|
+
];
|
|
173
|
+
if (input.sessionToken !== undefined) {
|
|
174
|
+
query.push(["X-Amz-Security-Token", input.sessionToken]);
|
|
175
|
+
}
|
|
176
|
+
const creq = canonicalRequest({
|
|
177
|
+
method: input.method,
|
|
178
|
+
canonicalUri: input.canonicalUri,
|
|
179
|
+
query,
|
|
180
|
+
headers,
|
|
181
|
+
payloadHash: UNSIGNED_PAYLOAD,
|
|
182
|
+
});
|
|
183
|
+
const toSign = stringToSign({ amzDate, scope, canonicalRequest: creq.text });
|
|
184
|
+
const signature = sign({
|
|
185
|
+
secretAccessKey: input.secretAccessKey,
|
|
186
|
+
dateStamp,
|
|
187
|
+
region: input.region,
|
|
188
|
+
service: input.service,
|
|
189
|
+
stringToSign: toSign,
|
|
190
|
+
});
|
|
191
|
+
// The URL carries the canonical (sorted, encoded) query so that two calls
|
|
192
|
+
// with the same inputs produce byte-identical URLs. The server re-sorts
|
|
193
|
+
// anyway; determinism is for the caller, who diffs these.
|
|
194
|
+
const url = `${input.origin}${input.canonicalUri}?${canonicalQuery(query)}&X-Amz-Signature=${signature}`;
|
|
195
|
+
return {
|
|
196
|
+
url,
|
|
197
|
+
amzDate,
|
|
198
|
+
signedHeaders: creq.signedHeaders,
|
|
199
|
+
canonicalRequest: creq.text,
|
|
200
|
+
stringToSign: toSign,
|
|
201
|
+
signature,
|
|
202
|
+
};
|
|
203
|
+
}
|