@crabcrop/url 0.6.0 → 0.8.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 +14 -1
- package/dist/explain.js +22 -1
- package/dist/index.d.ts +65 -0
- package/dist/index.js +146 -1
- package/dist/operations.d.ts +4 -0
- package/dist/operations.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Build and sign [crabcrop](https://github.com/dayvson/crabcrop) URLs from TypeScr
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
|
-
import { buildURL, buildSrcSet, purgeGroupSignature, purgeSignature, sign, uploadSignature, verifyUrlSignature } from "@crabcrop/url";
|
|
8
|
+
import { buildSealedURL, buildURL, buildSrcSet, purgeGroupSignature, purgeSignature, seal, sign, unseal, uploadSignature, verifyUrlSignature } from "@crabcrop/url";
|
|
9
9
|
|
|
10
10
|
// A signed URL. The operation string is passed verbatim and may
|
|
11
11
|
// contain several segments and preset: references.
|
|
@@ -52,6 +52,19 @@ const group = purgeGroupSignature("your-secret", "thumb");
|
|
|
52
52
|
// then; its path is /upload/<signature>/exp:<expiry>/<name>.
|
|
53
53
|
const upload = uploadSignature("your-secret", "heroes/photo.jpg");
|
|
54
54
|
const expiring = uploadSignature("your-secret", "heroes/photo.jpg", 1893456000);
|
|
55
|
+
|
|
56
|
+
// Sealed URLs (issue #255): the whole remainder encrypted into an
|
|
57
|
+
// opaque token in the signature slot, for public links whose recipe
|
|
58
|
+
// should stay unreadable. The server serves the sealed and plain
|
|
59
|
+
// forms of one render from the same cache entry.
|
|
60
|
+
const sealedUrl = buildSealedURL(
|
|
61
|
+
"https://images.example.com",
|
|
62
|
+
"your-secret",
|
|
63
|
+
"300x200",
|
|
64
|
+
"https://example.com/photo.jpg",
|
|
65
|
+
{ name: "photo.jpg" },
|
|
66
|
+
);
|
|
67
|
+
const remainder = unseal("your-secret", seal("your-secret", "300x200/https://example.com/photo.jpg"));
|
|
55
68
|
```
|
|
56
69
|
|
|
57
70
|
Pass `null` as the secret to produce the literal `unsafe` signature segment, for servers running with `CRABCROP_ALLOW_UNSAFE` or in presets-only mode.
|
package/dist/explain.js
CHANGED
|
@@ -22,6 +22,7 @@ export const FILTER_DESCRIPTIONS = {
|
|
|
22
22
|
pixelatefaces: "pixelate detected faces (needs a face model on the server)",
|
|
23
23
|
blurclass: "blur every detected object of the named class (needs a detection model on the server)",
|
|
24
24
|
pixelateclass: "pixelate every detected object of the named class (needs a detection model on the server)",
|
|
25
|
+
recolorclass: "recolor every detected object of the named class to the given color (needs a detection model on the server)",
|
|
25
26
|
removebg: "remove the background (needs an AI model on the server)",
|
|
26
27
|
keep_profile: "keep the source color profile instead of converting to sRGB",
|
|
27
28
|
upscale: "AI upscale by the given factor (needs an AI model on the server)",
|
|
@@ -48,6 +49,7 @@ export const FILTER_DESCRIPTIONS = {
|
|
|
48
49
|
blur_region: "gaussian-blur the rectangle (x,y,w,h) in pixels at this point in the chain (redaction)",
|
|
49
50
|
overlay: "composite another image over the result: (url,x,y,alpha,blend[,width %]) with blend normal, multiply, screen, or overlay",
|
|
50
51
|
pixelate_region: "pixelate the rectangle (x,y,w,h) in pixels at this point in the chain (redaction)",
|
|
52
|
+
remove_region: "erase the rectangle (x,y,w,h) in pixels at this point in the chain and inpaint it from the surrounding pixels (AI region removal)",
|
|
51
53
|
};
|
|
52
54
|
const MAX_DIMENSION = 16383;
|
|
53
55
|
function parseDimension(part) {
|
|
@@ -313,8 +315,27 @@ function validateFilter(name, args) {
|
|
|
313
315
|
? null
|
|
314
316
|
: bad;
|
|
315
317
|
}
|
|
318
|
+
case "recolorclass": {
|
|
319
|
+
if (args === null)
|
|
320
|
+
return bad;
|
|
321
|
+
const cut = args.lastIndexOf(",");
|
|
322
|
+
if (cut < 0)
|
|
323
|
+
return bad;
|
|
324
|
+
if (!/^[0-9a-fA-F]{6}$/.test(args.slice(cut + 1)))
|
|
325
|
+
return bad;
|
|
326
|
+
const decoded = percentDecodable(args.slice(0, cut));
|
|
327
|
+
if (decoded === null)
|
|
328
|
+
return bad;
|
|
329
|
+
const name = decoded.trim();
|
|
330
|
+
return name.length >= 1 &&
|
|
331
|
+
new TextEncoder().encode(name).length <= 64 &&
|
|
332
|
+
/^[\p{L}\p{N} _.\-/]+$/u.test(name)
|
|
333
|
+
? null
|
|
334
|
+
: bad;
|
|
335
|
+
}
|
|
316
336
|
case "blur_region":
|
|
317
|
-
case "pixelate_region":
|
|
337
|
+
case "pixelate_region":
|
|
338
|
+
case "remove_region": {
|
|
318
339
|
if (args === null)
|
|
319
340
|
return bad;
|
|
320
341
|
const parts = args.split(",");
|
package/dist/index.d.ts
CHANGED
|
@@ -63,6 +63,71 @@ export declare function isValidKeyId(id: string): boolean;
|
|
|
63
63
|
* followed by the hex tag `sign` computes.
|
|
64
64
|
*/
|
|
65
65
|
export declare function signWithKeyId(secret: string, keyId: string, remainder: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* The largest remainder {@link seal} will encrypt, in bytes
|
|
68
|
+
* (issue #255). A recipe longer than this belongs in a
|
|
69
|
+
* preset; the cap also bounds the token a server must
|
|
70
|
+
* decode.
|
|
71
|
+
*/
|
|
72
|
+
export declare const MAX_SEALED_REMAINDER = 2048;
|
|
73
|
+
/**
|
|
74
|
+
* Seal a remainder into a token segment with a
|
|
75
|
+
* caller-supplied nonce: `s1.<base64url(nonce ||
|
|
76
|
+
* ciphertext || tag)>`, AES-256-GCM under the derived seal
|
|
77
|
+
* subkey with the fixed associated data `crabcrop/sealed/1`.
|
|
78
|
+
*
|
|
79
|
+
* DANGER: a nonce must never repeat under one secret.
|
|
80
|
+
* Reusing a nonce with AES-GCM destroys the construction's
|
|
81
|
+
* confidentiality and integrity guarantees. This function
|
|
82
|
+
* is the deterministic seam the spec's test vectors use;
|
|
83
|
+
* production minting calls {@link seal}, which draws a
|
|
84
|
+
* fresh random nonce every time.
|
|
85
|
+
*/
|
|
86
|
+
export declare function sealWithNonce(secret: string, remainder: string, nonce: Uint8Array, keyId?: string): string;
|
|
87
|
+
/**
|
|
88
|
+
* Seal a remainder into a token segment (issue #255), with
|
|
89
|
+
* a fresh random 96-bit nonce. The token is the whole
|
|
90
|
+
* signature segment; pass `keyId` to stamp `<key-id>:`
|
|
91
|
+
* in front of it, so servers in a rotation window open it
|
|
92
|
+
* against the named key only.
|
|
93
|
+
*/
|
|
94
|
+
export declare function seal(secret: string, remainder: string, keyId?: string): string;
|
|
95
|
+
/**
|
|
96
|
+
* Open a token segment, with or without a `<key-id>:`
|
|
97
|
+
* prefix, under one secret, returning the remainder it
|
|
98
|
+
* seals. The key ID is routing metadata and is not
|
|
99
|
+
* consulted: the question is whether this secret opens the
|
|
100
|
+
* token. Every failure (malformed body, wrong secret,
|
|
101
|
+
* tampered bytes, unknown version) throws the one error,
|
|
102
|
+
* `the token did not open under this secret`: openers must
|
|
103
|
+
* never learn which stage failed.
|
|
104
|
+
*/
|
|
105
|
+
export declare function unseal(secret: string, segment: string): string;
|
|
106
|
+
/** Options for {@link buildSealedURL}. */
|
|
107
|
+
export interface SealOptions {
|
|
108
|
+
/** Unix-seconds expiry, sealed inside the token as a leading `exp:` segment. */
|
|
109
|
+
expires?: number;
|
|
110
|
+
/** Key ID to stamp on the token segment (`<key-id>:s1.…`), for rotation. */
|
|
111
|
+
keyId?: string;
|
|
112
|
+
/**
|
|
113
|
+
* The cosmetic friendly name after the token. The server
|
|
114
|
+
* never parses, validates, or authenticates it; one
|
|
115
|
+
* filename-like segment is the dependable shape. Omit it
|
|
116
|
+
* and the URL ends at the token.
|
|
117
|
+
*/
|
|
118
|
+
name?: string;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Assemble a sealed crabcrop URL:
|
|
122
|
+
* `{base}/[<key-id>:]s1.<token>/{friendly-name}` (issue
|
|
123
|
+
* #255). The remainder is assembled exactly as
|
|
124
|
+
* {@link buildURL} assembles it (expiry segment,
|
|
125
|
+
* operations, source) and then sealed whole, pre-expansion,
|
|
126
|
+
* so the sealed and plain forms of one render are one cache
|
|
127
|
+
* entry and one purge target on the server. A secret is
|
|
128
|
+
* required: there is no `unsafe` spelling of a token.
|
|
129
|
+
*/
|
|
130
|
+
export declare function buildSealedURL(base: string, secret: string, operations: string, source: string, options?: SealOptions): string;
|
|
66
131
|
/**
|
|
67
132
|
* Assemble a signed (or `unsafe`) crabcrop URL. `operations`
|
|
68
133
|
* is the operation string verbatim (several `/`-separated
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Build and sign crabcrop URLs, per `spec/url-signing.md` in
|
|
3
3
|
* the crabcrop repository. Standard library only.
|
|
4
4
|
*/
|
|
5
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
6
6
|
/**
|
|
7
7
|
* Compute the signature segment for a remainder: the lowercase
|
|
8
8
|
* hex HMAC-SHA256 of `remainder` under `secret`. The remainder
|
|
@@ -76,6 +76,151 @@ export function signWithKeyId(secret, keyId, remainder) {
|
|
|
76
76
|
}
|
|
77
77
|
return `${keyId}:${sign(secret, remainder)}`;
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* The largest remainder {@link seal} will encrypt, in bytes
|
|
81
|
+
* (issue #255). A recipe longer than this belongs in a
|
|
82
|
+
* preset; the cap also bounds the token a server must
|
|
83
|
+
* decode.
|
|
84
|
+
*/
|
|
85
|
+
export const MAX_SEALED_REMAINDER = 2048;
|
|
86
|
+
/** The AAD every sealed token is bound to. */
|
|
87
|
+
const SEAL_AAD = Buffer.from("crabcrop/sealed/1", "utf8");
|
|
88
|
+
/**
|
|
89
|
+
* The seal subkey: the raw bytes of the HMAC-SHA256 of the
|
|
90
|
+
* label `crabcrop/seal-key` under the secret, its own domain
|
|
91
|
+
* beside purge, purge-group, and upload, so there is no
|
|
92
|
+
* second secret to configure or rotate: each keyring entry
|
|
93
|
+
* yields its own seal subkey, and the rotation window for
|
|
94
|
+
* signatures is the rotation window for tokens.
|
|
95
|
+
*/
|
|
96
|
+
function sealKey(secret) {
|
|
97
|
+
return Buffer.from(sign(secret, "crabcrop/seal-key"), "hex");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Seal a remainder into a token segment with a
|
|
101
|
+
* caller-supplied nonce: `s1.<base64url(nonce ||
|
|
102
|
+
* ciphertext || tag)>`, AES-256-GCM under the derived seal
|
|
103
|
+
* subkey with the fixed associated data `crabcrop/sealed/1`.
|
|
104
|
+
*
|
|
105
|
+
* DANGER: a nonce must never repeat under one secret.
|
|
106
|
+
* Reusing a nonce with AES-GCM destroys the construction's
|
|
107
|
+
* confidentiality and integrity guarantees. This function
|
|
108
|
+
* is the deterministic seam the spec's test vectors use;
|
|
109
|
+
* production minting calls {@link seal}, which draws a
|
|
110
|
+
* fresh random nonce every time.
|
|
111
|
+
*/
|
|
112
|
+
export function sealWithNonce(secret, remainder, nonce, keyId) {
|
|
113
|
+
if (nonce.length !== 12) {
|
|
114
|
+
throw new Error(`sealed token nonce must be 12 bytes, got ${nonce.length}`);
|
|
115
|
+
}
|
|
116
|
+
if (Buffer.byteLength(remainder, "utf8") > MAX_SEALED_REMAINDER) {
|
|
117
|
+
throw new Error(`remainder is over the ${MAX_SEALED_REMAINDER}-byte sealing cap: a recipe that long belongs in a preset`);
|
|
118
|
+
}
|
|
119
|
+
const cipher = createCipheriv("aes-256-gcm", sealKey(secret), Buffer.from(nonce));
|
|
120
|
+
cipher.setAAD(SEAL_AAD);
|
|
121
|
+
const ciphertext = Buffer.concat([cipher.update(remainder, "utf8"), cipher.final()]);
|
|
122
|
+
const body = Buffer.concat([Buffer.from(nonce), ciphertext, cipher.getAuthTag()]);
|
|
123
|
+
const token = `s1.${body.toString("base64url")}`;
|
|
124
|
+
if (keyId === undefined) {
|
|
125
|
+
return token;
|
|
126
|
+
}
|
|
127
|
+
if (!isValidKeyId(keyId)) {
|
|
128
|
+
throw new Error(`invalid key ID ${JSON.stringify(keyId)}: 1 to 32 characters of [A-Za-z0-9_-]`);
|
|
129
|
+
}
|
|
130
|
+
return `${keyId}:${token}`;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Seal a remainder into a token segment (issue #255), with
|
|
134
|
+
* a fresh random 96-bit nonce. The token is the whole
|
|
135
|
+
* signature segment; pass `keyId` to stamp `<key-id>:`
|
|
136
|
+
* in front of it, so servers in a rotation window open it
|
|
137
|
+
* against the named key only.
|
|
138
|
+
*/
|
|
139
|
+
export function seal(secret, remainder, keyId) {
|
|
140
|
+
return sealWithNonce(secret, remainder, randomBytes(12), keyId);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Open a token segment, with or without a `<key-id>:`
|
|
144
|
+
* prefix, under one secret, returning the remainder it
|
|
145
|
+
* seals. The key ID is routing metadata and is not
|
|
146
|
+
* consulted: the question is whether this secret opens the
|
|
147
|
+
* token. Every failure (malformed body, wrong secret,
|
|
148
|
+
* tampered bytes, unknown version) throws the one error,
|
|
149
|
+
* `the token did not open under this secret`: openers must
|
|
150
|
+
* never learn which stage failed.
|
|
151
|
+
*/
|
|
152
|
+
export function unseal(secret, segment) {
|
|
153
|
+
const openFailed = () => new Error("the token did not open under this secret");
|
|
154
|
+
let token = segment;
|
|
155
|
+
const colon = segment.indexOf(":");
|
|
156
|
+
if (colon >= 0) {
|
|
157
|
+
if (!isValidKeyId(segment.slice(0, colon))) {
|
|
158
|
+
throw openFailed();
|
|
159
|
+
}
|
|
160
|
+
token = segment.slice(colon + 1);
|
|
161
|
+
}
|
|
162
|
+
if (!token.startsWith("s1.")) {
|
|
163
|
+
throw openFailed();
|
|
164
|
+
}
|
|
165
|
+
const body = token.slice(3);
|
|
166
|
+
// Node's base64 decoder is lenient (it skips characters
|
|
167
|
+
// outside the alphabet), so canonicity is checked by
|
|
168
|
+
// re-encoding: anything but the exact minted spelling
|
|
169
|
+
// fails here.
|
|
170
|
+
const raw = Buffer.from(body, "base64url");
|
|
171
|
+
if (raw.length < 12 + 16 || raw.toString("base64url") !== body) {
|
|
172
|
+
throw openFailed();
|
|
173
|
+
}
|
|
174
|
+
const decipher = createDecipheriv("aes-256-gcm", sealKey(secret), raw.subarray(0, 12));
|
|
175
|
+
decipher.setAAD(SEAL_AAD);
|
|
176
|
+
decipher.setAuthTag(raw.subarray(raw.length - 16));
|
|
177
|
+
let plain;
|
|
178
|
+
try {
|
|
179
|
+
plain = Buffer.concat([
|
|
180
|
+
decipher.update(raw.subarray(12, raw.length - 16)),
|
|
181
|
+
decipher.final(),
|
|
182
|
+
]);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
throw openFailed();
|
|
186
|
+
}
|
|
187
|
+
const text = plain.toString("utf8");
|
|
188
|
+
if (!Buffer.from(text, "utf8").equals(plain)) {
|
|
189
|
+
throw openFailed();
|
|
190
|
+
}
|
|
191
|
+
return text;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Assemble a sealed crabcrop URL:
|
|
195
|
+
* `{base}/[<key-id>:]s1.<token>/{friendly-name}` (issue
|
|
196
|
+
* #255). The remainder is assembled exactly as
|
|
197
|
+
* {@link buildURL} assembles it (expiry segment,
|
|
198
|
+
* operations, source) and then sealed whole, pre-expansion,
|
|
199
|
+
* so the sealed and plain forms of one render are one cache
|
|
200
|
+
* entry and one purge target on the server. A secret is
|
|
201
|
+
* required: there is no `unsafe` spelling of a token.
|
|
202
|
+
*/
|
|
203
|
+
export function buildSealedURL(base, secret, operations, source, options = {}) {
|
|
204
|
+
if (secret === null || secret === undefined) {
|
|
205
|
+
throw new Error("sealing requires a secret: there is no unsafe token");
|
|
206
|
+
}
|
|
207
|
+
if (options.expires !== undefined) {
|
|
208
|
+
assertExpires(options.expires);
|
|
209
|
+
}
|
|
210
|
+
assertUrlText(operations, "operations");
|
|
211
|
+
assertUrlText(source, "source");
|
|
212
|
+
const trimmedBase = base.replace(/\/+$/, "");
|
|
213
|
+
let remainder = "";
|
|
214
|
+
if (options.expires !== undefined) {
|
|
215
|
+
remainder += `exp:${options.expires}/`;
|
|
216
|
+
}
|
|
217
|
+
if (operations) {
|
|
218
|
+
remainder += `${operations}/`;
|
|
219
|
+
}
|
|
220
|
+
remainder += source;
|
|
221
|
+
const segment = seal(secret, remainder, options.keyId);
|
|
222
|
+
return options.name ? `${trimmedBase}/${segment}/${options.name}` : `${trimmedBase}/${segment}`;
|
|
223
|
+
}
|
|
79
224
|
/**
|
|
80
225
|
* Fail loudly on inputs that can never produce a verifiable URL
|
|
81
226
|
* (audit P-4, P-5): the remainder travels as a raw URL path, so
|
package/dist/operations.d.ts
CHANGED
|
@@ -171,8 +171,12 @@ export declare const filter: {
|
|
|
171
171
|
blurClass(name: string): string;
|
|
172
172
|
/** Pixelate every detected object of the named class. */
|
|
173
173
|
pixelateClass(name: string): string;
|
|
174
|
+
/** Recolor every detected object of the named class to the given RRGGBB color. */
|
|
175
|
+
recolorClass(name: string, color: string): string;
|
|
174
176
|
/** Blur the rectangle (x, y, w, h) in pixels. */
|
|
175
177
|
blurRegion(x: number, y: number, w: number, h: number): string;
|
|
176
178
|
/** Pixelate the rectangle (x, y, w, h) in pixels. */
|
|
177
179
|
pixelateRegion(x: number, y: number, w: number, h: number): string;
|
|
180
|
+
/** Erase the rectangle (x, y, w, h) in pixels and inpaint it (AI). */
|
|
181
|
+
removeRegion(x: number, y: number, w: number, h: number): string;
|
|
178
182
|
};
|
package/dist/operations.js
CHANGED
|
@@ -480,6 +480,10 @@ export const filter = {
|
|
|
480
480
|
pixelateClass(name) {
|
|
481
481
|
return `pixelateclass(${className("pixelateclass name", name)})`;
|
|
482
482
|
},
|
|
483
|
+
/** Recolor every detected object of the named class to the given RRGGBB color. */
|
|
484
|
+
recolorClass(name, color) {
|
|
485
|
+
return `recolorclass(${className("recolorclass name", name)},${hex6("recolorclass color", color)})`;
|
|
486
|
+
},
|
|
483
487
|
/** Blur the rectangle (x, y, w, h) in pixels. */
|
|
484
488
|
blurRegion(x, y, w, h) {
|
|
485
489
|
return `blur_region(${region(x, y, w, h)})`;
|
|
@@ -488,6 +492,10 @@ export const filter = {
|
|
|
488
492
|
pixelateRegion(x, y, w, h) {
|
|
489
493
|
return `pixelate_region(${region(x, y, w, h)})`;
|
|
490
494
|
},
|
|
495
|
+
/** Erase the rectangle (x, y, w, h) in pixels and inpaint it (AI). */
|
|
496
|
+
removeRegion(x, y, w, h) {
|
|
497
|
+
return `remove_region(${region(x, y, w, h)})`;
|
|
498
|
+
},
|
|
491
499
|
};
|
|
492
500
|
function region(x, y, w, h) {
|
|
493
501
|
intIn("region x", x, 0, MAX_DIMENSION);
|