@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.
@@ -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
+ }