@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 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
@@ -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
  };
@@ -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);
package/package.json CHANGED
@@ -21,7 +21,7 @@
21
21
  "test": "node --test"
22
22
  },
23
23
  "type": "module",
24
- "version": "0.6.0",
24
+ "version": "0.8.0",
25
25
  "devDependencies": {
26
26
  "@types/node": "^26.6.5",
27
27
  "typescript": "^5.9.3"