@yielded/crypto 0.1.0-beta.28 → 0.1.0-beta.30

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.
@@ -1,16 +1,28 @@
1
1
  import { Effect, Redacted, Schema } from "effect";
2
2
 
3
- import { Aead, DecryptInput, EncryptInput } from "../Aead";
3
+ import {
4
+ Aead,
5
+ DecryptInput,
6
+ EncryptInput,
7
+ type Key,
8
+ KeyDecryptInput,
9
+ KeyEncryptInput,
10
+ KeyInput,
11
+ } from "../Aead";
4
12
  import { AuthenticationFailed, type OperationError, UnsupportedAlgorithm } from "../Errors";
5
13
  import { copy, decode, importError, nativeError, nativeErrorName, withSecret } from "./common";
14
+ import { makeScopedKey } from "./scoped-key";
6
15
 
7
- interface CipherInput {
8
- readonly key: Uint8Array<ArrayBuffer>;
16
+ interface CipherData {
9
17
  readonly nonce: Uint8Array<ArrayBuffer>;
10
18
  readonly additionalData: Uint8Array<ArrayBuffer>;
11
19
  readonly data: Uint8Array<ArrayBuffer>;
12
20
  }
13
21
 
22
+ interface CipherInput extends CipherData {
23
+ readonly key: Uint8Array<ArrayBuffer>;
24
+ }
25
+
14
26
  export interface XChaCha {
15
27
  readonly encrypt: (input: CipherInput) => Effect.Effect<Uint8Array, OperationError>;
16
28
  readonly decrypt: (
@@ -19,9 +31,86 @@ export interface XChaCha {
19
31
  }
20
32
 
21
33
  const keyBytes = Schema.Uint8Array.check(Schema.isBetweenLength(32, 32));
34
+ const aesNonce = Schema.Uint8Array.check(Schema.isBetweenLength(12, 12));
35
+
36
+ export const makeAead = (subtle: SubtleCrypto, xchacha?: XChaCha): Aead["Service"] => {
37
+ const encryptAes = (key: CryptoKey, input: CipherData) =>
38
+ Effect.tryPromise({
39
+ try: () =>
40
+ subtle.encrypt(
41
+ {
42
+ name: "AES-GCM",
43
+ iv: input.nonce,
44
+ additionalData: input.additionalData,
45
+ tagLength: 128,
46
+ },
47
+ key,
48
+ input.data,
49
+ ),
50
+ catch: nativeError,
51
+ }).pipe(Effect.map((encrypted) => new Uint8Array(encrypted)));
52
+
53
+ const decryptAes = (key: CryptoKey, input: CipherData) =>
54
+ Effect.tryPromise({
55
+ try: () =>
56
+ subtle.decrypt(
57
+ {
58
+ name: "AES-GCM",
59
+ iv: input.nonce,
60
+ additionalData: input.additionalData,
61
+ tagLength: 128,
62
+ },
63
+ key,
64
+ input.data,
65
+ ),
66
+ // WebCrypto specifies OperationError for a rejected GCM tag.
67
+ catch: (cause) =>
68
+ nativeErrorName(cause) === "OperationError"
69
+ ? AuthenticationFailed.make({})
70
+ : nativeError(cause),
71
+ }).pipe(Effect.map((decrypted) => new Uint8Array(decrypted)));
72
+
73
+ return Aead.of({
74
+ importKey: Effect.fnUntraced(function* (input) {
75
+ const value = yield* decode(KeyInput, input, "key");
76
+
77
+ yield* decode(keyBytes, Redacted.value(value.key), "key");
78
+
79
+ const use = yield* makeScopedKey(
80
+ withSecret(value.key, (material) =>
81
+ Effect.tryPromise({
82
+ try: () => subtle.importKey("raw", material, "AES-GCM", false, ["encrypt", "decrypt"]),
83
+ catch: importError,
84
+ }),
85
+ ),
86
+ );
22
87
 
23
- export const makeAead = (subtle: SubtleCrypto, xchacha?: XChaCha): Aead["Service"] =>
24
- Aead.of({
88
+ return {
89
+ encrypt: Effect.fnUntraced(function* (input) {
90
+ const value = yield* decode(KeyEncryptInput, input, "data");
91
+ const nonce = yield* decode(aesNonce, value.nonce, "nonce").pipe(Effect.flatMap(copy));
92
+ const additionalData = yield* copy(value.additionalData ?? new Uint8Array());
93
+
94
+ return yield* withSecret(value.plaintext, (data) =>
95
+ use((key) => encryptAes(key, { nonce, additionalData, data })),
96
+ );
97
+ }),
98
+ decrypt: Effect.fnUntraced(function* (input) {
99
+ const value = yield* decode(KeyDecryptInput, input, "data");
100
+ const nonce = yield* decode(aesNonce, value.nonce, "nonce").pipe(Effect.flatMap(copy));
101
+ const additionalData = yield* copy(value.additionalData ?? new Uint8Array());
102
+ const data = yield* copy(value.ciphertext);
103
+
104
+ const plaintext = yield* use((key) =>
105
+ data.length < 16
106
+ ? Effect.fail(AuthenticationFailed.make({}))
107
+ : decryptAes(key, { nonce, additionalData, data }),
108
+ );
109
+
110
+ return Redacted.make(plaintext);
111
+ }),
112
+ } satisfies Key;
113
+ }),
25
114
  encrypt: Effect.fnUntraced(function* (input) {
26
115
  const value = yield* decode(EncryptInput, input, "data");
27
116
  const nonceLength = value.algorithm === "AES-256-GCM" ? 12 : 24;
@@ -51,17 +140,7 @@ export const makeAead = (subtle: SubtleCrypto, xchacha?: XChaCha): Aead["Service
51
140
  catch: importError,
52
141
  });
53
142
 
54
- const encrypted = yield* Effect.tryPromise({
55
- try: () =>
56
- subtle.encrypt(
57
- { name: "AES-GCM", iv: nonce, additionalData, tagLength: 128 },
58
- imported,
59
- data,
60
- ),
61
- catch: nativeError,
62
- });
63
-
64
- return new Uint8Array(encrypted);
143
+ return yield* encryptAes(imported, { nonce, additionalData, data });
65
144
  }).pipe(Effect.uninterruptible);
66
145
  }),
67
146
  );
@@ -96,24 +175,11 @@ export const makeAead = (subtle: SubtleCrypto, xchacha?: XChaCha): Aead["Service
96
175
  catch: importError,
97
176
  });
98
177
 
99
- const decrypted = yield* Effect.tryPromise({
100
- try: () =>
101
- subtle.decrypt(
102
- { name: "AES-GCM", iv: nonce, additionalData, tagLength: 128 },
103
- imported,
104
- data,
105
- ),
106
- // WebCrypto specifies OperationError for a rejected GCM tag.
107
- catch: (cause) =>
108
- nativeErrorName(cause) === "OperationError"
109
- ? AuthenticationFailed.make({})
110
- : nativeError(cause),
111
- });
112
-
113
- return new Uint8Array(decrypted);
178
+ return yield* decryptAes(imported, { nonce, additionalData, data });
114
179
  }).pipe(Effect.uninterruptible);
115
180
  });
