@kokuin/jwe 0.1.0

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/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # @kokuin/jwe
2
+
3
+ ## Installation
4
+
5
+ ```sh
6
+ npm install @kokuin/jwe
7
+ ```
package/lib/index.d.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * JWE encryption for kokuin tokens.
3
+ *
4
+ * ## Installation
5
+ *
6
+ * ```sh
7
+ * npm install @kokuin/jwe
8
+ * ```
9
+ *
10
+ * @module jwe
11
+ */
12
+ import { type KeyAgreementIdentity, type MethodRegistry, type SigningIdentity, type Verifiers } from '@kokuin/token';
13
+ export type ConcatKDFParams = {
14
+ sharedSecret: Uint8Array;
15
+ keyLength: number;
16
+ algorithmID: string;
17
+ partyUInfo: Uint8Array;
18
+ partyVInfo: Uint8Array;
19
+ };
20
+ export type JWEHeader = {
21
+ alg: string;
22
+ enc: string;
23
+ epk: {
24
+ kty: string;
25
+ crv: string;
26
+ x: string;
27
+ };
28
+ apu?: string;
29
+ apv?: string;
30
+ };
31
+ export type TokenEncrypter = {
32
+ recipientID?: string;
33
+ encrypt(plaintext: Uint8Array): Promise<string>;
34
+ };
35
+ export type EncryptOptions = {
36
+ algorithm: 'X25519';
37
+ };
38
+ export type SharedSecretResult = {
39
+ /**
40
+ * The raw X25519 ECDH output. **Not** uniformly random — run it through a KDF with your own
41
+ * domain separation before using it as a key.
42
+ */
43
+ sharedSecret: Uint8Array;
44
+ /**
45
+ * Send this to the recipient. They recover the same `sharedSecret` bytes with
46
+ * `identity.agreeKey(ephemeralPublicKey)`.
47
+ */
48
+ ephemeralPublicKey: Uint8Array;
49
+ };
50
+ /**
51
+ * Concat KDF per RFC 7518 Section 4.6.2.
52
+ * Single SHA-256 iteration (sufficient for 256-bit keys).
53
+ */
54
+ export declare function concatKDF(params: ConcatKDFParams): Uint8Array;
55
+ /**
56
+ * Perform X25519 key agreement with a recipient DID, without building a JWE. Resolves the agreement
57
+ * key by the same rule `createTokenEncrypter` uses (a did:peer:4's published `keyAgreement` key, or a
58
+ * `did:key`'s birationally derived one), generates a single-use ephemeral pair, and returns the ECDH
59
+ * output; the ephemeral private key never leaves this function.
60
+ *
61
+ * The recipient recovers the identical bytes with `identity.agreeKey(ephemeralPublicKey)` and no
62
+ * `kid` — the sender always resolves the *first* published `keyAgreement` key, so a different `kid`
63
+ * on a multi-KEM identity silently recovers different bytes.
64
+ *
65
+ * Anonymous — it authenticates nothing about the sender, only that the recipient can recover the
66
+ * secret. Bind `ephemeralPublicKey` and the recipient DID into your KDF's info/context. The result is
67
+ * a raw ECDH output, not a key: run it through a KDF.
68
+ *
69
+ * ```ts
70
+ * const { sharedSecret, ephemeralPublicKey } = deriveSharedSecret(recipientDID)
71
+ * // ship ephemeralPublicKey alongside whatever the secret protects
72
+ * ```
73
+ *
74
+ * @param recipient a `did:key` EdDSA DID, or a `did:peer:4` **long form** — a short form throws, its
75
+ * agreement key living only in the long form.
76
+ */
77
+ export declare function deriveSharedSecret(recipient: string): SharedSecretResult;
78
+ /**
79
+ * Create a token encrypter for a recipient identified by X25519 public key or DID string.
80
+ */
81
+ export declare function createTokenEncrypter(recipient: Uint8Array, options: EncryptOptions): TokenEncrypter;
82
+ export declare function createTokenEncrypter(recipient: string): TokenEncrypter;
83
+ export type ResolveRecipientOptions = {
84
+ methods: MethodRegistry;
85
+ };
86
+ /**
87
+ * Async sibling of {@link deriveSharedSecret} for recipients whose agreement key must be reached
88
+ * through a registered `DIDMethodResolver` (e.g. `did:kokuin:`), in addition to the
89
+ * self-contained DIDs `deriveSharedSecret` already handles.
90
+ *
91
+ * @param recipient a DID string. Self-contained DIDs (`did:key`, `did:peer:4` long form) resolve
92
+ * the same way `deriveSharedSecret` does; any other DID is looked up in `options.methods`.
93
+ */
94
+ export declare function deriveSharedSecretAsync(recipient: string, options: ResolveRecipientOptions): Promise<SharedSecretResult>;
95
+ /**
96
+ * Async sibling of {@link createTokenEncrypter} for recipients whose agreement key must be
97
+ * reached through a registered `DIDMethodResolver` (e.g. `did:kokuin:`), in addition to the
98
+ * self-contained DIDs `createTokenEncrypter` already handles.
99
+ */
100
+ export declare function createTokenEncrypterAsync(recipient: string, options: ResolveRecipientOptions): Promise<TokenEncrypter>;
101
+ /**
102
+ * Encrypt plaintext to JWE compact serialization using the given encrypter.
103
+ */
104
+ export declare function encryptToken(encrypter: TokenEncrypter, plaintext: Uint8Array): Promise<string>;
105
+ /**
106
+ * Decrypt a JWE compact serialization string.
107
+ */
108
+ export declare function decryptToken(decrypter: KeyAgreementIdentity, jwe: string): Promise<Uint8Array>;
109
+ export type EnvelopeMode = 'plain' | 'jws' | 'jws-in-jwe' | 'jwe-in-jws';
110
+ export type UnwrappedEnvelope = {
111
+ payload: Record<string, unknown>;
112
+ mode: EnvelopeMode;
113
+ };
114
+ export type WrapOptions = {
115
+ signer?: SigningIdentity;
116
+ encrypter?: TokenEncrypter;
117
+ header?: Record<string, unknown>;
118
+ };
119
+ export type UnwrapOptions = {
120
+ decrypter?: KeyAgreementIdentity;
121
+ verifiers?: Verifiers;
122
+ };
123
+ /**
124
+ * Wrap a payload into a token string according to the specified envelope mode.
125
+ */
126
+ export declare function wrapEnvelope(mode: EnvelopeMode, payload: Record<string, unknown>, options: WrapOptions): Promise<string>;
127
+ /**
128
+ * Unwrap a token string, auto-detecting the envelope mode from its structure.
129
+ */
130
+ export declare function unwrapEnvelope(message: string, options: UnwrapOptions): Promise<UnwrappedEnvelope>;
package/lib/index.js ADDED
@@ -0,0 +1,399 @@
1
+ /**
2
+ * JWE encryption for kokuin tokens.
3
+ *
4
+ * ## Installation
5
+ *
6
+ * ```sh
7
+ * npm install @kokuin/jwe
8
+ * ```
9
+ *
10
+ * @module jwe
11
+ */ import { createUnsignedToken, decodePeer4, findMethodResolver, getAgreementKey, getPeer4ShortForm, getSignatureInfo, isPeer4, isUnsignedToken, stringifyToken, verifyToken } from '@kokuin/token';
12
+ import { gcm } from '@noble/ciphers/aes.js';
13
+ import { randomBytes } from '@noble/ciphers/utils.js';
14
+ import { ed25519, x25519 } from '@noble/curves/ed25519.js';
15
+ import { sha256 } from '@noble/hashes/sha2.js';
16
+ import { b64uFromJSON, b64uToJSON, fromB64U, toB64U } from '@sozai/codec';
17
+ function uint32BE(value) {
18
+ const buf = new Uint8Array(4);
19
+ const view = new DataView(buf.buffer);
20
+ view.setUint32(0, value, false);
21
+ return buf;
22
+ }
23
+ function lengthPrefixed(data) {
24
+ const prefix = uint32BE(data.length);
25
+ const result = new Uint8Array(4 + data.length);
26
+ result.set(prefix);
27
+ result.set(data, 4);
28
+ return result;
29
+ }
30
+ /**
31
+ * Concat KDF per RFC 7518 Section 4.6.2.
32
+ * Single SHA-256 iteration (sufficient for 256-bit keys).
33
+ */ export function concatKDF(params) {
34
+ const { sharedSecret, keyLength, algorithmID, partyUInfo, partyVInfo } = params;
35
+ const encoder = new TextEncoder();
36
+ const algID = lengthPrefixed(encoder.encode(algorithmID));
37
+ const apu = lengthPrefixed(partyUInfo);
38
+ const apv = lengthPrefixed(partyVInfo);
39
+ const keyDataLen = uint32BE(keyLength);
40
+ // round = 1 (single iteration for 256-bit key)
41
+ const round = uint32BE(1);
42
+ // Hash(round || sharedSecret || algID || apu || apv || keyDataLen)
43
+ const parts = [
44
+ round,
45
+ sharedSecret,
46
+ algID,
47
+ apu,
48
+ apv,
49
+ keyDataLen
50
+ ];
51
+ let totalLength = 0;
52
+ for (const part of parts){
53
+ totalLength += part.length;
54
+ }
55
+ const hashInput = new Uint8Array(totalLength);
56
+ let offset = 0;
57
+ for (const part of parts){
58
+ hashInput.set(part, offset);
59
+ offset += part.length;
60
+ }
61
+ return sha256(hashInput).slice(0, keyLength / 8);
62
+ }
63
+ function agreeWithKey(recipientPublicKey) {
64
+ const ephemeralPrivateKey = x25519.utils.randomSecretKey();
65
+ return {
66
+ ephemeralPublicKey: x25519.getPublicKey(ephemeralPrivateKey),
67
+ sharedSecret: x25519.getSharedSecret(ephemeralPrivateKey, recipientPublicKey)
68
+ };
69
+ }
70
+ function encryptWithX25519(recipientPublicKey, plaintext) {
71
+ const { sharedSecret, ephemeralPublicKey } = agreeWithKey(recipientPublicKey);
72
+ // Derive content encryption key via Concat KDF
73
+ const cek = concatKDF({
74
+ sharedSecret,
75
+ keyLength: 256,
76
+ algorithmID: 'A256GCM',
77
+ partyUInfo: new Uint8Array(0),
78
+ partyVInfo: new Uint8Array(0)
79
+ });
80
+ // Generate random 96-bit IV for AES-GCM
81
+ const iv = randomBytes(12);
82
+ // Build protected header
83
+ const protectedHeader = {
84
+ alg: 'ECDH-ES',
85
+ enc: 'A256GCM',
86
+ epk: {
87
+ kty: 'OKP',
88
+ crv: 'X25519',
89
+ x: toB64U(ephemeralPublicKey)
90
+ }
91
+ };
92
+ // Encode protected header
93
+ const encodedHeader = b64uFromJSON(protectedHeader);
94
+ // AAD is the ASCII bytes of the encoded header (per RFC 7516 Section 5.1 step 14)
95
+ const aad = new TextEncoder().encode(encodedHeader);
96
+ // Encrypt with AES-256-GCM (tag is appended to ciphertext)
97
+ const cipher = gcm(cek, iv, aad);
98
+ const ciphertextWithTag = cipher.encrypt(plaintext);
99
+ // Split ciphertext and tag (GCM tag is last 16 bytes)
100
+ const ciphertext = ciphertextWithTag.slice(0, ciphertextWithTag.length - 16);
101
+ const tag = ciphertextWithTag.slice(ciphertextWithTag.length - 16);
102
+ // JWE Compact Serialization: header.encryptedKey.iv.ciphertext.tag
103
+ // For ECDH-ES direct, encrypted key is empty
104
+ return [
105
+ encodedHeader,
106
+ '',
107
+ toB64U(iv),
108
+ toB64U(ciphertext),
109
+ toB64U(tag)
110
+ ].join('.');
111
+ }
112
+ function resolveX25519Key(recipient) {
113
+ if (typeof recipient !== 'string') {
114
+ return {
115
+ key: recipient
116
+ };
117
+ }
118
+ if (isPeer4(recipient)) {
119
+ const shortForm = getPeer4ShortForm(recipient);
120
+ if (recipient === shortForm) {
121
+ // The doc lives in the long form. Resolving a short form needs a DIDResolver, which
122
+ // this sync constructor cannot await — so say so, rather than failing as a bad DID.
123
+ throw new Error(`Cannot encrypt to a did:peer:4 short form: ${shortForm}. Pass the long form, which carries the document.`);
124
+ }
125
+ const { doc } = decodePeer4(recipient);
126
+ const key = getAgreementKey(doc);
127
+ if (key == null) {
128
+ throw new Error(`Recipient publishes no X25519 keyAgreement key: ${shortForm}`);
129
+ }
130
+ return {
131
+ key,
132
+ id: shortForm
133
+ };
134
+ }
135
+ const [algorithm, publicKey] = getSignatureInfo(recipient);
136
+ if (algorithm === 'EdDSA') {
137
+ return {
138
+ key: ed25519.utils.toMontgomery(publicKey),
139
+ id: recipient
140
+ };
141
+ }
142
+ throw new Error(`Unsupported DID algorithm for encryption: ${algorithm}`);
143
+ }
144
+ /**
145
+ * Perform X25519 key agreement with a recipient DID, without building a JWE. Resolves the agreement
146
+ * key by the same rule `createTokenEncrypter` uses (a did:peer:4's published `keyAgreement` key, or a
147
+ * `did:key`'s birationally derived one), generates a single-use ephemeral pair, and returns the ECDH
148
+ * output; the ephemeral private key never leaves this function.
149
+ *
150
+ * The recipient recovers the identical bytes with `identity.agreeKey(ephemeralPublicKey)` and no
151
+ * `kid` — the sender always resolves the *first* published `keyAgreement` key, so a different `kid`
152
+ * on a multi-KEM identity silently recovers different bytes.
153
+ *
154
+ * Anonymous — it authenticates nothing about the sender, only that the recipient can recover the
155
+ * secret. Bind `ephemeralPublicKey` and the recipient DID into your KDF's info/context. The result is
156
+ * a raw ECDH output, not a key: run it through a KDF.
157
+ *
158
+ * ```ts
159
+ * const { sharedSecret, ephemeralPublicKey } = deriveSharedSecret(recipientDID)
160
+ * // ship ephemeralPublicKey alongside whatever the secret protects
161
+ * ```
162
+ *
163
+ * @param recipient a `did:key` EdDSA DID, or a `did:peer:4` **long form** — a short form throws, its
164
+ * agreement key living only in the long form.
165
+ */ export function deriveSharedSecret(recipient) {
166
+ if (typeof recipient !== 'string') {
167
+ throw new Error('deriveSharedSecret requires a DID string');
168
+ }
169
+ return agreeWithKey(resolveX25519Key(recipient).key);
170
+ }
171
+ export function createTokenEncrypter(recipient, options) {
172
+ if (typeof recipient !== 'string' && options?.algorithm !== 'X25519') {
173
+ throw new Error(`Unsupported algorithm: ${options?.algorithm}`);
174
+ }
175
+ const { key, id } = typeof recipient === 'string' ? resolveX25519Key(recipient) : resolveX25519Key(recipient);
176
+ return {
177
+ recipientID: id,
178
+ async encrypt (plaintext) {
179
+ return encryptWithX25519(key, plaintext);
180
+ }
181
+ };
182
+ }
183
+ /** Algorithms this package can encrypt with, strongest first. */ const AGREEMENT_PREFERENCE = [
184
+ 'X25519'
185
+ ];
186
+ /**
187
+ * Pick the strongest supported algorithm out of a resolver's key agreement set.
188
+ *
189
+ * Three distinguishable failures, so a caller can tell what to do about each: nothing registered
190
+ * for the method, a resolver that never grew key agreement, and a resolver whose keys are all
191
+ * algorithms this package does not (yet) implement.
192
+ */ async function resolveViaMethodResolver(recipient, resolver) {
193
+ if (resolver.resolveAgreementKey == null) {
194
+ throw new Error(`Cannot encrypt: ${resolver.method} does not support key agreement`);
195
+ }
196
+ const keys = await resolver.resolveAgreementKey(recipient);
197
+ for (const alg of AGREEMENT_PREFERENCE){
198
+ const match = keys.find((key)=>key.alg === alg);
199
+ if (match != null) {
200
+ return {
201
+ key: match.publicKey,
202
+ id: recipient
203
+ };
204
+ }
205
+ }
206
+ throw new Error(`Cannot encrypt to ${recipient}: no supported key agreement algorithm in [${keys.map((key)=>key.alg).join(', ')}]`);
207
+ }
208
+ /**
209
+ * `did:key` and `did:peer:4` carry their own key material and never go through a registered
210
+ * `DIDMethodResolver` — a cheap shape check, not an attempt-and-catch, so it never has to
211
+ * interpret (or swallow) an error the sync path raised.
212
+ */ function isSelfContainedRecipient(recipient) {
213
+ // Mirrors isPeer4's own prefix check without relying on its `value is string` predicate, which
214
+ // narrows a value already known to be a string to `never` on the negated branch.
215
+ return recipient.startsWith('did:peer:4z') || recipient.startsWith('did:key:');
216
+ }
217
+ /**
218
+ * Resolve a recipient's X25519 agreement key, reaching for a registered `DIDMethodResolver`
219
+ * only when the DID needs one.
220
+ *
221
+ * A resolver match is decided first and always wins. Failing that, `did:key` / `did:peer:4`
222
+ * fall to the sync path, whose own diagnostics (a did:peer:4 short form, an unsupported
223
+ * signature algorithm) propagate untouched — never caught here and rewritten as "no resolver".
224
+ * Anything else with no resolver registered gets that "no resolver" error, since the sync path
225
+ * has no way to resolve it either and would otherwise fail with an unrelated parse error.
226
+ */ async function resolveRecipientKey(recipient, options) {
227
+ const resolver = findMethodResolver(options.methods, recipient);
228
+ if (resolver != null) {
229
+ return resolveViaMethodResolver(recipient, resolver);
230
+ }
231
+ if (isSelfContainedRecipient(recipient)) {
232
+ return resolveX25519Key(recipient);
233
+ }
234
+ const method = recipient.split(':').slice(0, 2).join(':');
235
+ throw new Error(`Cannot encrypt: no resolver registered for ${method}`);
236
+ }
237
+ /**
238
+ * Async sibling of {@link deriveSharedSecret} for recipients whose agreement key must be reached
239
+ * through a registered `DIDMethodResolver` (e.g. `did:kokuin:`), in addition to the
240
+ * self-contained DIDs `deriveSharedSecret` already handles.
241
+ *
242
+ * @param recipient a DID string. Self-contained DIDs (`did:key`, `did:peer:4` long form) resolve
243
+ * the same way `deriveSharedSecret` does; any other DID is looked up in `options.methods`.
244
+ */ export async function deriveSharedSecretAsync(recipient, options) {
245
+ if (typeof recipient !== 'string') {
246
+ throw new Error('deriveSharedSecretAsync requires a DID string');
247
+ }
248
+ const { key } = await resolveRecipientKey(recipient, options);
249
+ return agreeWithKey(key);
250
+ }
251
+ /**
252
+ * Async sibling of {@link createTokenEncrypter} for recipients whose agreement key must be
253
+ * reached through a registered `DIDMethodResolver` (e.g. `did:kokuin:`), in addition to the
254
+ * self-contained DIDs `createTokenEncrypter` already handles.
255
+ */ export async function createTokenEncrypterAsync(recipient, options) {
256
+ const { key, id } = await resolveRecipientKey(recipient, options);
257
+ return {
258
+ recipientID: id,
259
+ async encrypt (plaintext) {
260
+ return encryptWithX25519(key, plaintext);
261
+ }
262
+ };
263
+ }
264
+ /**
265
+ * Encrypt plaintext to JWE compact serialization using the given encrypter.
266
+ */ export async function encryptToken(encrypter, plaintext) {
267
+ return encrypter.encrypt(plaintext);
268
+ }
269
+ /**
270
+ * Decrypt a JWE compact serialization string.
271
+ */ export async function decryptToken(decrypter, jwe) {
272
+ const parts = jwe.split('.');
273
+ if (parts.length !== 5) {
274
+ throw new Error(`Invalid JWE format: expected 5 parts, got ${parts.length}`);
275
+ }
276
+ const [encodedHeader, _encryptedKey, encodedIV, encodedCiphertext, encodedTag] = parts;
277
+ // Parse protected header
278
+ const header = b64uToJSON(encodedHeader);
279
+ if (header.alg !== 'ECDH-ES') {
280
+ throw new Error(`Unsupported JWE algorithm: ${header.alg}`);
281
+ }
282
+ if (header.enc !== 'A256GCM') {
283
+ throw new Error(`Unsupported JWE encryption: ${header.enc}`);
284
+ }
285
+ // Extract ephemeral public key from header
286
+ const ephemeralPublicKey = fromB64U(header.epk.x);
287
+ // Compute shared secret via ECDH key agreement
288
+ const sharedSecret = await decrypter.agreeKey(ephemeralPublicKey);
289
+ // Derive content encryption key
290
+ const cek = concatKDF({
291
+ sharedSecret,
292
+ keyLength: 256,
293
+ algorithmID: 'A256GCM',
294
+ partyUInfo: header.apu != null ? fromB64U(header.apu) : new Uint8Array(0),
295
+ partyVInfo: header.apv != null ? fromB64U(header.apv) : new Uint8Array(0)
296
+ });
297
+ // Decode components
298
+ const iv = fromB64U(encodedIV);
299
+ const ciphertext = fromB64U(encodedCiphertext);
300
+ const tag = fromB64U(encodedTag);
301
+ // Reconstruct ciphertext+tag (GCM expects them concatenated)
302
+ const ciphertextWithTag = new Uint8Array(ciphertext.length + tag.length);
303
+ ciphertextWithTag.set(ciphertext);
304
+ ciphertextWithTag.set(tag, ciphertext.length);
305
+ // AAD is the ASCII bytes of the encoded header
306
+ const aad = new TextEncoder().encode(encodedHeader);
307
+ // Decrypt with AES-256-GCM
308
+ const cipher = gcm(cek, iv, aad);
309
+ return cipher.decrypt(ciphertextWithTag);
310
+ }
311
+ /**
312
+ * Wrap a payload into a token string according to the specified envelope mode.
313
+ */ export async function wrapEnvelope(mode, payload, options) {
314
+ switch(mode){
315
+ case 'plain':
316
+ {
317
+ const token = createUnsignedToken(payload, options.header);
318
+ // Append empty signature segment for RFC 7519 unsecured JWT format (header.payload.)
319
+ return `${stringifyToken(token)}.`;
320
+ }
321
+ case 'jws':
322
+ {
323
+ if (options.signer == null) throw new Error('Signer required for jws mode');
324
+ const token = await options.signer.signToken(payload, {
325
+ header: options.header
326
+ });
327
+ return stringifyToken(token);
328
+ }
329
+ case 'jws-in-jwe':
330
+ {
331
+ if (options.signer == null) throw new Error('Signer required for jws-in-jwe mode');
332
+ if (options.encrypter == null) throw new Error('Encrypter required for jws-in-jwe mode');
333
+ const signed = await options.signer.signToken(payload, {
334
+ header: options.header
335
+ });
336
+ const jwsString = stringifyToken(signed);
337
+ return encryptToken(options.encrypter, new TextEncoder().encode(jwsString));
338
+ }
339
+ case 'jwe-in-jws':
340
+ {
341
+ if (options.signer == null) throw new Error('Signer required for jwe-in-jws mode');
342
+ if (options.encrypter == null) throw new Error('Encrypter required for jwe-in-jws mode');
343
+ const plaintext = new TextEncoder().encode(JSON.stringify(payload));
344
+ const jwe = await encryptToken(options.encrypter, plaintext);
345
+ const signed = await options.signer.signToken({
346
+ jwe
347
+ }, {
348
+ header: options.header
349
+ });
350
+ return stringifyToken(signed);
351
+ }
352
+ }
353
+ }
354
+ /**
355
+ * Unwrap a token string, auto-detecting the envelope mode from its structure.
356
+ */ export async function unwrapEnvelope(message, options) {
357
+ const parts = message.split('.');
358
+ if (parts.length === 5) {
359
+ // JWE outer → jws-in-jwe mode
360
+ if (options.decrypter == null) throw new Error('Decrypter required for JWE message');
361
+ const decrypted = await decryptToken(options.decrypter, message);
362
+ const jwsString = new TextDecoder().decode(decrypted);
363
+ const token = await verifyToken(jwsString, {
364
+ verifiers: options.verifiers
365
+ });
366
+ return {
367
+ payload: token.payload,
368
+ mode: 'jws-in-jwe'
369
+ };
370
+ }
371
+ if (parts.length === 3) {
372
+ // JWT: could be plain, jws, or jwe-in-jws
373
+ const token = await verifyToken(message, {
374
+ verifiers: options.verifiers,
375
+ allowUnsigned: true
376
+ });
377
+ if (isUnsignedToken(token)) {
378
+ return {
379
+ payload: token.payload,
380
+ mode: 'plain'
381
+ };
382
+ }
383
+ // Check if payload contains a JWE (jwe-in-jws)
384
+ if ('jwe' in token.payload && typeof token.payload.jwe === 'string') {
385
+ if (options.decrypter == null) throw new Error('Decrypter required for jwe-in-jws message');
386
+ const decrypted = await decryptToken(options.decrypter, token.payload.jwe);
387
+ const innerPayload = JSON.parse(new TextDecoder().decode(decrypted));
388
+ return {
389
+ payload: innerPayload,
390
+ mode: 'jwe-in-jws'
391
+ };
392
+ }
393
+ return {
394
+ payload: token.payload,
395
+ mode: 'jws'
396
+ };
397
+ }
398
+ throw new Error(`Invalid envelope format: expected 3 or 5 dot-separated parts, got ${parts.length}`);
399
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@kokuin/jwe",
3
+ "version": "0.1.0",
4
+ "description": "JWE encryption for kokuin tokens",
5
+ "keywords": [
6
+ "json",
7
+ "web",
8
+ "encryption",
9
+ "jwe"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/TairuFramework/kokuin",
14
+ "directory": "packages/jwe"
15
+ },
16
+ "license": "MIT",
17
+ "sideEffects": false,
18
+ "type": "module",
19
+ "exports": {
20
+ ".": "./lib/index.js"
21
+ },
22
+ "main": "lib/index.js",
23
+ "types": "lib/index.d.ts",
24
+ "files": [
25
+ "lib/*"
26
+ ],
27
+ "dependencies": {
28
+ "@noble/ciphers": "^2.3.0",
29
+ "@noble/curves": "^2.3.0",
30
+ "@noble/hashes": "^2.3.0",
31
+ "@sozai/codec": "^0.4.0",
32
+ "@kokuin/token": "^0.5.0"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
39
+ "build:clean": "del lib",
40
+ "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
41
+ "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
42
+ "test": "pnpm run test:types && pnpm run test:unit",
43
+ "test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
44
+ "test:unit": "vitest run"
45
+ }
46
+ }