1import * as BrowserCrypto from "@effect/platform-browser/BrowserCrypto"
2import { assert, describe, it } from "@effect/vitest"
3import { Cause, Exit, Layer } from "effect"
4import * as Crypto from "effect/Crypto"
5import * as Effect from "effect/Effect"
6import * as PlatformError from "effect/PlatformError"
7import * as TestClock from "effect/testing/TestClock"
8import { webcrypto } from "node:crypto"
9
10const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
11const uuidV7Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
12
13const getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
14 if (array instanceof Uint8Array) {
15 for (let i = 0; i < array.length; i++) {
16 array[i] = i & 0xff
17 }
18 }
19 return array
20}
21
22const withSubtle = (subtle: unknown): globalThis.Crypto =>
23 Object.create(globalThis.crypto, { subtle: { value: subtle } })
24
25const layerWith = (crypto: globalThis.Crypto) =>
26 BrowserCrypto.layer.pipe(Layer.provide(Layer.succeed(BrowserCrypto.WebCrypto, crypto)))
27
28const assertPlatformError = (
29 exit: Exit.Exit<Uint8Array, PlatformError.PlatformError>,
30 description: string
31) => {
32 assert.ok(Exit.isFailure(exit))
33 assert.strictEqual(exit.cause.reasons.length, 1)
34 const reason = exit.cause.reasons[0]
35 assert.ok(Cause.isFailReason(reason))
36 assert.strictEqual(reason.error._tag, "PlatformError")
37 assert.instanceOf(reason.error.reason, PlatformError.SystemError)
38 assert.strictEqual(reason.error.reason._tag, "Unknown")
39 assert.strictEqual(reason.error.reason.module, "Crypto")
40 assert.strictEqual(reason.error.reason.method, "digest")
41 assert.strictEqual(reason.error.reason.description, description)
42 return reason.error
43}
44
45const checkUnavailable = (crypto: globalThis.Crypto) =>
46 Effect.gen(function*() {
47 const service = yield* Crypto.Crypto
48 const exit = yield* Effect.exit(Effect.suspend(() => service.digest("SHA-256", new Uint8Array())))
49 assertPlatformError(exit, "crypto.subtle.digest is not available")
50 }).pipe(Effect.provide(layerWith(crypto)))
51
52describe("BrowserCrypto", () => {
53 it.effect("generates random bytes at and above the getRandomValues limit", () => {
54 const chunks: Array<number> = []
55
56 return Effect.gen(function*() {
57 const crypto = yield* Crypto.Crypto
58 for (const size of [65_536, 65_537, 70_000]) {
59 const bytes = yield* crypto.randomBytes(size)
60 assert.strictEqual(bytes.length, size)
61 assert.strictEqual(bytes[0], 0)
62 assert.strictEqual(bytes[size - 1], (size - 1) & 0xff)
63 }
64 assert.deepStrictEqual(chunks, [65_536, 65_536, 1, 65_536, 4_464])
65 }).pipe(Effect.provide(BrowserCrypto.layer.pipe(
66 Layer.provide(Layer.succeed(BrowserCrypto.WebCrypto, {
67 ...crypto,
68 getRandomValues<T extends ArrayBufferView | null>(array: T): T {
69 if (array !== null) {
70 assert.ok(array.byteLength <= 65_536)
71 chunks.push(array.byteLength)
72 }
73 return getRandomValues(array)
74 }
75 }))
76 )))
77 })
78
79 it.effect("generates UUIDv4 values from getRandomValues", () =>
80 Effect.gen(function*() {
81 const crypto = yield* Crypto.Crypto
82 const uuid = yield* crypto.randomUUIDv4
83 assert.strictEqual(uuid, "00010203-0405-4607-8809-0a0b0c0d0e0f")
84 assert.match(uuid, uuidV4Regex)
85 }).pipe(Effect.provide(BrowserCrypto.layer.pipe(
86 Layer.provide(Layer.succeed(BrowserCrypto.WebCrypto, {
87 ...crypto,
88 getRandomValues
89 }))
90 ))))
91
92 it.effect("generates UUIDv7 values from getRandomValues and the Clock", () =>
93 Effect.gen(function*() {
94 yield* TestClock.setTime(0x0123456789ab)
95 const crypto = yield* Crypto.Crypto
96 const uuid = yield* crypto.randomUUIDv7
97 assert.strictEqual(uuid, "01234567-89ab-7607-8809-0a0b0c0d0e0f")
98 assert.match(uuid, uuidV7Regex)
99 }).pipe(Effect.provide(BrowserCrypto.layer.pipe(
100 Layer.provide(Layer.succeed(BrowserCrypto.WebCrypto, {
101 ...crypto,
102 getRandomValues
103 }))
104 ))))
105
106 it.effect("computes digests with subtle crypto", () => {
107 const buffer = new ArrayBuffer(3)
108 new Uint8Array(buffer).set([1, 2, 3])
109
110 return Effect.gen(function*() {
111 const crypto = yield* Crypto.Crypto
112 const digest = yield* crypto.digest("SHA-256", new Uint8Array(buffer))
113 assert.deepStrictEqual(digest, new Uint8Array([1, 2, 3]))
114 }).pipe(
115 Effect.provide(BrowserCrypto.layer.pipe(
116 Layer.provide(Layer.succeed(BrowserCrypto.WebCrypto, {
117 ...crypto,
118 subtle: {
119 ...crypto.subtle,
120 digest() {
121 return Promise.resolve(buffer)
122 }
123 }
124 }))
125 ))
126 )
127 })
128
129 it.effect("fails with PlatformError when subtle is absent", () => checkUnavailable(withSubtle(undefined)))
130
131 it.effect("fails with PlatformError when subtle exists but digest is absent", () =>
132 checkUnavailable(withSubtle(Object.create(globalThis.crypto.subtle, { digest: { value: undefined } }))))
133
134 it.effect("computes a real SHA-256 digest when subtle is present", () =>
135 Effect.gen(function*() {
136 const service = yield* Crypto.Crypto
137 const bytes = yield* service.digest("SHA-256", new TextEncoder().encode("abc"))
138 const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")
139 assert.strictEqual(hex, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
140 }).pipe(Effect.provide(layerWith(withSubtle(webcrypto.subtle)))))
141
142 it.effect("preserves a rejected digest promise as a typed PlatformError", () => {
143 const rejection = new Error("Digest rejected by fixture")
144 const subtle = Object.create(globalThis.crypto.subtle, {
145 digest: { value: () => Promise.reject(rejection) }
146 })
147 return Effect.gen(function*() {
148 const service = yield* Crypto.Crypto
149 const exit = yield* Effect.exit(service.digest("SHA-256", new Uint8Array()))
150 const error = assertPlatformError(exit, "Could not compute digest")
151 assert.strictEqual(error.reason.cause, rejection)
152 }).pipe(Effect.provide(layerWith(withSubtle(subtle))))
153 })
154
155 it.effect("synthetic control: fails with PlatformError when subtle is null", () => checkUnavailable(withSubtle(null)))
156
157 it.effect("still dies when the entire Web Crypto object is absent", () => {
158 const fixture = { crypto: globalThis.crypto }
159 Object.defineProperty(fixture, "crypto", { value: undefined })
160 return Effect.gen(function*() {
161 const exit = yield* Effect.exit(Crypto.Crypto.pipe(Effect.provide(layerWith(fixture.crypto))))
162 assert.ok(Exit.isFailure(exit))
163 assert.strictEqual(exit.cause.reasons.length, 1)
164 const reason = exit.cause.reasons[0]
165 assert.ok(Cause.isDieReason(reason))
166 assert.ok(reason.defect instanceof Error)
167 assert.strictEqual(reason.defect.message, "Web Crypto API is not available")
168 })
169 })
170})