116
181
 
117
182
  return Redacted.make(plaintext);
118
183
  }),
119
184
  });
185
+ };
@@ -1,20 +1,29 @@
1
- import { Effect } from "effect";
1
+ import { Clock, Effect } from "effect";
2
2
 
3
3
  import { allocate, destroy, fill, initialize, output } from "./argon2-core";
4
4
  import type { Argon2 } from "./kdf";
5
5
 
6
- /** Effect schedules bounded batches; admission remains held through buffer cleanup. */
6
+ /** Effect schedules time slices; admission remains held through buffer cleanup. */
7
7
  export const argon2: Argon2 = (input) =>
8
8
  Effect.acquireUseRelease(
9
9
  Effect.sync(() => allocate(input)),
10
10
  (state) =>
11
11
  Effect.gen(function* () {
12
+ const clock = yield* Clock.Clock;
13
+
12
14
  initialize(state, input);
13
15
  const blocks = fill(state, input);
14
16
 
15
17
  for (;;) {
16
- for (let batch = 0; batch < 256; batch++) {
17
- if (blocks.next().done) return output(state, input);
18
+ const started = clock.monotonicTimeNanosUnsafe();
19
+
20
+ // Workers freeze clocks during CPU work. Cap each slice as well so
21
+ // interruption and other fibers still get a turn on those runtimes.
22
+ for (let processed = 0; processed < 2048; processed += 256) {
23
+ for (let batch = 0; batch < 256; batch++) {
24
+ if (blocks.next().done) return output(state, input);
25
+ }
26
+ if (clock.monotonicTimeNanosUnsafe() - started >= 8_000_000n) break;
18
27
  }
19
28
  yield* Effect.yieldNow;
20
29
  }
@@ -1,8 +1,8 @@
1
- import { Effect, Exit, Fiber, Redacted, Schema, Scope } from "effect";
1
+ import { Effect, Redacted, Schema } from "effect";
2
2
 
3
- import { CryptoUnavailable } from "../Errors";
4
3
  import { Hmac, Input, type Key, KeyInput, VerifyInput } from "../Hmac";
5
4
  import { copy, decode, importError, nativeError, withSecret } from "./common";
5
+ import { makeScopedKey } from "./scoped-key";
6
6
 
7
7
  const keyBytes = Schema.Uint8Array.check(Schema.isMinLength(1));
8
8
 
@@ -12,37 +12,7 @@ export const makeHmac = (subtle: SubtleCrypto): Hmac["Service"] => {
12
12
 
13
13
  yield* decode(keyBytes, Redacted.value(value.key), "key");
14
14
 
15
- // A sequential child joins every native call before dropping the key, even
16
- // when the caller's Scope runs its other finalizers in parallel.
17
- const scope = yield* Scope.fork(yield* Effect.scope, "sequential");
18
- let imported: CryptoKey | undefined;
19
-
20
- yield* Scope.addFinalizer(
21
- scope,
22
- Effect.sync(() => {
23
- imported = undefined;
24
- }),
25
- );
26
-
27
- const available = Effect.suspend(() =>
28
- scope.state._tag === "Closed" ? Effect.fail(CryptoUnavailable.make({})) : Effect.void,
29
- );
30
-
31
- const run = <A, E>(operation: Effect.Effect<A, E>) =>
32
- Effect.acquireUseRelease(
33
- available.pipe(Effect.andThen(Effect.forkIn(operation, scope, { uninterruptible: false }))),
34
- (fiber) =>
35
- Effect.gen(function* () {
36
- const exit = yield* Fiber.await(fiber);
37
-
38
- yield* available;
39
-
40
- return yield* exit;
41
- }),
42
- Fiber.interrupt,
43
- );
44
-
45
- yield* run(
15
+ const use = yield* makeScopedKey(
46
16
  withSecret(value.key, (material) =>
47
17
  Effect.tryPromise({
48
18
  try: () =>
@@ -51,41 +21,30 @@ export const makeHmac = (subtle: SubtleCrypto): Hmac["Service"] => {
51
21
  "verify",
52
22
  ]),
53
23
  catch: importError,
54
- }).pipe(
55
- Effect.tap((key) =>
56
- Effect.sync(() => {
57
- imported = key;
58
- }),
59
- ),
60
- Effect.uninterruptible,
61
- ),
24
+ }),
62
25
  ),
63
- ).pipe(
64
- Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(scope, exit) : Effect.void)),
65
26
  );
66
27
 
67
- const use = <A>(operation: (key: CryptoKey) => Promise<A>) =>
68
- run(
69
- Effect.suspend(() => {
70
- const key = imported;
71
-
72
- return key === undefined
73
- ? Effect.fail(CryptoUnavailable.make({}))
74
- : Effect.tryPromise({ try: () => operation(key), catch: nativeError });
75
- }).pipe(Effect.uninterruptible),
76
- );
77
-
78
28
  return {
79
29
  sign: Effect.fnUntraced(function* (input) {
80
30
  const data = yield* decode(Schema.Uint8Array, input, "data").pipe(Effect.flatMap(copy));
81
31
 
82
- return new Uint8Array(yield* use((key) => subtle.sign("HMAC", key, data)));
32
+ return new Uint8Array(
33
+ yield* use((key) =>
34
+ Effect.tryPromise({ try: () => subtle.sign("HMAC", key, data), catch: nativeError }),
35
+ ),
36
+ );
83
37
  }),
84
38
  verify: Effect.fnUntraced(function* (input, signature) {
85
39
  const data = yield* decode(Schema.Uint8Array, input, "data").pipe(Effect.flatMap(copy));
86
40
  const tag = yield* decode(Schema.Uint8Array, signature, "data").pipe(Effect.flatMap(copy));
87
41
 
88
- return yield* use((key) => subtle.verify("HMAC", key, tag, data));
42
+ return yield* use((key) =>
43
+ Effect.tryPromise({
44
+ try: () => subtle.verify("HMAC", key, tag, data),
45
+ catch: nativeError,
46
+ }),
47
+ );
89
48
  }),
90
49
  } satisfies Key;
91
50
  });
