Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 365c918

Browse files
committed
Implement crypto.DigestStream
1 parent 0d33897 commit 365c918

File tree

5 files changed

+148
-25
lines changed

5 files changed

+148
-25
lines changed

packages/core/src/standards/crypto.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,50 @@
11
import { createHash, webcrypto } from "crypto";
2+
import { WritableStream } from "stream/web";
3+
import { DOMException } from "@miniflare/core";
24
import { viewToBuffer } from "@miniflare/shared";
5+
import {
6+
bufferSourceToArray,
7+
buildNotBufferSourceError,
8+
isBufferSource,
9+
} from "./helpers";
10+
11+
// https://developers.cloudflare.com/workers/runtime-apis/web-crypto#supported-algorithms
12+
const supportedDigests = ["sha-1", "sha-256", "sha-384", "sha-512", "md5"];
13+
14+
export class DigestStream extends WritableStream<BufferSource> {
15+
readonly digest: Promise<ArrayBuffer>;
16+
17+
constructor(algorithm: AlgorithmIdentifier) {
18+
// Check algorithm supported by Cloudflare Workers
19+
let name = typeof algorithm === "string" ? algorithm : algorithm?.name;
20+
if (!(name && supportedDigests.includes(name.toLowerCase()))) {
21+
throw new DOMException("Unrecognized name.", "NotSupportedError");
22+
}
23+
// createHash expects "shaN" instead of "sha-N"
24+
name = name.replace("-", "");
25+
26+
// Create deferred promise to resolve digest once stream is closed
27+
let digestResolve: (digest: ArrayBuffer) => void;
28+
const digest = new Promise<ArrayBuffer>((r) => (digestResolve = r));
29+
30+
// Create hasher and initialise stream
31+
const hash = createHash(name);
32+
super({
33+
write(chunk: unknown) {
34+
if (isBufferSource(chunk)) {
35+
hash.update(bufferSourceToArray(chunk));
36+
} else {
37+
throw new TypeError(buildNotBufferSourceError(chunk));
38+
}
39+
},
40+
close() {
41+
digestResolve(viewToBuffer(hash.digest()));
42+
},
43+
});
44+
45+
this.digest = digest;
46+
}
47+
}
348

