From b4651d3554e711dfc18afb7ffd86f6634a78c8da Mon Sep 17 00:00:00 2001 From: ahmednfwela Date: Wed, 8 Apr 2026 01:50:15 +0200 Subject: [PATCH] feat: implement ECDH-ES key agreement (ECDH-ES, ECDH-ES+A*KW) --- lib/src/ecdh.dart | 413 ++++++++++++++++++++++++++++++++++++++++++++ lib/src/jose.dart | 16 ++ lib/src/jwa.dart | 20 +++ lib/src/jwe.dart | 112 ++++++++++-- test/ecdh_test.dart | 272 +++++++++++++++++++++++++++++ 5 files changed, 820 insertions(+), 13 deletions(-) create mode 100644 lib/src/ecdh.dart create mode 100644 test/ecdh_test.dart diff --git a/lib/src/ecdh.dart b/lib/src/ecdh.dart new file mode 100644 index 0000000..72abbeb --- /dev/null +++ b/lib/src/ecdh.dart @@ -0,0 +1,413 @@ +/// ECDH-ES key agreement per [RFC 7518 §4.6](https://tools.ietf.org/html/rfc7518#section-4.6) +library; + +import 'dart:typed_data'; + +import 'package:crypto_keys_plus/crypto_keys.dart'; + +import 'jwk.dart'; +import 'util.dart'; + +// --------------------------------------------------------------------------- +// EC point arithmetic for Weierstrass curves y² = x³ + ax + b (mod p) +// --------------------------------------------------------------------------- + +class _ECPoint { + final BigInt x; + final BigInt y; + final bool isInfinity; + + const _ECPoint(this.x, this.y) : isInfinity = false; + _ECPoint.infinity() + : x = BigInt.zero, + y = BigInt.zero, + isInfinity = true; + + _ECPoint add(_ECPoint other, BigInt p, BigInt a) { + if (isInfinity) return other; + if (other.isInfinity) return this; + if (x == other.x && y == other.y) return double_(p, a); + if (x == other.x) return _ECPoint.infinity(); + + final lambda = ((other.y - y) * (other.x - x).modInverse(p)) % p; + final rx = (lambda * lambda - x - other.x) % p; + final ry = (lambda * (x - rx) - y) % p; + return _ECPoint(rx, ry); + } + + _ECPoint double_(BigInt p, BigInt a) { + if (isInfinity || y == BigInt.zero) return _ECPoint.infinity(); + + final lambda = ((BigInt.from(3) * x * x + a) * + (BigInt.from(2) * y).modInverse(p)) % + p; + final rx = (lambda * lambda - BigInt.from(2) * x) % p; + final ry = (lambda * (x - rx) - y) % p; + return _ECPoint(rx, ry); + } + + /// Scalar multiplication using double-and-add. + _ECPoint multiply(BigInt k, BigInt p, BigInt a) { + var result = _ECPoint.infinity(); + var base = this; + var n = k; + while (n > BigInt.zero) { + if (n.isOdd) { + result = result.add(base, p, a); + } + base = base.double_(p, a); + n >>= 1; + } + return result; + } +} + +class _ECCurve { + final BigInt p; + final BigInt a; + final BigInt b; + final int fieldSize; // in bytes + + const _ECCurve(this.p, this.a, this.b, this.fieldSize); +} + +// SEC 2 curve parameters +// https://www.secg.org/sec2-v2.pdf + +final _p256 = _ECCurve( + BigInt.parse( + 'FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF', + radix: 16), + BigInt.parse( + 'FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC', + radix: 16), + BigInt.parse( + '5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B', + radix: 16), + 32, +); + +final _p384 = _ECCurve( + BigInt.parse( + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE' + 'FFFFFFFF0000000000000000FFFFFFFF', + radix: 16), + BigInt.parse( + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE' + 'FFFFFFFF0000000000000000FFFFFFFC', + radix: 16), + BigInt.parse( + 'B3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013875A' + 'C656398D8A2ED19D2A85C8EDD3EC2AEF', + radix: 16), + 48, +); + +final _p521 = _ECCurve( + BigInt.parse( + '01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' + 'FFFF', + radix: 16), + BigInt.parse( + '01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' + 'FFFC', + radix: 16), + BigInt.parse( + '0051953EB9618E1C9A1F929A21A0B68540EEA2DA725B99B315F3B8B489918EF1' + '09E156193951EC7E937B1652C0BD3BB1BF073573DF883D2C34F1EF451FD46B50' + '3F00', + radix: 16), + 66, +); + +_ECCurve _curveForName(String name) { + switch (name) { + case 'P-256': + return _p256; + case 'P-384': + return _p384; + case 'P-521': + return _p521; + default: + throw UnsupportedError('Unsupported curve: $name'); + } +} + +/// Returns the key length in bits for a content/wrapping algorithm. +int _keyLengthForAlgorithm(String algorithm) { + switch (algorithm) { + case 'A128CBC-HS256': + return 256; + case 'A192CBC-HS384': + return 384; + case 'A256CBC-HS512': + return 512; + case 'A128GCM': + return 128; + case 'A192GCM': + return 192; + case 'A256GCM': + return 256; + case 'A128KW': + return 128; + case 'A192KW': + return 192; + case 'A256KW': + return 256; + default: + throw UnsupportedError('Unsupported algorithm: $algorithm'); + } +} + +// --------------------------------------------------------------------------- +// ECDH shared secret Z = x(d · Q) +// --------------------------------------------------------------------------- + +BigInt _ecdhAgreement(EcPrivateKey privateKey, EcPublicKey publicKey) { + final curveName = privateKey.curve.name.split('/').last; + final curve = _curveForName(curveName); + + final q = _ECPoint(publicKey.xCoordinate, publicKey.yCoordinate); + + // Validate that the public key point lies on the curve: y² ≡ x³ + ax + b (mod p) + final lhs = (q.y * q.y) % curve.p; + final rhs = (q.x * q.x * q.x + curve.a * q.x + curve.b) % curve.p; + if (lhs != rhs) { + throw ArgumentError('Public key point is not on the curve'); + } + + final result = q.multiply(privateKey.eccPrivateKey, curve.p, curve.a); + + if (result.isInfinity) { + throw StateError('ECDH produced point at infinity'); + } + return result.x; +} + +int _fieldSizeForCurve(String curveName) => + _curveForName(curveName).fieldSize; + +// --------------------------------------------------------------------------- +// Concat KDF (NIST SP 800-56A, RFC 7518 §4.6.2) +// --------------------------------------------------------------------------- + +Uint8List concatKdf( + Uint8List sharedSecret, { + required int keyDataLen, + required String algorithmId, + Uint8List? apu, + Uint8List? apv, +}) { + final keyDataBytes = keyDataLen ~/ 8; + final hashLen = 32; // SHA-256 + final reps = (keyDataBytes + hashLen - 1) ~/ hashLen; + + final result = BytesBuilder(); + + for (var counter = 1; counter <= reps; counter++) { + final input = BytesBuilder(); + + // counter (32-bit big-endian) + input.add(_int32BigEndian(counter)); + + // Z + input.add(sharedSecret); + + // AlgorithmID + final algIdBytes = Uint8List.fromList(algorithmId.codeUnits); + input.add(_int32BigEndian(algIdBytes.length)); + input.add(algIdBytes); + + // PartyUInfo + final apuBytes = apu ?? Uint8List(0); + input.add(_int32BigEndian(apuBytes.length)); + if (apuBytes.isNotEmpty) input.add(apuBytes); + + // PartyVInfo + final apvBytes = apv ?? Uint8List(0); + input.add(_int32BigEndian(apvBytes.length)); + if (apvBytes.isNotEmpty) input.add(apvBytes); + + // SuppPubInfo + input.add(_int32BigEndian(keyDataLen)); + + final inputBytes = Uint8List.fromList(input.takeBytes()); + final digest = algorithms.digest.sha256.createAlgorithm(); + digest.update(inputBytes, 0, inputBytes.length); + final hash = Uint8List(digest.digestSize); + digest.doFinal(hash, 0); + result.add(hash); + } + + return Uint8List.fromList(result.takeBytes().sublist(0, keyDataBytes)); +} + +Uint8List _int32BigEndian(int value) { + final bytes = Uint8List(4); + ByteData.view(bytes.buffer).setUint32(0, value, Endian.big); + return bytes; +} + +Uint8List _bigIntToBytes(BigInt value, int fieldSize) { + var hex = value.toRadixString(16); + if (hex.length % 2 != 0) hex = '0$hex'; + final raw = Uint8List(hex.length ~/ 2); + for (var i = 0; i < raw.length; i++) { + raw[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + if (raw.length > fieldSize) { + throw StateError( + 'EC coordinate exceeds field size: ${raw.length} > $fieldSize'); + } + if (raw.length < fieldSize) { + final padded = Uint8List(fieldSize); + padded.setRange(fieldSize - raw.length, fieldSize, raw); + return padded; + } + return raw; +} + +// --------------------------------------------------------------------------- +// AES Key Wrap / Unwrap (RFC 3394) +// --------------------------------------------------------------------------- + +Uint8List _aesKeyWrap(Uint8List kek, Uint8List plaintext) { + if (plaintext.length % 8 != 0) { + throw ArgumentError('Plaintext length must be a multiple of 8 bytes'); + } + + final wrapper = SymmetricKey(keyValue: kek) + .createEncrypter(algorithms.encryption.aes.keyWrap); + return Uint8List.fromList(wrapper.encrypt(plaintext).data); +} + +Uint8List _aesKeyUnwrap(Uint8List kek, Uint8List ciphertext) { + if (ciphertext.length % 8 != 0 || ciphertext.length < 24) { + throw ArgumentError( + 'Ciphertext length must be at least 24 and a multiple of 8 bytes'); + } + + final wrapper = SymmetricKey(keyValue: kek) + .createEncrypter(algorithms.encryption.aes.keyWrap); + return Uint8List.fromList(wrapper.decrypt(EncryptionResult(ciphertext))); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +class EcdhEsResult { + final Uint8List derivedKey; + final JsonWebKey ephemeralPublicKey; + + EcdhEsResult({required this.derivedKey, required this.ephemeralPublicKey}); +} + +/// Sender side: generate ephemeral key, perform ECDH, derive key material. +EcdhEsResult ecdhEsDerive({ + required JsonWebKey recipientPublicKey, + required String algorithmId, + required int keyDataLen, + Uint8List? apu, + Uint8List? apv, + KeyPair? ephemeralKeyPair, +}) { + final recipientPublic = recipientPublicKey.cryptoKeyPair.publicKey; + if (recipientPublic is! EcPublicKey) { + throw ArgumentError('Recipient key must be an EC public key'); + } + + final curveName = recipientPublicKey['crv'] as String; + final curveId = curvesByName[curveName]; + if (curveId == null) { + throw UnsupportedError('Unsupported curve: $curveName'); + } + + final ephemeral = ephemeralKeyPair ?? KeyPair.generateEc(curveId); + final ephemeralPrivate = ephemeral.privateKey as EcPrivateKey; + final ephemeralPublic = ephemeral.publicKey as EcPublicKey; + + final z = _ecdhAgreement(ephemeralPrivate, recipientPublic); + final fieldSize = _fieldSizeForCurve(curveName); + final zBytes = _bigIntToBytes(z, fieldSize); + + final derivedKey = concatKdf( + zBytes, + keyDataLen: keyDataLen, + algorithmId: algorithmId, + apu: apu, + apv: apv, + ); + + final epk = JsonWebKey.fromCryptoKeys(publicKey: ephemeralPublic); + + return EcdhEsResult(derivedKey: derivedKey, ephemeralPublicKey: epk); +} + +/// Recipient side: use private key + EPK to derive key material. +Uint8List ecdhEsDecrypt({ + required JsonWebKey recipientPrivateKey, + required JsonWebKey ephemeralPublicKey, + required String algorithmId, + required int keyDataLen, + Uint8List? apu, + Uint8List? apv, +}) { + final recipientPrivate = recipientPrivateKey.cryptoKeyPair.privateKey; + if (recipientPrivate is! EcPrivateKey) { + throw ArgumentError('Recipient key must have an EC private key'); + } + + final ephemeralPublic = ephemeralPublicKey.cryptoKeyPair.publicKey; + if (ephemeralPublic is! EcPublicKey) { + throw ArgumentError('Ephemeral key must be an EC public key'); + } + + // Verify curve of EPK matches recipient key + final recipientCurve = recipientPrivateKey['crv'] as String; + final ephemeralCurve = ephemeralPublicKey['crv'] as String; + if (recipientCurve != ephemeralCurve) { + throw ArgumentError( + 'Ephemeral key curve ($ephemeralCurve) does not match ' + 'recipient key curve ($recipientCurve)'); + } + + final z = _ecdhAgreement(recipientPrivate, ephemeralPublic); + final curveName = recipientCurve; + final fieldSize = _fieldSizeForCurve(curveName); + final zBytes = _bigIntToBytes(z, fieldSize); + + return concatKdf( + zBytes, + keyDataLen: keyDataLen, + algorithmId: algorithmId, + apu: apu, + apv: apv, + ); +} + +// --------------------------------------------------------------------------- +// Algorithm helpers +// --------------------------------------------------------------------------- + +String ecdhAlgorithmId(String algorithm, String encAlgorithm) { + if (algorithm == 'ECDH-ES') return encAlgorithm; + return algorithm; +} + +int ecdhKeyDataLen(String algorithm, String encAlgorithm) { + if (algorithm == 'ECDH-ES') return _keyLengthForAlgorithm(encAlgorithm); + final wrapAlg = algorithm.substring('ECDH-ES+'.length); + return _keyLengthForAlgorithm(wrapAlg); +} + +List ecdhEsWrapKey(Uint8List derivedKey, JsonWebKey cek) { + final cekBytes = Uint8List.fromList(decodeBase64EncodedBytes(cek['k'])); + return _aesKeyWrap(derivedKey, cekBytes); +} + +Uint8List ecdhEsUnwrapKey(Uint8List derivedKey, List encryptedKey) { + return _aesKeyUnwrap(derivedKey, Uint8List.fromList(encryptedKey)); +} diff --git a/lib/src/jose.dart b/lib/src/jose.dart index 715938e..ecdd010 100644 --- a/lib/src/jose.dart +++ b/lib/src/jose.dart @@ -85,6 +85,22 @@ class JoseHeader extends JsonObject { /// /// Only for [JsonWebEncryption] objects String? get compressionAlgorithm => getTyped('zip'); + + /// The ephemeral public key for ECDH key agreement. + /// + /// Only for [JsonWebEncryption] objects using ECDH-ES algorithms. + JsonWebKey? get ephemeralPublicKey => + getTyped('epk', factory: (v) => JsonWebKey.fromJson(v)); + + /// Agreement PartyUInfo value for ECDH key agreement. + /// + /// Only for [JsonWebEncryption] objects using ECDH-ES algorithms. + String? get agreementPartyUInfo => getTyped('apu'); + + /// Agreement PartyVInfo value for ECDH key agreement. + /// + /// Only for [JsonWebEncryption] objects using ECDH-ES algorithms. + String? get agreementPartyVInfo => getTyped('apv'); } /// Base class for [JsonWebSignature] and [JsonWebEncryption]. diff --git a/lib/src/jwa.dart b/lib/src/jwa.dart index 01d3f3f..f34df1e 100644 --- a/lib/src/jwa.dart +++ b/lib/src/jwa.dart @@ -79,6 +79,10 @@ class JsonWebAlgorithm { a128kw, a192kw, a256kw, + ecdh_es, + ecdh_es_a128kw, + ecdh_es_a192kw, + ecdh_es_a256kw, a128cbc_hs256, a192cbc_hs384, a256cbc_hs512, @@ -165,6 +169,22 @@ class JsonWebAlgorithm { static const a256kw = JsonWebAlgorithm('A256KW', type: 'oct', use: 'key', minKeyBitLength: 256); + /// ECDH-ES using Concat KDF + static const ecdh_es = + JsonWebAlgorithm('ECDH-ES', type: 'EC', use: 'key', curve: 'P-256'); + + /// ECDH-ES using Concat KDF and A128KW wrapping + static const ecdh_es_a128kw = + JsonWebAlgorithm('ECDH-ES+A128KW', type: 'EC', use: 'key', curve: 'P-256'); + + /// ECDH-ES using Concat KDF and A192KW wrapping + static const ecdh_es_a192kw = + JsonWebAlgorithm('ECDH-ES+A192KW', type: 'EC', use: 'key', curve: 'P-256'); + + /// ECDH-ES using Concat KDF and A256KW wrapping + static const ecdh_es_a256kw = + JsonWebAlgorithm('ECDH-ES+A256KW', type: 'EC', use: 'key', curve: 'P-256'); + /// AES_128_CBC_HMAC_SHA_256 authenticated encryption algorithm static const a128cbc_hs256 = JsonWebAlgorithm('A128CBC-HS256', type: 'oct', use: 'enc', minKeyBitLength: 256); diff --git a/lib/src/jwe.dart b/lib/src/jwe.dart index 3101e9a..9d6991b 100644 --- a/lib/src/jwe.dart +++ b/lib/src/jwe.dart @@ -6,6 +6,7 @@ import 'dart:typed_data'; import 'package:archive/archive.dart'; +import 'ecdh.dart'; import 'jose.dart'; import 'jwk.dart'; import 'util.dart'; @@ -70,11 +71,15 @@ class JsonWebEncryption extends JoseObject { decodeBase64EncodedBytes(json['ciphertext']), List.unmodifiable(json.containsKey('recipients') ? (json['recipients'] as List).map((v) => _JweRecipient._( - header: JsonObject.from(v['header']), + header: v['header'] == null + ? null + : JsonObject.from(v['header']), encryptedKey: decodeBase64EncodedBytes(v['encrypted_key']))) : [ _JweRecipient._( - header: JsonObject.from(json['header']), + header: json['header'] == null + ? null + : JsonObject.from(json['header']), encryptedKey: decodeBase64EncodedBytes(json['encrypted_key'])) ]), @@ -147,9 +152,59 @@ class JsonWebEncryption extends JoseObject { if (header.encryptionAlgorithm == 'none') { throw JoseException('Encryption algorithm cannot be `none`'); } - var cek = header.algorithm == 'dir' - ? key - : key.unwrapKey(recipient.data, algorithm: header.algorithm); + + JsonWebKey cek; + if (header.algorithm == 'dir') { + cek = key; + } else if (header.algorithm != null && + header.algorithm!.startsWith('ECDH-ES')) { + // ECDH-ES key agreement + final epk = header.ephemeralPublicKey; + if (epk == null) { + throw JoseException('Missing ephemeral public key (epk) in header'); + } + final encAlgorithm = header.encryptionAlgorithm!; + final algId = ecdhAlgorithmId(header.algorithm!, encAlgorithm); + final keyLen = ecdhKeyDataLen(header.algorithm!, encAlgorithm); + + final apu = header.agreementPartyUInfo != null + ? Uint8List.fromList(decodeBase64EncodedBytes(header.agreementPartyUInfo!)) + : null; + final apv = header.agreementPartyVInfo != null + ? Uint8List.fromList(decodeBase64EncodedBytes(header.agreementPartyVInfo!)) + : null; + + final derivedKey = ecdhEsDecrypt( + recipientPrivateKey: key, + ephemeralPublicKey: epk, + algorithmId: algId, + keyDataLen: keyLen, + apu: apu, + apv: apv, + ); + + if (header.algorithm == 'ECDH-ES') { + // Direct key agreement — derived key IS the CEK + cek = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': encodeBase64EncodedBytes(derivedKey), + 'use': 'enc', + 'key_ops': ['encrypt', 'decrypt'], + })!; + } else { + // ECDH-ES+A*KW — derived key unwraps the CEK + final unwrappedCekBytes = + ecdhEsUnwrapKey(derivedKey, recipient.data); + cek = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': encodeBase64EncodedBytes(unwrappedCekBytes), + 'use': 'enc', + 'key_ops': ['encrypt', 'decrypt'], + })!; + } + } else { + cek = key.unwrapKey(recipient.data, algorithm: header.algorithm); + } var uncompressed = cek.decrypt(data, initializationVector: initializationVector, @@ -221,6 +276,10 @@ class JsonWebEncryptionBuilder extends JoseObjectBuilder { var recipientsMapped = recipients.map((r) { var key = r['_jwk'] as JsonWebKey; var algorithm = r['alg'] ?? key.algorithmForOperation('wrapKey') ?? 'dir'; + + List encryptedKey; + var unprotectedHeaderParams = {'alg': algorithm}; + if (algorithm == 'dir') { if (recipients.length > 1) { throw StateError( @@ -234,15 +293,42 @@ class JsonWebEncryptionBuilder extends JoseObjectBuilder { throw UnimplementedError('Unkown key.'); } cek = k; - } - var encryptedKey = algorithm == 'dir' - ? const [] - : key.wrapKey( - cek, - algorithm: algorithm, - ); + encryptedKey = const []; + } else if (algorithm.startsWith('ECDH-ES')) { + // ECDH-ES key agreement + final algId = ecdhAlgorithmId(algorithm, encryptionAlgorithm!); + final keyLen = ecdhKeyDataLen(algorithm, encryptionAlgorithm!); - var unprotectedHeaderParams = {'alg': algorithm}; + final result = ecdhEsDerive( + recipientPublicKey: key, + algorithmId: algId, + keyDataLen: keyLen, + ); + + // Add EPK to header + unprotectedHeaderParams['epk'] = result.ephemeralPublicKey.toJson(); + + if (algorithm == 'ECDH-ES') { + // Direct key agreement — derived key IS the CEK + if (recipients.length > 1) { + throw StateError( + 'JWE can only have one recipient when using ECDH-ES direct key agreement.'); + } + cek = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': encodeBase64EncodedBytes(result.derivedKey), + 'use': 'enc', + 'alg': encryptionAlgorithm, + 'key_ops': ['encrypt', 'decrypt'], + })!; + encryptedKey = const []; + } else { + // ECDH-ES+A*KW — derived key wraps the CEK + encryptedKey = ecdhEsWrapKey(result.derivedKey, cek); + } + } else { + encryptedKey = key.wrapKey(cek, algorithm: algorithm); + } if (key.keyId != null) { unprotectedHeaderParams['kid'] = key.keyId; } diff --git a/test/ecdh_test.dart b/test/ecdh_test.dart new file mode 100644 index 0000000..5a1d7d6 --- /dev/null +++ b/test/ecdh_test.dart @@ -0,0 +1,272 @@ +import 'dart:convert'; + +import 'package:jose_plus/jose.dart'; +import 'package:test/test.dart'; + +void main() { + group('ECDH-ES roundtrip', () { + late JsonWebKey ecKey; + + setUp(() { + ecKey = JsonWebKey.generate('ECDH-ES'); + }); + + test('ECDH-ES with A128GCM', () async { + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'Hello ECDH-ES with A128GCM!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES with A128GCM!'); + }); + + test('ECDH-ES with A256GCM', () async { + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A256GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'Hello ECDH-ES with A256GCM!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES with A256GCM!'); + }); + + test('ECDH-ES with A128CBC-HS256', () async { + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'Hello ECDH-ES with A128CBC-HS256!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES with A128CBC-HS256!'); + }); + + test('ECDH-ES with A256CBC-HS512', () async { + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A256CBC-HS512' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'Hello ECDH-ES with A256CBC-HS512!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES with A256CBC-HS512!'); + }); + }); + + group('ECDH-ES+A*KW roundtrip', () { + test('ECDH-ES+A128KW with A128GCM', () async { + final ecKey = JsonWebKey.generate('ECDH-ES+A128KW'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES+A128KW') + ..stringContent = 'Hello ECDH-ES+A128KW!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES+A128KW!'); + }); + + test('ECDH-ES+A192KW with A192GCM', () async { + final ecKey = JsonWebKey.generate('ECDH-ES+A192KW'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A192GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES+A192KW') + ..stringContent = 'Hello ECDH-ES+A192KW!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES+A192KW!'); + }); + + test('ECDH-ES+A256KW with A256CBC-HS512', () async { + final ecKey = JsonWebKey.generate('ECDH-ES+A256KW'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A256CBC-HS512' + ..addRecipient(ecKey, algorithm: 'ECDH-ES+A256KW') + ..stringContent = 'Hello ECDH-ES+A256KW!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES+A256KW!'); + }); + }); + + group('ECDH-ES with different curves', () { + for (var entry in { + 'P-256': 'ES256', + 'P-384': 'ES384', + 'P-521': 'ES512', + }.entries) { + test('ECDH-ES with ${entry.key} and A128GCM', () async { + // Generate an EC key pair using the signature algorithm + final sigKey = JsonWebKey.generate(entry.value); + final keyJson = Map.from(sigKey.toJson()); + keyJson.remove('alg'); + keyJson.remove('use'); + keyJson.remove('key_ops'); + final ecKey = JsonWebKey.fromJson(keyJson)!; + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'Test with ${entry.key}'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Test with ${entry.key}'); + }); + } + }); + + group('ECDH-ES header verification', () { + test('compact serialization contains epk in header', () { + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'test'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final headerPart = compact.split('.')[0]; + final headerJson = json.decode( + utf8.decode(base64Url.decode(base64Url.normalize(headerPart)))); + + expect(headerJson['alg'], 'ECDH-ES'); + expect(headerJson['enc'], 'A128GCM'); + expect(headerJson['epk'], isNotNull); + expect(headerJson['epk']['kty'], 'EC'); + expect(headerJson['epk']['crv'], isNotNull); + expect(headerJson['epk']['x'], isNotNull); + expect(headerJson['epk']['y'], isNotNull); + // Ephemeral public key should NOT contain private key + expect(headerJson['epk']['d'], isNull); + }); + + test('ECDH-ES direct has empty encrypted key', () { + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'test'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parts = compact.split('.'); + expect(parts[1], isEmpty); + }); + + test('ECDH-ES+A128KW has non-empty encrypted key', () { + final ecKey = JsonWebKey.generate('ECDH-ES+A128KW'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES+A128KW') + ..stringContent = 'test'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parts = compact.split('.'); + expect(parts[1], isNotEmpty); + }); + }); + + group('ECDH-ES with JSON serialization', () { + test('JWE JSON roundtrip with ECDH-ES', () async { + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'JSON serialization test'; + + final jwe = builder.build(); + final jsonSerialization = jwe.toJson(); + + final parsed = JsonWebEncryption.fromJson(jsonSerialization); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'JSON serialization test'); + }); + }); + + group('ECDH-ES in JWT', () { + test('Encrypted JWT with ECDH-ES', () async { + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final claims = JsonWebTokenClaims.fromJson({ + 'sub': '1234567890', + 'name': 'John Doe', + 'admin': true, + 'iat': 1516239022, + }); + + final builder = JsonWebSignatureBuilder() + ..jsonContent = claims.toJson() + ..addRecipient(JsonWebKey.fromJson({'kty': 'oct', 'k': ''})!, + algorithm: 'none'); + final innerJws = builder.build(); + + final jweBuilder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = innerJws.toCompactSerialization(); + + final jwe = jweBuilder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + final innerContent = payload.stringContent; + // The decrypted content is the inner JWS compact serialization + // which contains the base64url-encoded claims + final jwsParts = innerContent.split('.'); + final claimsJson = utf8.decode(base64Url.decode(base64Url.normalize(jwsParts[1]))); + expect(claimsJson, contains('John Doe')); + }); + }); +}