@nifrajs/image 0.1.0-alpha.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/src/sign.ts ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Signed image URLs — a portable, **synchronous** HMAC-SHA256 so the (sync) `selfHostedLoader` can sign
3
+ * URLs inline, on any runtime including the edge (WebCrypto's HMAC is async-only; `node:crypto` isn't on
4
+ * Workers). A signed URL lets `createImageHandler` reject any `(src, w, q)` it didn't authorize —
5
+ * shutting down resize-bombing (width/quality enumeration) and locking the endpoint to your own images.
6
+ *
7
+ * Pure JS, dependency-free, KAT-tested against the SHA-256 / RFC 4231 HMAC vectors. The secret never
8
+ * leaves the bytes you pass in.
9
+ */
10
+
11
+ // FIPS 180-4 SHA-256 round constants.
12
+ // biome-ignore format: the 64 constants read best as an 8×8 grid.
13
+ const K = new Uint32Array([
14
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
15
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
16
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
17
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
18
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
19
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
20
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
21
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
22
+ ])
23
+
24
+ const rotr = (x: number, n: number): number => ((x >>> n) | (x << (32 - n))) >>> 0
25
+
26
+ /** SHA-256 of a byte array → 32-byte digest. */
27
+ function sha256(data: Uint8Array): Uint8Array {
28
+ const len = data.length
29
+ const bitLen = len * 8
30
+ // Pad: 0x80, then zeros, so the total is ≡ 56 (mod 64), then the 64-bit big-endian bit length.
31
+ const padded = (56 - ((len + 1) % 64) + 64) % 64
32
+ const total = len + 1 + padded + 8
33
+ const msg = new Uint8Array(total)
34
+ msg.set(data)
35
+ msg[len] = 0x80
36
+ const dv = new DataView(msg.buffer)
37
+ dv.setUint32(total - 8, Math.floor(bitLen / 0x1_0000_0000)) // high word (0 for our sizes)
38
+ dv.setUint32(total - 4, bitLen >>> 0)
39
+
40
+ const h = new Uint32Array([
41
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
42
+ ])
43
+ const w = new Uint32Array(64)
44
+ for (let off = 0; off < total; off += 64) {
45
+ for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4)
46
+ for (let i = 16; i < 64; i++) {
47
+ const a = w[i - 15] as number
48
+ const b = w[i - 2] as number
49
+ const s0 = (rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3)) >>> 0
50
+ const s1 = (rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10)) >>> 0
51
+ w[i] = ((w[i - 16] as number) + s0 + (w[i - 7] as number) + s1) >>> 0
52
+ }
53
+ let a = h[0] as number
54
+ let b = h[1] as number
55
+ let c = h[2] as number
56
+ let d = h[3] as number
57
+ let e = h[4] as number
58
+ let f = h[5] as number
59
+ let g = h[6] as number
60
+ let hh = h[7] as number
61
+ for (let i = 0; i < 64; i++) {
62
+ const s1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0
63
+ const ch = ((e & f) ^ (~e & g)) >>> 0
64
+ const t1 = (hh + s1 + ch + (K[i] as number) + (w[i] as number)) >>> 0
65
+ const s0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0
66
+ const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0
67
+ const t2 = (s0 + maj) >>> 0
68
+ hh = g
69
+ g = f
70
+ f = e
71
+ e = (d + t1) >>> 0
72
+ d = c
73
+ c = b
74
+ b = a
75
+ a = (t1 + t2) >>> 0
76
+ }
77
+ h[0] = ((h[0] as number) + a) >>> 0
78
+ h[1] = ((h[1] as number) + b) >>> 0
79
+ h[2] = ((h[2] as number) + c) >>> 0
80
+ h[3] = ((h[3] as number) + d) >>> 0
81
+ h[4] = ((h[4] as number) + e) >>> 0
82
+ h[5] = ((h[5] as number) + f) >>> 0
83
+ h[6] = ((h[6] as number) + g) >>> 0
84
+ h[7] = ((h[7] as number) + hh) >>> 0
85
+ }
86
+ const out = new Uint8Array(32)
87
+ const odv = new DataView(out.buffer)
88
+ for (let i = 0; i < 8; i++) odv.setUint32(i * 4, h[i] as number)
89
+ return out
90
+ }
91
+
92
+ const BLOCK = 64
93
+
94
+ /** HMAC-SHA256 (RFC 2104) of `message` under `key` → 32-byte tag. */
95
+ function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array {
96
+ let k = key.length > BLOCK ? sha256(key) : key
97
+ if (k.length < BLOCK) {
98
+ const padded = new Uint8Array(BLOCK)
99
+ padded.set(k)
100
+ k = padded
101
+ }
102
+ const inner = new Uint8Array(BLOCK + message.length)
103
+ const outer = new Uint8Array(BLOCK + 32)
104
+ for (let i = 0; i < BLOCK; i++) {
105
+ inner[i] = (k[i] as number) ^ 0x36
106
+ outer[i] = (k[i] as number) ^ 0x5c
107
+ }
108
+ inner.set(message, BLOCK)
109
+ outer.set(sha256(inner), BLOCK)
110
+ return sha256(outer)
111
+ }
112
+
113
+ const encoder = new TextEncoder()
114
+
115
+ const hex = (bytes: Uint8Array): string => {
116
+ let s = ""
117
+ for (const b of bytes) s += b.toString(16).padStart(2, "0")
118
+ return s
119
+ }
120
+
121
+ /** HMAC-SHA256 of `message` under `key` (both UTF-8 strings) → lowercase hex. The primitive behind URL
122
+ * signing; exported for tests (RFC 4231 vectors) and advanced use. */
123
+ export function hmacSha256Hex(key: string, message: string): string {
124
+ return hex(hmacSha256(encoder.encode(key), encoder.encode(message)))
125
+ }
126
+
127
+ /** base64url (RFC 4648 §5, unpadded) of a byte array — URL-safe, compact (43 chars for a 32-byte tag). */
128
+ function base64url(bytes: Uint8Array): string {
129
+ let binary = ""
130
+ for (const b of bytes) binary += String.fromCharCode(b)
131
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
132
+ }
133
+
134
+ /** Constant-time string compare — length-independent of where the first difference is. */
135
+ function safeEqual(a: string, b: string): boolean {
136
+ if (a.length !== b.length) return false
137
+ let diff = 0
138
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
139
+ return diff === 0
140
+ }
141
+
142
+ /** The fields a signature covers. Newline-delimited into a canonical string (newlines can't appear in
143
+ * a URL query value, so the join is unambiguous). `q`/`exp` are normalized to `""` when absent so the
144
+ * signer and verifier agree. */
145
+ export interface ImageSignatureParts {
146
+ readonly src: string
147
+ readonly w: string
148
+ // `| undefined` (not just `?`) so callers can pass a computed `string | undefined` under
149
+ // `exactOptionalPropertyTypes` (the value is normalized to `""` in the canonical string).
150
+ readonly q?: string | undefined
151
+ readonly exp?: string | undefined
152
+ }
153
+
154
+ const canonical = (p: ImageSignatureParts): string =>
155
+ `${p.src}\n${p.w}\n${p.q ?? ""}\n${p.exp ?? ""}`
156
+
157
+ // 256-bit secret floor — mirrors @nifrajs/core's HMAC guard. This package is dependency-free (ships
158
+ // its own SHA-256), so the constant is inlined rather than imported.
159
+ const MIN_SECRET_BYTES = 32
160
+
161
+ /** Compute the URL signature (base64url HMAC-SHA256) for a set of image params. Guards (and so does
162
+ * the verify path, which calls through here) against a sub-256-bit secret. */
163
+ export function signImageParams(secret: string, parts: ImageSignatureParts): string {
164
+ if (encoder.encode(secret).length < MIN_SECRET_BYTES) {
165
+ throw new Error(
166
+ `[nifra/image] signing secret must be at least ${MIN_SECRET_BYTES} bytes (256-bit). Generate one with: openssl rand -base64 32`,
167
+ )
168
+ }
169
+ return base64url(hmacSha256(encoder.encode(secret), encoder.encode(canonical(parts))))
170
+ }
171
+
172
+ /**
173
+ * Verify a request's signature against `secret` (constant-time) and, when an `exp` is present, that it
174
+ * hasn't passed `nowSeconds`. Returns `false` for a missing/forged/expired signature.
175
+ */
176
+ export function verifyImageParams(
177
+ secret: string,
178
+ parts: ImageSignatureParts,
179
+ signature: string,
180
+ nowSeconds: number,
181
+ ): boolean {
182
+ if (parts.exp !== undefined) {
183
+ if (!/^\d+$/.test(parts.exp)) return false
184
+ if (Number(parts.exp) < nowSeconds) return false // expired
185
+ }
186
+ return safeEqual(signImageParams(secret, parts), signature)
187
+ }