449
// Workers support non-standard MD5 digests
550
function digest(
@@ -27,6 +72,7 @@ const subtle = new Proxy(webcrypto.subtle, {
2772
export const crypto = new Proxy(webcrypto, {
2873
get(target, propertyKey, receiver) {
2974
if (propertyKey === "subtle") return subtle;
75+
if (propertyKey === "DigestStream") return DigestStream;
3076
return Reflect.get(target, propertyKey, receiver);
3177
},
3278
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export function isBufferSource(chunk: unknown): chunk is BufferSource {
2+
return chunk instanceof ArrayBuffer || ArrayBuffer.isView(chunk);
3+
}
4+
5+
export function bufferSourceToArray(chunk: BufferSource): Uint8Array {
6+
if (chunk instanceof Uint8Array) {
7+
return chunk;
8+
} else if (chunk instanceof ArrayBuffer) {
9+
return new Uint8Array(chunk);
10+
} else {
11+
return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
12+
}
13+
}
14+
15+
export function buildNotBufferSourceError(value: unknown): string {
16+
const isString = typeof value === "string";
17+
return (
18+
"This TransformStream is being used as a byte stream, but received " +
19+
(isString
20+
? "a string on its writable side. If you wish to write a string, " +
21+
"you'll probably want to explicitly UTF-8-encode it with TextEncoder."
22+
: "an object of non-ArrayBuffer/ArrayBufferView type on its writable side.")
23+
);
24+
}

packages/core/src/standards/http.ts

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ import {
4343
// @ts-expect-error we need these for making Request's Headers immutable
4444
import fetchSymbols from "undici/lib/fetch/symbols.js";
4545
import { IncomingRequestCfProperties, RequestInitCfProperties } from "./cf";
46+
import {
47+
bufferSourceToArray,
48+
buildNotBufferSourceError,
49+
isBufferSource,
50+
} from "./helpers";
4651

4752
const inspect = Symbol.for("nodejs.util.inspect.custom");
4853
const nonEnumerable = Object.create(null);
@@ -117,20 +122,6 @@ export function _isByteStream(
117122
return false;
118123
}
119124

120-
function isBufferSource(chunk: unknown): chunk is BufferSource {
121-
return chunk instanceof ArrayBuffer || ArrayBuffer.isView(chunk);
122-
}
123-
124-
function bufferSourceToArray(chunk: BufferSource): Uint8Array {
125-
if (chunk instanceof Uint8Array) {
126-
return chunk;
127-
} else if (chunk instanceof ArrayBuffer) {
128-
return new Uint8Array(chunk);
129-
} else {
130-
return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
131-
}
132-
}
133-
134125
const enumerableBodyKeys: (keyof Body<any>)[] = ["body", "bodyUsed", "headers"];
135126
export class Body<Inner extends BaseRequest | BaseResponse> {
136127
/** @internal */
@@ -213,15 +204,8 @@ export class Body<Inner extends BaseRequest | BaseResponse> {
213204
}
214205
} else if (value) {
215206
// Otherwise, if it's not an ArrayBuffer(View), throw
216-
const isString = typeof value === "string";
217207
return controller.error(
218-
new TypeError(
219-
"This TransformStream is being used as a byte stream, but received " +
220-
(isString
221-
? "a string on its writable side. If you wish to write a string, " +
222-
"you'll probably want to explicitly UTF-8-encode it with TextEncoder."
223-
: "an object of non-ArrayBuffer/ArrayBufferView type on its writable side.")
224-
)
208+
new TypeError(buildNotBufferSourceError(value))
225209
);
226210
}
227211

@@ -422,7 +406,7 @@ export interface ResponseInit extends BaseResponseInit {
422406
const kWaitUntil = Symbol("kWaitUntil");
423407

424408
// From https://github.com/nodejs/undici/blob/3f6b564b7d3023d506cad75b16207006b23956a8/lib/fetch/constants.js#L28, minus 101
425-
const nullBodyStatus = new Set<number | undefined>([204, 205, 304]);
409+
const nullBodyStatus: (number | undefined)[] = [204, 205, 304];
426410

427411
const enumerableResponseKeys: (keyof Response)[] = [
428412
"encodeBody",
@@ -493,7 +477,7 @@ export class Response<
493477
// This zero-length body behavior is allowed because it was previously
494478
// the only way to construct a Response with a null body status. It may
495479
// change in the future.
496-
if (nullBodyStatus.has(init.status) && body === "") body = null;
480+
if (nullBodyStatus.includes(init.status) && body === "") body = null;
497481
}
498482
super(new BaseResponse(body, init));
499483
}

packages/core/test/standards/crypto.spec.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,72 @@
11
import { TextEncoder } from "util";
2-
import { crypto } from "@miniflare/core";
2+
import { DOMException, DigestStream, crypto } from "@miniflare/core";
33
import { utf8Encode } from "@miniflare/shared-test";
44
import test, { Macro } from "ava";
55

6+
const digestStreamMacro: Macro<[AlgorithmIdentifier]> = async (
7+
t,
8+
algorithm
9+
) => {
10+
const stream = new DigestStream(algorithm);
11+
const writer = stream.getWriter();
12+
await writer.write(utf8Encode("a"));
13+
await writer.write(utf8Encode("bb"));
14+
await writer.write(utf8Encode("ccc"));
15+
await writer.close();
16+
const digest = await stream.digest;
17+
18+
const expected = await crypto.subtle.digest(algorithm, utf8Encode("abbccc"));
19+
20+
t.deepEqual(digest, expected);
21+
};
22+
digestStreamMacro.title = (providedTitle, algorithm) =>
23+
`DigestStream: computes ${JSON.stringify(algorithm)} digest`;
24+
test(digestStreamMacro, "SHA-1");
25+
test(digestStreamMacro, "Sha-256");
26+
test(digestStreamMacro, "sha-384");
27+
test(digestStreamMacro, "SHA-512");
28+
test(digestStreamMacro, "mD5");
29+
test(digestStreamMacro, { name: "ShA-1" });
30+
31+
test("DigestStream: throws on unsupported algorithm", (t) => {
32+
// Note md5 IS supported by Node's createHash
33+
t.throws(() => new DigestStream("md4"), {
34+
instanceOf: DOMException,
35+
name: "NotSupportedError",
36+
message: "Unrecognized name.",
37+
});
38+
});
39+
40+
test("DigestStream: throws on string chunks", async (t) => {
41+
const stream = new DigestStream("sha-1");
42+
const writer = stream.getWriter();
43+
// @ts-expect-error intentionally testing incorrect types
44+
await t.throwsAsync(async () => writer.write("a"), {
45+
instanceOf: TypeError,
46+
message:
47+
"This TransformStream is being used as a byte stream, " +
48+
"but received a string on its writable side. " +
49+
"If you wish to write a string, you'll probably want to " +
50+
"explicitly UTF-8-encode it with TextEncoder.",
51+
});
52+
});
53+
test("DigestStream: throws on non-ArrayBuffer/ArrayBufferView chunks", async (t) => {
54+
const stream = new DigestStream("sha-1");
55+
const writer = stream.getWriter();
56+
// @ts-expect-error intentionally testing incorrect types
57+
await t.throwsAsync(async () => writer.write(42), {
58+
instanceOf: TypeError,
59+
message:
60+
"This TransformStream is being used as a byte stream, " +
61+
"but received an object of non-ArrayBuffer/ArrayBufferView " +
62+
"type on its writable side.",
63+
});
64+
});
65+
66+
test("crypto: provides DigestStream", (t) => {
67+
t.is(crypto.DigestStream, DigestStream);
68+
});
69+
670
const md5Macro: Macro<[BufferSource]> = async (t, data) => {
771
const digest = await crypto.subtle.digest("md5", data);
872
t.is(Buffer.from(digest).toString("hex"), "098f6bcd4621d373cade4e832627b4f6");

types/crypto.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,10 @@ declare module "crypto" {
4545
namespace webcrypto {
4646
const subtle: SubtleCrypto;
4747
function getRandomValues<T extends ArrayBufferView>(array: T): T;
48+
49+
class DigestStream {
50+
constructor(algorithm: AlgorithmIdentifier);
51+
readonly digest: Promise<ArrayBuffer>;
52+
}
4853
}
4954
}

0 commit comments

Comments
 (0)