diff --git a/.changeset/three-bees-melt.md b/.changeset/three-bees-melt.md new file mode 100644 index 0000000000..5413cdf58a --- /dev/null +++ b/.changeset/three-bees-melt.md @@ -0,0 +1,6 @@ +--- +"@uploadthing/shared": patch +"uploadthing": patch +--- + +feat: add optional hashFn to generateKey for wider key entropy diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index af9d28530e..7f46ea4c4b 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -5,7 +5,7 @@ import * as Redacted from "effect/Redacted"; import SQIds, { defaultOptions } from "sqids"; import { UploadThingError } from "./error"; -import type { ExtractHashPartsFn, FileProperties, Time } from "./types"; +import type { ExtractHashPartsFn, FileProperties, HashFn, Time } from "./types"; import { parseTimeToSeconds } from "./utils"; const signaturePrefix = "hmac-sha256="; @@ -90,6 +90,7 @@ export const generateKey = ( file: FileProperties, appId: string, getHashParts?: ExtractHashPartsFn, + hashFn?: HashFn, ) => Micro.sync(() => { // Get the parts of which we should hash to constuct the key @@ -108,9 +109,11 @@ export const generateKey = ( // Hash and Encode the parts and appId as sqids const alphabet = shuffle(defaultOptions.alphabet, appId); - const encodedFileSeed = new SQIds({ alphabet, minLength: 36 }).encode([ - Math.abs(Hash.string(hashParts)), - ]); + const rawHash = hashFn ? hashFn(hashParts) : Hash.string(hashParts); + const hashArray = Array.isArray(rawHash) ? rawHash : [rawHash]; + const encodedFileSeed = new SQIds({ alphabet, minLength: 36 }).encode( + hashArray.map((n) => Math.abs(n)), + ); const encodedAppId = new SQIds({ alphabet, minLength: 12 }).encode([ Math.abs(Hash.string(appId)), ]); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index eedd0d0c4f..eccb270668 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -35,6 +35,16 @@ export type ExtractHashPartsFn = ( file: FileProperties, ) => (string | number | undefined | null | boolean)[]; +/** + * Custom hash function for the file seed portion of the key. + * By default, Effect's `Hash.string` (32-bit) is used. Provide a function + * that returns a wider hash to increase entropy and reduce collision probability. + * The function receives the JSON-stringified hash parts and should return + * a number or an array of numbers (for wider entropy via multiple SQIds inputs). + * @default Hash.string (32-bit) + */ +export type HashFn = (input: string) => number | number[]; + /** * A subset of the standard RequestInit properties needed by UploadThing internally. * @see RequestInit from lib.dom.d.ts @@ -199,6 +209,15 @@ export type RouteOptions = { * @default (file) => [file.name, file.size, file.type, file.lastModified, Date.now()] */ getFileHashParts?: ExtractHashPartsFn; + /** + * Custom hash function for the file seed portion of the key. + * By default, Effect's `Hash.string` (32-bit) is used. Provide a function + * that returns a wider hash to increase entropy and reduce collision probability. + * The function receives the JSON-stringified hash parts and should return + * a number or an array of numbers (for wider entropy via multiple SQIds inputs). + * @default Hash.string (32-bit) + */ + hashFn?: HashFn; }; export type FileRouterInputKey = AllowedFileType | MimeType; diff --git a/packages/shared/test/crypto.test.ts b/packages/shared/test/crypto.test.ts index 02ff9a6945..b21b57ed9d 100644 --- a/packages/shared/test/crypto.test.ts +++ b/packages/shared/test/crypto.test.ts @@ -10,6 +10,7 @@ import { verifyKey, verifySignature, } from "../src/crypto"; +import type { ExtractHashPartsFn } from "../src/types"; describe("crypto sign / verify", () => { it.effect("signs and verifies a payload", () => @@ -198,4 +199,68 @@ describe("key gen", () => { expect(verified).toBe(true); }), ); + + it.effect( + "custom hashFn returning a single number generates a valid key", + () => + Effect.gen(function* () { + const appI = "foo-123"; + const key = yield* generateKey( + { + name: "foo.txt", + size: 123, + type: "text/plain", + lastModified: 1000, + }, + appI, + (file) => [file.name], + () => 42, + ); + + expect(key).toBeTruthy(); + const verified = yield* verifyKey(key, appI); + expect(verified).toBe(true); + }), + ); + + it.effect( + "custom hashFn returning an array of numbers generates a valid key", + () => + Effect.gen(function* () { + const appI = "foo-123"; + const key = yield* generateKey( + { + name: "foo.txt", + size: 123, + type: "text/plain", + lastModified: 1000, + }, + appI, + (file) => [file.name], + () => [111, 222, 333, 444], + ); + + expect(key).toBeTruthy(); + const verified = yield* verifyKey(key, appI); + expect(verified).toBe(true); + }), + ); + + it.effect("different hashFn outputs produce different keys", () => + Effect.gen(function* () { + const appI = "foo-123"; + const file = { + name: "foo.txt", + size: 123, + type: "text/plain", + lastModified: 1000, + }; + const hashParts: ExtractHashPartsFn = (f) => [f.name]; + + const key1 = yield* generateKey(file, appI, hashParts, () => 100); + const key2 = yield* generateKey(file, appI, hashParts, () => 999); + + expect(key1).not.toBe(key2); + }), + ); }); diff --git a/packages/uploadthing/src/_internal/handler.ts b/packages/uploadthing/src/_internal/handler.ts index 3711579b4f..b4cc5fc355 100644 --- a/packages/uploadthing/src/_internal/handler.ts +++ b/packages/uploadthing/src/_internal/handler.ts @@ -581,6 +581,7 @@ const handleUploadAction = (opts: { file, appId, routeOptions.getFileHashParts, + routeOptions.hashFn, ); const url = yield* generateSignedURL(`${ingestUrl}/${key}`, apiKey, {