@@ -0,0 +1,60 @@
1
+ import { Effect, Exit, Fiber, Scope } from "effect";
2
+
3
+ import { CryptoUnavailable, type OperationError } from "../Errors";
4
+
5
+ /** Join native calls before releasing a key, including parallel parent closure. */
6
+ export const makeScopedKey = Effect.fnUntraced(function* (
7
+ acquire: Effect.Effect<CryptoKey, OperationError>,
8
+ ) {
9
+ const scope = yield* Scope.fork(yield* Effect.scope, "sequential");
10
+ let imported: CryptoKey | undefined;
11
+
12
+ yield* Scope.addFinalizer(
13
+ scope,
14
+ Effect.sync(() => {
15
+ imported = undefined;
16
+ }),
17
+ );
18
+
19
+ const available = Effect.suspend(() =>
20
+ scope.state._tag === "Closed" ? Effect.fail(CryptoUnavailable.make({})) : Effect.void,
21
+ );
22
+
23
+ const run = <A, E>(operation: Effect.Effect<A, E>) =>
24
+ Effect.acquireUseRelease(
25
+ available.pipe(
26
+ Effect.andThen(
27
+ Effect.forkIn(operation, scope, { uninterruptible: false, startImmediately: true }),
28
+ ),
29
+ ),
30
+ (fiber) =>
31
+ Effect.gen(function* () {
32
+ const exit = yield* Fiber.await(fiber);
33
+
34
+ yield* available;
35
+
36
+ return yield* exit;
37
+ }),
38
+ Fiber.interrupt,
39
+ );
40
+
41
+ yield* run(
42
+ acquire.pipe(
43
+ Effect.tap((key) =>
44
+ Effect.sync(() => {
45
+ imported = key;
46
+ }),
47
+ ),
48
+ Effect.uninterruptible,
49
+ ),
50
+ ).pipe(Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(scope, exit) : Effect.void)));
51
+
52
+ return <A, E>(operation: (key: CryptoKey) => Effect.Effect<A, E>) =>
53
+ run(
54
+ Effect.suspend((): Effect.Effect<A, E | CryptoUnavailable> => {
55
+ const key = imported;
56
+
57
+ return key === undefined ? Effect.fail(CryptoUnavailable.make({})) : operation(key);
58
+ }).pipe(Effect.uninterruptible),
59
+ );
60
+ });
@@ -1,16 +1,22 @@
1
1
  import { Effect, Redacted, Schema } from "effect";
