From 15e5b4368b8c697fd01402cb70a3b95b9b2ec706 Mon Sep 17 00:00:00 2001 From: Yaroslav Moria <5eeman@users.noreply.github.com> Date: Mon, 27 Oct 2025 18:24:56 +0100 Subject: [PATCH 1/3] fix(jwe): fallback parsing for missing recipients/header, support unpadded protected header; refactor fromJson and add focused tests (fallback, AAD, key wrap, error paths). --- CHANGELOG.md | 6 +++ lib/src/jwe.dart | 65 +++++++++++++++++++++---- pubspec.yaml | 2 +- test/jwe_test.dart | 115 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b3cc22..fe6b9d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.4.8 + - **FIX**: JWE JSON parsing now handles missing `recipients` / `header` by deriving a single recipient from the protected header and validates absent `encrypted_key` for non-`dir` algorithms. + - **FEAT**: Support unpadded Base64URL protected header (new parsing test). + - **TEST**: Added fallback, AAD, key wrap, and error path tests. + - **CHORE**: Refactored `JsonWebEncryption.fromJson` for clarity. + ## 0.4.7 - **DEPS**: Remove dependency on `package:collection` - **UPGRADE**: Updated to use crypto_keys_plus 0.5.0 which uses pointycastle 4.0.0. diff --git a/lib/src/jwe.dart b/lib/src/jwe.dart index 67f1db4..0593995 100644 --- a/lib/src/jwe.dart +++ b/lib/src/jwe.dart @@ -66,16 +66,63 @@ class JsonWebEncryption extends JoseObject { JsonWebEncryption.fromJson(Map json) : this._( decodeBase64EncodedBytes(json['ciphertext']), - List.unmodifiable(json.containsKey('recipients') - ? (json['recipients'] as List).map((v) => _JweRecipient._( + // Determine recipients according to RFC7516 general/flattened or + // fallback when neither recipients nor header are present. + List.unmodifiable(() { + if (json.containsKey('recipients')) { + // General JSON Serialization + return (json['recipients'] as List).map((v) => _JweRecipient._( header: JsonObject.from(v['header']), - encryptedKey: decodeBase64EncodedBytes(v['encrypted_key']))) - : [ - _JweRecipient._( - header: JsonObject.from(json['header']), - encryptedKey: - decodeBase64EncodedBytes(json['encrypted_key'])) - ]), + encryptedKey: decodeBase64EncodedBytes(v['encrypted_key']))); + } + if (json.containsKey('header')) { + // Flattened JSON Serialization (header present) + var hdr = json['header']; + var encryptedKey = json.containsKey('encrypted_key') + ? decodeBase64EncodedBytes(json['encrypted_key']) + : []; + return [ + _JweRecipient._( + header: hdr == null ? null : JsonObject.from(hdr), + encryptedKey: encryptedKey) + ]; + } + // (Do not treat presence of only 'encrypted_key' as flattened; fall back to protected header derivation) + // Fallback: No recipients array and no per-recipient header. + // Try to derive necessary information from protected header. + // This supports cases where all header parameters (alg, enc, kid, ...) + // are only present in the protected header. + var protectedHeader = json['protected']; + if (protectedHeader == null) { + throw ArgumentError('Missing protected header for JWE'); + } + // We still build a single recipient to keep internal model consistent. + // encrypted_key may be legitimately absent for direct encryption (alg == 'dir'). + List encryptedKey = []; + JsonObject? derivedRecipientHeader; + var phDecoded = JsonObject.decode(protectedHeader); + var alg = phDecoded['alg']; + var kid = phDecoded['kid']; + if (alg != null) { + // Populate derived recipient header so downstream logic finds per-recipient alg + derivedRecipientHeader = JsonObject.from({ + 'alg': alg, + if (kid != null) 'kid': kid, + }); + } + if (json.containsKey('encrypted_key')) { + encryptedKey = decodeBase64EncodedBytes(json['encrypted_key']); + } else { + if (alg != null && alg != 'dir') { + throw ArgumentError( + 'Missing encrypted_key for algorithm "$alg"'); + } + } + return [ + _JweRecipient._( + header: derivedRecipientHeader, encryptedKey: encryptedKey) + ]; + }()), protectedHeader: JsonObject.decode(json['protected']), unprotectedHeader: json['unprotected'] == null ? null diff --git a/pubspec.yaml b/pubspec.yaml index 12a1b40..524ec54 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: jose_plus description: Javascript Object Signing and Encryption (JOSE) library supporting JWE, JWS, JWK and JWT -version: 0.4.7 +version: 0.4.8 homepage: https://github.com/Bdaya-Dev/jose funding: - https://github.com/sponsors/rbellens diff --git a/test/jwe_test.dart b/test/jwe_test.dart index 1a3b641..1f2d973 100644 --- a/test/jwe_test.dart +++ b/test/jwe_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:jose_plus/jose.dart'; +import 'package:jose_plus/src/util.dart'; import 'package:test/test.dart'; void main() { @@ -285,6 +286,120 @@ void main() { jwe.getPayloadFor(jwk, jwe.commonHeader, jwe.recipients.first)!), '{"aud": "somekey", "sub": 12, "iss": "auth.example.com", "exp": 1617349353}'); }); + + group('Fallback JWE JSON parsing (no recipients/header)', () { + test('Direct encryption without encrypted_key succeeds', () async { + var payload = 'Secret message'; + var builder = JsonWebEncryptionBuilder()..stringContent = payload; + builder.encryptionAlgorithm = 'A256GCM'; + // Generate a symmetric key for direct encryption + var key = JsonWebKey.generate(builder.encryptionAlgorithm); + builder.addRecipient(key, algorithm: 'dir'); + var jwe = builder.build(); + // Build JSON representation without recipients/header and intentionally omit encrypted_key + var compact = jwe.toCompactSerialization().split('.'); + // compact parts: [protected, encrypted_key (empty for dir), iv, ciphertext, tag] + var fallbackJson = { + 'protected': compact[0], + // intentionally no 'encrypted_key' + 'iv': compact[2], + 'ciphertext': compact[3], + 'tag': compact[4] + }; + var parsed = JsonWebEncryption.fromJson(fallbackJson); + var keyStore = JsonWebKeyStore()..addKey(key); + expect((await parsed.getPayload(keyStore)).stringContent, payload); + }); + + test('Missing encrypted_key for non-direct algorithm throws', () async { + var payload = 'Another secret'; + var builder = JsonWebEncryptionBuilder()..stringContent = payload; + builder.encryptionAlgorithm = 'A128CBC-HS256'; + // Use an octet key suitable for key wrapping (A128KW) + var key = + JsonWebKey.fromJson({'kty': 'oct', 'k': 'GawgguFyGrWKav7AX4VKUg'}); + builder.addRecipient(key, algorithm: 'A128KW'); + var jwe = builder.build(); + var compact = jwe.toCompactSerialization().split('.'); + // Remove encrypted_key field + var fallbackJson = { + 'protected': compact[0], + // encrypted_key omitted but algorithm in protected header is not 'dir' + 'iv': compact[2], + 'ciphertext': compact[3], + 'tag': compact[4] + }; + expect( + () => JsonWebEncryption.fromJson(fallbackJson), throwsArgumentError); + }); + + test('Parse JWE protected header (Base64URL unpadded)', () { + final jweJson = { + 'ciphertext': + 'F9vgLFXCxXd46RVux-YNAT0aSxAevRvz0Po1vLrbc45vTC3r_S8fv2S24I9IVOjRvE1CJm73-cyf04CvtngCiyLwgzhnr2hFiIzVOQ4bM077_KqHkn0rWHD5xGekxNHcGpVMkcXfhwnRGNMTCfcnJV5YbolDNYXwYJw4Wcjk2suLCPB-NFK0yuakmszOzC82PIlKBJ4VQ0Gjh_R5DsQoz00IcpvQogzsYWxgbV87TsGXrjqbV8x97ng_8B0V0Yd9EURg3SWJqsuWFPFig9k2voNwurztmKoRiNbYEdIV5Zn-AP96G7p4i76rY0h_v-NdBsTz38z17Qu_1W8-RNsdcH0rGUblQPQV6VWVQjrS3D6AfSoz-uoYga2X3BISU1GTmsqtHnOauq03aT2pN33PqxQ0nxQgJ1bvU1E4BuHugN6Mt9kVwv9ssNeYk3QE6gax4LE24f1SGH3-PfKQ0hwGfeql6yRdVA', + 'encrypted_key': + 'eId1wPPb7TEH9PazhcUsYEk5_nPOfVmqxwui7W7k5bqbIvIsJKX7vIh3xrcYF51fheHvOdZfqewWoJY3PTtPwBg1pJ-IE6V8UPKFpyJ6p8ETjKpypE5VijXrYeqTfFAXxpU3dhk5xVnm7eF8YfymmqzHA2_ErFzqbHu6e5JK6xGFkSc_bMINBBs1612ow1HHBr1LnnGUGM5hVX0bydisnJG9kkxLX7PH_0Np1vzkRspVlsi16zK4uA09GXraxIVo5GQNPoEdoAqC7AK71DzThOBMZMbN7incVJxegBnO8G0oqkG9IvCHHglTP6wtOVF_qzNNZ36-q5uxDatw4-oc6Q', + 'iv': '4fX9lmzwwR1d4Jbw', + 'protected': + 'eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiZGlkOmlkZW4zOmJpbGxpb25zOnRlc3Q6MlZ4bm9pTnFkTVB5SE10VXdBRXpobldxWEdrRWVKcEFwNG50VGtMOFhUI2tleTEiLCJ0eXAiOiJhcHBsaWNhdGlvbi9pZGVuM2NvbW0tZW5jcnlwdGVkLWpzb24ifQ', + 'tag': 'of7WWH7gbhEiADgowsXiOA', + }; + // Parsing should succeed and derive header values from protected header (unpadded base64url) + final jwe = JsonWebEncryption.fromJson(jweJson); + final header = jwe.commonHeader; + expect(header.algorithm, 'RSA-OAEP-256'); + expect(header.encryptionAlgorithm, 'A256GCM'); + expect(header.type, 'application/iden3comm-encrypted-json'); + expect(header.keyId, isNotNull); + // Ensure keyId has expected suffix fragment + expect(header.keyId!.endsWith('#key1'), isTrue); + // Ensure no recipients array created (flattened/fallback single recipient) + expect(jwe.recipients.length, 1); + }); + + test('Key wrapping (non-dir) fallback with encrypted_key present', + () async { + var payload = 'Key wrap fallback'; + var builder = JsonWebEncryptionBuilder()..stringContent = payload; + builder.encryptionAlgorithm = 'A128CBC-HS256'; + var key = + JsonWebKey.fromJson({'kty': 'oct', 'k': 'GawgguFyGrWKav7AX4VKUg'}); + builder.addRecipient(key, algorithm: 'A128KW'); + var jwe = builder.build(); + var parts = jwe.toCompactSerialization().split('.'); + var fallbackJson = { + 'protected': parts[0], + 'encrypted_key': parts[1], + 'iv': parts[2], + 'ciphertext': parts[3], + 'tag': parts[4] + }; + var parsed = JsonWebEncryption.fromJson(fallbackJson); + var store = JsonWebKeyStore()..addKey(key); + expect((await parsed.getPayload(store)).stringContent, payload); + expect(parsed.commonHeader.algorithm, 'A128KW'); + expect(parsed.commonHeader.encryptionAlgorithm, 'A128CBC-HS256'); + }); + + test('Error when protected header missing', () { + var json = { + 'iv': 'AA', + 'ciphertext': 'AA', + 'tag': 'AA' + }; // intentionally minimal + expect(() => JsonWebEncryption.fromJson(json), throwsArgumentError); + }); + + test('Error for invalid protected header base64/json', () { + var json = { + 'protected': '***', // invalid base64 / json + 'iv': 'AA', + 'ciphertext': 'AA', + 'tag': 'AA' + }; + expect(() => JsonWebEncryption.fromJson(json), throwsA(isA())); + }); + }); } void _doTests(dynamic payload, dynamic key, dynamic encoded) { From 8f2a7d7ad2fa93873b7c9cc435b1c5251fb4c7fc Mon Sep 17 00:00:00 2001 From: Yaroslav Moria <5eeman@users.noreply.github.com> Date: Tue, 28 Oct 2025 11:25:35 +0100 Subject: [PATCH 2/3] Downgraded crypto_keys_plus lower bound. --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 524ec54..028c19f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,7 +9,7 @@ environment: sdk: ^3.0.0 dependencies: - crypto_keys_plus: ">=0.5.0 <1.0.0" + crypto_keys_plus: ">=0.4.0 <1.0.0" meta: ^1.1.6 typed_data: ^1.0.0 x509_plus: ">=0.3.3 <1.0.0" From 599835223a5291252b2080ed866523b6ccffbe7d Mon Sep 17 00:00:00 2001 From: Yaroslav Moria <5eeman@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:13:06 +0100 Subject: [PATCH 3/3] Format and add comments for updated constructor. --- lib/src/jwe.dart | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/lib/src/jwe.dart b/lib/src/jwe.dart index 0593995..f22cb79 100644 --- a/lib/src/jwe.dart +++ b/lib/src/jwe.dart @@ -71,20 +71,25 @@ class JsonWebEncryption extends JoseObject { List.unmodifiable(() { if (json.containsKey('recipients')) { // General JSON Serialization - return (json['recipients'] as List).map((v) => _JweRecipient._( + return (json['recipients'] as List).map( + (v) => _JweRecipient._( header: JsonObject.from(v['header']), - encryptedKey: decodeBase64EncodedBytes(v['encrypted_key']))); + encryptedKey: decodeBase64EncodedBytes(v['encrypted_key']), + ), + ); } + var encryptedKey = json.containsKey('encrypted_key') + ? decodeBase64EncodedBytes(json['encrypted_key']) + : []; + if (json.containsKey('header')) { // Flattened JSON Serialization (header present) var hdr = json['header']; - var encryptedKey = json.containsKey('encrypted_key') - ? decodeBase64EncodedBytes(json['encrypted_key']) - : []; return [ _JweRecipient._( - header: hdr == null ? null : JsonObject.from(hdr), - encryptedKey: encryptedKey) + header: hdr == null ? null : JsonObject.from(hdr), + encryptedKey: encryptedKey, + ), ]; } // (Do not treat presence of only 'encrypted_key' as flattened; fall back to protected header derivation) @@ -98,7 +103,6 @@ class JsonWebEncryption extends JoseObject { } // We still build a single recipient to keep internal model consistent. // encrypted_key may be legitimately absent for direct encryption (alg == 'dir'). - List encryptedKey = []; JsonObject? derivedRecipientHeader; var phDecoded = JsonObject.decode(protectedHeader); var alg = phDecoded['alg']; @@ -110,17 +114,17 @@ class JsonWebEncryption extends JoseObject { if (kid != null) 'kid': kid, }); } - if (json.containsKey('encrypted_key')) { - encryptedKey = decodeBase64EncodedBytes(json['encrypted_key']); - } else { - if (alg != null && alg != 'dir') { - throw ArgumentError( - 'Missing encrypted_key for algorithm "$alg"'); - } + + // Encrypted key must be present unless alg == 'dir'. + if (encryptedKey.isEmpty && alg != null && alg != 'dir') { + throw ArgumentError('Missing encrypted_key for algorithm "$alg"'); } + return [ _JweRecipient._( - header: derivedRecipientHeader, encryptedKey: encryptedKey) + header: derivedRecipientHeader, + encryptedKey: encryptedKey, + ), ]; }()), protectedHeader: JsonObject.decode(json['protected']),