@yielded/jose 0.1.0-beta.19

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/src/Jwe.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { Aead } from "@yielded/crypto/Aead";
2
+ import { CryptoUnavailable, type OperationError } from "@yielded/crypto/Errors";
3
+ import { Crypto, Effect, Redacted, Schema } from "effect";
4
+ import { Base64Url } from "effect/encoding";
5
+
6
+ import { DecryptionFailed, InvalidKey, InvalidToken } from "./Errors";
7
+ import {
8
+ decode,
9
+ json,
10
+ JsonObject,
11
+ parse,
12
+ reveal,
13
+ split,
14
+ stringify,
15
+ utf8,
16
+ } from "./internal/encoding";
17
+ import * as KeyPolicy from "./internal/keyPolicy";
18
+ import type { SecretKey } from "./Jwk";
19
+
20
+ export const Header = Schema.Struct({
21
+ alg: Schema.Literal("dir"),
22
+ enc: Schema.Literal("A256GCM"),
23
+ kid: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(256))),
24
+ typ: Schema.optionalKey(Schema.NonEmptyString),
25
+ cty: Schema.optionalKey(Schema.NonEmptyString),
26
+ });
27
+
28
+ export type Header = typeof Header.Type;
29
+ export type JweError = InvalidToken | InvalidKey | OperationError | DecryptionFailed;
30
+
31
+ const readHeader = Effect.fnUntraced(function* (input: unknown) {
32
+ const raw = yield* parse(JsonObject, input, "header");
33
+
34
+ if (raw.crit !== undefined || raw.zip !== undefined)
35
+ return yield* InvalidToken.make({ reason: "header" });
36
+
37
+ return yield* parse(Header, raw, "header");
38
+ });
39
+
40
+ /** Generates a fresh 96-bit IV; random-source failures become CryptoUnavailable. */
41
+ export const encrypt = Effect.fnUntraced(function* (
42
+ plaintext: Redacted.Redacted<Uint8Array>,
43
+ key: SecretKey,
44
+ input: Header,
45
+ ): Effect.fn.Return<Redacted.Redacted<string>, JweError, Aead | Crypto.Crypto> {
46
+ const header = yield* readHeader(input);
47
+
48
+ if (key.algorithm !== "dir") return yield* InvalidKey.make({});
49
+ yield* KeyPolicy.check(yield* reveal(key.jwk), "dir", "encrypt");
50
+ yield* parse(
51
+ Schema.Uint8Array.check(Schema.isMaxLength(45000)),
52
+ yield* reveal(plaintext),
53
+ "payload",
54
+ );
55
+
56
+ const protectedPart = Base64Url.encode(
57
+ yield* stringify(header).pipe(Effect.mapError(() => InvalidToken.make({ reason: "header" }))),
58
+ );
59
+
60
+ if (protectedPart.length > 4096) return yield* InvalidToken.make({ reason: "header" });
61
+ const random = yield* Crypto.Crypto;
62
+
63
+ const nonce = yield* random
64
+ .randomBytes(12)
65
+ .pipe(Effect.mapError(() => CryptoUnavailable.make({})));
66
+
67
+ const aead = yield* Aead;
68
+
69
+ const sealed = yield* aead.encrypt({
70
+ algorithm: "AES-256-GCM",
71
+ key: key.material,
72
+ nonce,
73
+ plaintext,
74
+ additionalData: utf8(protectedPart),
75
+ });
76
+
77
+ return Redacted.make(
78
+ `${protectedPart}..${Base64Url.encode(nonce)}.${Base64Url.encode(sealed.subarray(0, -16))}.${Base64Url.encode(sealed.subarray(-16))}`,
79
+ );
80
+ });
81
+
82
+ export const decrypt = Effect.fnUntraced(function* (
83
+ token: Redacted.Redacted<string>,
84
+ key: SecretKey,
85
+ ) {
86
+ const [protectedPart, encryptedKey, iv, ciphertext, tag] = yield* split(token, 5);
87
+
88
+ if (encryptedKey !== "" || protectedPart.length > 4096)
89
+ return yield* InvalidToken.make({ reason: "serialization" });
90
+ const protectedHeader = yield* readHeader(yield* json(yield* decode(protectedPart), "header"));
91
+
92
+ if (key.algorithm !== "dir") return yield* InvalidKey.make({});
93
+ yield* KeyPolicy.check(yield* reveal(key.jwk), "dir", "decrypt");
94
+ const nonce = yield* decode(iv);
95
+ const data = yield* decode(ciphertext);
96
+ const authTag = yield* decode(tag);
97
+
98
+ if (nonce.length !== 12 || authTag.length !== 16)
99
+ return yield* InvalidToken.make({ reason: "serialization" });
100
+ const sealed = new Uint8Array(data.length + authTag.length);
101
+
102
+ sealed.set(data);
103
+ sealed.set(authTag, data.length);
104
+ const aead = yield* Aead;
105
+
106
+ const plaintext = yield* aead
107
+ .decrypt({
108
+ algorithm: "AES-256-GCM",
109
+ key: key.material,
110
+ nonce,
111
+ ciphertext: sealed,
112
+ additionalData: utf8(protectedPart),
113
+ })
114
+ .pipe(Effect.catchTag("CryptoAuthenticationFailed", () => DecryptionFailed.make({})));
115
+
116
+ return { protectedHeader, plaintext };
117
+ });
package/src/Jwk.ts ADDED
@@ -0,0 +1,269 @@
1
+ import * as Signature from "@yielded/crypto/Signature";
2
+ import { Effect, Redacted, Schema } from "effect";
3
+
4
+ import { InvalidKey } from "./Errors";
5
+ import { algorithms } from "./internal/algorithms";
6
+ import { decode, reveal } from "./internal/encoding";
7
+ import * as KeyInput from "./internal/keyInput";
8
+
9
+ export const AsymmetricAlgorithm = Schema.Literals(["RS256", "PS256", "ES256", "EdDSA"]);
10
+ export type AsymmetricAlgorithm = typeof AsymmetricAlgorithm.Type;
11
+ export const Algorithm = Schema.Literals(["HS256", "RS256", "PS256", "ES256", "EdDSA"]);
12
+ export type Algorithm = typeof Algorithm.Type;
13
+
14
+ const encodedBytes = Schema.String.check(
15
+ Schema.isPattern(/^[A-Za-z0-9_-]+$/),
16
+ Schema.isMaxLength(16384),
17
+ );
18
+
19
+ const operations = Schema.Array(Schema.NonEmptyString).check(
20
+ Schema.isMaxLength(KeyInput.maxArrayLength),
21
+ Schema.makeFilter((values) => new Set(values).size === values.length),
22
+ );
23
+
24
+ const metadata = {
25
+ kid: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(256))),
26
+ alg: Schema.optionalKey(Schema.NonEmptyString),
27
+ use: Schema.optionalKey(Schema.Literals(["sig", "enc"])),
28
+ key_ops: Schema.optionalKey(operations),
29
+ };
30
+
31
+ const rsa = { ...metadata, kty: Schema.Literal("RSA"), n: encodedBytes, e: encodedBytes };
32
+
33
+ const ec = {
34
+ ...metadata,
35
+ kty: Schema.Literal("EC"),
36
+ crv: Schema.Literal("P-256"),
37
+ x: encodedBytes,
38
+ y: encodedBytes,
39
+ };
40
+
41
+ const okp = {
42
+ ...metadata,
43
+ kty: Schema.Literal("OKP"),
44
+ crv: Schema.Literal("Ed25519"),
45
+ x: encodedBytes,
46
+ };
47
+
48
+ const noSecret = { d: Schema.optionalKey(Schema.Never), k: Schema.optionalKey(Schema.Never) };
49
+
50
+ export const PublicJwk = Schema.Union([
51
+ Schema.Struct({ ...rsa, ...noSecret }),
52
+ Schema.Struct({ ...ec, ...noSecret }),
53
+ Schema.Struct({ ...okp, ...noSecret }),
54
+ ]);
55
+
56
+ export type PublicJwk = typeof PublicJwk.Type;
57
+
58
+ export const PrivateJwk = Schema.Union([
59
+ Schema.Struct({
60
+ ...rsa,
61
+ d: encodedBytes,
62
+ p: encodedBytes,
63
+ q: encodedBytes,
64
+ dp: encodedBytes,
65
+ dq: encodedBytes,
66
+ qi: encodedBytes,
67
+ oth: Schema.optionalKey(Schema.Never),
68
+ }),
69
+ Schema.Struct({ ...ec, d: encodedBytes }),
70
+ Schema.Struct({ ...okp, d: encodedBytes }),
71
+ ]);
72
+
73
+ export type PrivateJwk = typeof PrivateJwk.Type;
74
+
75
+ export const SecretJwk = Schema.Struct({
76
+ ...metadata,
77
+ kty: Schema.Literal("oct"),
78
+ k: encodedBytes,
79
+ });
80
+
81
+ export type SecretJwk = typeof SecretJwk.Type;
82
+
83
+ /** Validated, algorithm-bound key snapshots. Private material remains Redacted. */
84
+ export interface PublicKey {
85
+ readonly _tag: "PublicKey";
86
+ readonly algorithm: AsymmetricAlgorithm;
87
+ readonly jwk: PublicJwk;
88
+ readonly material: Uint8Array;
89
+ }
90
+
91
+ export interface PrivateKey {
92
+ readonly _tag: "PrivateKey";
93
+ readonly algorithm: AsymmetricAlgorithm;
94
+ readonly jwk: Redacted.Redacted<PrivateJwk>;
95
+ readonly material: Redacted.Redacted<Uint8Array>;
96
+ }
97
+
98
+ export interface SecretKey {
99
+ readonly _tag: "SecretKey";
100
+ readonly algorithm: "HS256" | "dir";
101
+ readonly jwk: Redacted.Redacted<SecretJwk>;
102
+ readonly material: Redacted.Redacted<Uint8Array>;
103
+ }
104
+
105
+ export type SigningKey = PrivateKey | SecretKey;
106
+ export type VerificationKey = PublicKey | SecretKey;
107
+
108
+ const read = <S extends Schema.Constraint>(schema: S, input: unknown) =>
109
+ Effect.try({
110
+ try: () => KeyInput.jwk(input),
111
+ catch: () => InvalidKey.make({}),
112
+ }).pipe(
113
+ Effect.flatMap(Effect.fromResult),
114
+ Effect.flatMap((captured) =>
115
+ Schema.decodeUnknownEffect(schema)(captured, { reportInput: false }).pipe(
116
+ Effect.mapError(() => InvalidKey.make({})),
117
+ ),
118
+ ),
119
+ Effect.map(KeyInput.detach),
120
+ );
121
+
122
+ const bytes = (value: string) => decode(value).pipe(Effect.mapError(() => InvalidKey.make({})));
123
+
124
+ const snapshot = <A extends PublicJwk | PrivateJwk | SecretJwk>(jwk: A): A => {
125
+ if (jwk.key_ops !== undefined) Object.freeze(jwk.key_ops);
126
+
127
+ return Object.freeze(jwk);
128
+ };
129
+
130
+ const publicParameters = Effect.fnUntraced(function* (
131
+ jwk: PublicJwk | PrivateJwk,
132
+ algorithm: AsymmetricAlgorithm,
133
+ ): Effect.fn.Return<Signature.PublicKeyParameters, InvalidKey> {
134
+ if (jwk.alg !== undefined && jwk.alg !== algorithm) return yield* InvalidKey.make({});
135
+ switch (jwk.kty) {
136
+ case "EC":
137
+ if (algorithm !== "ES256") return yield* InvalidKey.make({});
138
+
139
+ return { algorithm: "ECDSA-P256-SHA256", x: yield* bytes(jwk.x), y: yield* bytes(jwk.y) };
140
+ case "OKP":
141
+ if (algorithm !== "EdDSA") return yield* InvalidKey.make({});
142
+
143
+ return { algorithm: "Ed25519", x: yield* bytes(jwk.x) };
144
+ case "RSA":
145
+ if (algorithm !== "RS256" && algorithm !== "PS256") return yield* InvalidKey.make({});
146
+
147
+ return { algorithm: algorithms[algorithm], n: yield* bytes(jwk.n), e: yield* bytes(jwk.e) };
148
+ }
149
+ });
150
+
151
+ export const importPublic = Effect.fnUntraced(function* (
152
+ input: unknown,
153
+ selected: AsymmetricAlgorithm,
154
+ ) {
155
+ const algorithm = yield* read(AsymmetricAlgorithm, selected);
156
+ const jwk = yield* read(PublicJwk, input);
157
+ const parameters = yield* publicParameters(jwk, algorithm);
158
+ const signatures = yield* Signature.Signature;
159
+
160
+ const material = yield* signatures
161
+ .encodePublicKey(parameters)
162
+ .pipe(Effect.catchTag("CryptoInvalidInput", () => InvalidKey.make({})));
163
+
164
+ return Object.freeze({
165
+ _tag: "PublicKey",
166
+ algorithm,
167
+ jwk: snapshot(jwk),
168
+ material,
169
+ }) satisfies PublicKey;
170
+ });
171
+
172
+ export const importPrivate = Effect.fnUntraced(function* (
173
+ input: Redacted.Redacted<unknown>,
174
+ selected: AsymmetricAlgorithm,
175
+ ) {
176
+ const algorithm = yield* read(AsymmetricAlgorithm, selected);
177
+
178
+ const jwk = yield* read(
179
+ PrivateJwk,
180
+ yield* reveal(input).pipe(Effect.mapError(() => InvalidKey.make({}))),
181
+ );
182
+
183
+ const publicPart = yield* publicParameters(jwk, algorithm);
184
+ const d = yield* bytes(jwk.d);
185
+
186
+ const parameters = yield* read(Signature.PrivateKeyParameters, {
187
+ ...publicPart,
188
+ d,
189
+ ...(jwk.kty === "RSA"
190
+ ? {
191
+ p: yield* bytes(jwk.p),
192
+ q: yield* bytes(jwk.q),
193
+ dp: yield* bytes(jwk.dp),
194
+ dq: yield* bytes(jwk.dq),
195
+ qi: yield* bytes(jwk.qi),
196
+ }
197
+ : {}),
198
+ });
199
+
200
+ const signatures = yield* Signature.Signature;
201
+
202
+ const material = yield* signatures.encodePrivateKey(Redacted.make(parameters)).pipe(
203
+ Effect.catchTag("CryptoInvalidInput", () => InvalidKey.make({})),
204
+ Effect.ensuring(
205
+ Effect.sync(() => {
206
+ parameters.d.fill(0);
207
+ if ("p" in parameters)
208
+ for (const part of [
209
+ parameters.p,
210
+ parameters.q,
211
+ parameters.dp,
212
+ parameters.dq,
213
+ parameters.qi,
214
+ ])
215
+ part.fill(0);
216
+ }),
217
+ ),
218
+ );
219
+
220
+ return Object.freeze({
221
+ _tag: "PrivateKey",
222
+ algorithm,
223
+ jwk: Redacted.make(snapshot(jwk)),
224
+ material,
225
+ }) satisfies PrivateKey;
226
+ });
227
+
228
+ export const importSecret = Effect.fnUntraced(function* (
229
+ input: Redacted.Redacted<unknown>,
230
+ selected: "HS256" | "dir",
231
+ ) {
232
+ const algorithm = yield* read(Schema.Literals(["HS256", "dir"]), selected);
233
+
234
+ const jwk = yield* read(
235
+ SecretJwk,
236
+ yield* reveal(input).pipe(Effect.mapError(() => InvalidKey.make({}))),
237
+ );
238
+
239
+ const material = yield* bytes(jwk.k);
240
+
241
+ if (
242
+ (jwk.alg !== undefined && jwk.alg !== algorithm) ||
243
+ material.length < 32 ||
244
+ (algorithm === "dir" && material.length !== 32)
245
+ ) {
246
+ material.fill(0);
247
+
248
+ return yield* InvalidKey.make({});
249
+ }
250
+
251
+ return Object.freeze({
252
+ _tag: "SecretKey",
253
+ algorithm,
254
+ jwk: Redacted.make(snapshot(jwk)),
255
+ material: Redacted.make(material),
256
+ }) satisfies SecretKey;
257
+ });
258
+
259
+ export const exportPublic = Effect.fnUntraced(function* (key: PublicKey) {
260
+ return yield* read(PublicJwk, key.jwk);
261
+ });
262
+
263
+ export const exportPrivate = Effect.fnUntraced(function* (key: PrivateKey | SecretKey) {
264
+ const value = yield* reveal<PrivateJwk | SecretJwk>(key.jwk).pipe(
265
+ Effect.mapError(() => InvalidKey.make({})),
266
+ );
267
+
268
+ return Redacted.make(yield* read(Schema.Union([PrivateJwk, SecretJwk]), value));
269
+ });