2
2
  import { Base64Url } from "effect/encoding";
3
3
 
4
- import { InvalidInput } from "../Errors";
4
+ import { CryptoUnavailable, InvalidInput } from "../Errors";
5
5
  import {
6
6
  type Algorithm,
7
+ type PrivateKey,
8
+ PrivateKeyInput,
9
+ GenerateKeyPairInput,
7
10
  PrivateKeyParameters,
11
+ type PublicKey,
12
+ PublicKeyInput,
8
13
  PublicKeyParameters,
9
14
  SignInput,
10
15
  Signature,
11
16
  VerifyInput,
12
17
  } from "../Signature";
13
18
  import { copy, decode, importError, nativeError, withSecret } from "./common";
19
+ import { makeScopedKey } from "./scoped-key";
14
20
 
15
21
  const derBytes = Schema.Uint8Array.check(Schema.isMinLength(1), Schema.isMaxLength(16384));
16
22
 
@@ -18,6 +24,21 @@ const RsaAlgorithm = Schema.Struct({
18
24
  modulusLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(2048)),
19
25
  });
20
26
 
27
+ const component = Schema.Uint8ArrayFromBase64Url;
28
+ const GeneratedEc = Schema.Struct({ x: component, y: component, d: component });
29
+ const GeneratedEd = Schema.Struct({ x: component, d: component });
30
+
31
+ const GeneratedRsa = Schema.Struct({
32
+ n: component,
33
+ e: component,
34
+ d: component,
35
+ p: component,
36
+ q: component,
37
+ dp: component,
38
+ dq: component,
39
+ qi: component,
40
+ });
41
+
21
42
  const importAlgorithm = (algorithm: Algorithm) => {
22
43
  switch (algorithm) {
23
44
  case "ECDSA-P256-SHA256":
@@ -121,6 +142,124 @@ export const makeSignature = (subtle: SubtleCrypto): Signature["Service"] => {
121
142
  });
122
143
 
123
144
  return Signature.of({
145
+ importPrivateKey: Effect.fnUntraced(function* (input) {
146
+ const value = yield* decode(PrivateKeyInput, input, "key");
147
+ const algorithm = value.algorithm;
148
+
149
+ const use = yield* makeScopedKey(
150
+ withSecret(value.privateKey, (material) => importKey(algorithm, "pkcs8", material)),
151
+ );
152
+
153
+ return {
154
+ sign: Effect.fnUntraced(function* (input) {
155
+ const data = yield* decode(Schema.Uint8Array, input, "data").pipe(Effect.flatMap(copy));
156
+
157
+ const result = yield* use((key) =>
158
+ Effect.tryPromise({
159
+ try: () => subtle.sign(operationAlgorithm(algorithm), key, data),
160
+ catch: nativeError,
161
+ }),
162
+ );
163
+
164
+ return new Uint8Array(result);
165
+ }),
166
+ } satisfies PrivateKey;
167
+ }),
168
+ importPublicKey: Effect.fnUntraced(function* (input) {
169
+ const value = yield* decode(PublicKeyInput, input, "key");
170
+ const algorithm = value.algorithm;
171
+ const material = yield* copy(value.publicKey);
172
+ const use = yield* makeScopedKey(importKey(algorithm, "spki", material));
173
+
174
+ return {
175
+ verify: Effect.fnUntraced(function* (input, signature) {
176
+ const data = yield* decode(Schema.Uint8Array, input, "data").pipe(Effect.flatMap(copy));
177
+
178
+ const tag = yield* decode(Schema.Uint8Array, signature, "data").pipe(
179
+ Effect.flatMap(copy),
180
+ );
181
+
182
+ return yield* use((key) => {
183
+ if ((algorithm === "ECDSA-P256-SHA256" || algorithm === "Ed25519") && tag.length !== 64)
184
+ return Effect.succeed(false);
185
+
186
+ return Effect.tryPromise({
187
+ try: () => subtle.verify(operationAlgorithm(algorithm), key, tag, data),
188
+ catch: nativeError,
189
+ });
190
+ });
191
+ }),
192
+ } satisfies PublicKey;
193
+ }),
194
+ generateKeyPair: Effect.fnUntraced(function* (input) {
195
+ const value = yield* decode(GenerateKeyPairInput, input, "parameters");
196
+
197
+ const pair = yield* Effect.tryPromise({
198
+ try: () => {
199
+ switch (value.algorithm) {
200
+ case "ECDSA-P256-SHA256":
201
+ return subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
202
+ "sign",
203
+ "verify",
204
+ ]);
205
+ case "Ed25519":
206
+ return subtle.generateKey("Ed25519", true, ["sign", "verify"]);
207
+ case "RSASSA-PKCS1-v1_5-SHA256":
208
+ case "RSA-PSS-SHA256":
209
+ return subtle.generateKey(
210
+ {
211
+ name: value.algorithm === "RSA-PSS-SHA256" ? "RSA-PSS" : "RSASSA-PKCS1-v1_5",
212
+ hash: "SHA-256",
213
+ modulusLength: value.modulusLength ?? 2048,
214
+ publicExponent: new Uint8Array([1, 0, 1]),
215
+ },
216
+ true,
217
+ ["sign", "verify"],
218
+ );
219
+ }
220
+ },
221
+ catch: nativeError,
222
+ });
223
+
224
+ const jwk = yield* Effect.tryPromise({
225
+ try: () => subtle.exportKey("jwk", pair.privateKey),
226
+ catch: nativeError,
227
+ });
228
+
229
+ // JsonWebKey does not guarantee algorithm-specific fields. Decode only at
230
+ // this native boundary; callers receive typed components, never loose JWKs.
231
+ const parameters: PrivateKeyParameters = yield* Effect.gen(function* () {
232
+ switch (value.algorithm) {
233
+ case "ECDSA-P256-SHA256":
234
+ return {
235
+ algorithm: value.algorithm,
236
+ ...(yield* Schema.decodeUnknownEffect(GeneratedEc)(jwk, { reportInput: false })),
237
+ };
238
+ case "Ed25519":
239
+ return {
240
+ algorithm: value.algorithm,
241
+ ...(yield* Schema.decodeUnknownEffect(GeneratedEd)(jwk, { reportInput: false })),
242
+ };
243
+ case "RSASSA-PKCS1-v1_5-SHA256":
244
+ case "RSA-PSS-SHA256":
245
+ return {
246
+ algorithm: value.algorithm,
247
+ ...(yield* Schema.decodeUnknownEffect(GeneratedRsa)(jwk, { reportInput: false })),
248
+ };
249
+ }
250
+ }).pipe(Effect.mapError(() => CryptoUnavailable.make({})));
251
+
252
+ yield* decode(PrivateKeyParameters, parameters, "key");
253
+
254
+ const publicKey: PublicKeyParameters =
255
+ "n" in parameters
256
+ ? { algorithm: parameters.algorithm, n: parameters.n.slice(), e: parameters.e.slice() }
257
+ : "y" in parameters
258
+ ? { algorithm: parameters.algorithm, x: parameters.x.slice(), y: parameters.y.slice() }
259
+ : { algorithm: "Ed25519", x: parameters.x.slice() };
260
+
261
+ return { publicKey, privateKey: Redacted.make(parameters) };
262
+ }, Effect.uninterruptible),
124
263
  encodePublicKey: Effect.fnUntraced(function* (input) {
125
264
  const parameters = yield* decode(PublicKeyParameters, input, "key");
126
265
  const key = yield* importComponents(parameters, "verify");
@@ -258,6 +258,7 @@ interface State {
258
258
  readonly nonce: Uint32Array;
259
259
  readonly words: Uint32Array;
260
260
  readonly stream: Uint8Array;
261
+ readonly streamView: DataView;
261
262
  }
262
263
 
263
264
  const readWords = (data: Uint8Array, words: Uint32Array): void => {
@@ -270,11 +271,14 @@ function withState<A>(key: Uint8Array, nonce: Uint8Array, operation: (state: Sta
270
271
  const original = new Uint32Array(8);
271
272
  const extendedNonce = new Uint32Array(4);
272
273
 
274
+ const stream = new Uint8Array(64);
275
+
273
276
  const state: State = {
274
277
  key: new Uint32Array(8),
275
278
  nonce: new Uint32Array(3),
276
279
  words: new Uint32Array(16),
277
- stream: new Uint8Array(64),
280
+ stream,
281
+ streamView: new DataView(stream.buffer),
278
282
  };
279
283
 
280
284
  try {
@@ -300,7 +304,7 @@ function withState<A>(key: Uint8Array, nonce: Uint8Array, operation: (state: Sta
300
304
 
301
305
  const blockAt = (state: State, counter: number): void => {
302
306
  chachaCore(sigma, state.key, state.nonce, state.words, counter);
303
- const view = new DataView(state.stream.buffer, state.stream.byteOffset, state.stream.byteLength);
307
+ const view = state.streamView;
304
308
 
305
309
  for (let i = 0; i < 16; i++) view.setUint32(i * 4, state.words[i], true);
306
310
  };