Skip to content

feat: implement ECDH-ES key agreement (ECDH-ES, ECDH-ES+A*KW)#13

Open
ahmednfwela wants to merge 1 commit into
mainfrom
feat/ecdh-es
Open

feat: implement ECDH-ES key agreement (ECDH-ES, ECDH-ES+A*KW)#13
ahmednfwela wants to merge 1 commit into
mainfrom
feat/ecdh-es

Conversation

@ahmednfwela
Copy link
Copy Markdown
Member

@ahmednfwela ahmednfwela commented Apr 8, 2026

Summary by CodeRabbit

Release Notes

  • New Features
    • Added ECDH-ES (Elliptic Curve Diffie-Hellman) key agreement support for JWE encryption and decryption
    • Supports direct key agreement and key-wrapping variants (A128KW, A192KW, A256KW)
    • Compatible with P-256, P-384, and P-521 elliptic curves
    • Includes agreement party information (APU/APV) support for key derivation

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 8, 2026

📝 Walkthrough

Walkthrough

This PR introduces ECDH-ES (Elliptic Curve Diffie-Hellman Ephemeral Static) key agreement support to the library. It implements elliptic curve arithmetic, Concat KDF derivation, and AES key wrap/unwrap for secure key establishment, integrating these primitives into the JWE encryption and decryption pipelines with comprehensive test coverage.

Changes

Cohort / File(s) Summary
Core ECDH-ES Implementation
lib/src/ecdh.dart
New file implementing EC point arithmetic (addition, doubling, scalar multiplication) for P-256/P-384/P-521, Concat KDF (NIST SP 800-56A) with SHA-256, and AES Key Wrap/Unwrap. Exports EcdhEsResult, ecdhEsDerive (sender: derive shared secret), ecdhEsDecrypt (recipient: derive shared secret), algorithm mapping helpers, and wrap/unwrap utilities.
Algorithm Registry
lib/src/jwa.dart
Added four new JsonWebAlgorithm constants (ecdh_es, ecdh_es_a128kw, ecdh_es_a192kw, ecdh_es_a256kw) and updated allAlgorithms list for registry/lookup support.
JOSE Header Extensions
lib/src/jose.dart
Added three new JoseHeader getters: ephemeralPublicKey (parses epk field), agreementPartyUInfo (reads apu), agreementPartyVInfo (reads apv) for ECDH-ES parameter access.
JWE Integration
lib/src/jwe.dart
Integrated ECDH-ES into JsonWebEncryption.getPayloadFor (decryption: validate ephemeral key, derive shared secret, unwrap/construct CEK) and JsonWebEncryptionBuilder.build (encryption: generate ephemeral keypair, derive shared secret, wrap/set CEK). Updated JWE JSON parsing to handle missing per-recipient headers.
Test Suite
test/ecdh_test.dart
Comprehensive test file validating ECDH-ES roundtrip encryption/decryption across multiple content-encryption algorithms (A128GCM, A256GCM, A128CBC-HS256, A256CBC-HS512), key-wrapping variants, curve compatibility, header correctness, and nested JWE-JWS scenarios.

Sequence Diagram(s)

sequenceDiagram
    participant Sender
    participant Recipient
    participant ECDH as ECDH Module
    participant KDF as Concat KDF
    participant Wrapper as Key Wrapper

    Sender->>ECDH: ecdhEsDerive(recipientPublicKey, algId, keyLen)
    ECDH->>ECDH: Generate ephemeral EC keypair
    ECDH->>ECDH: Compute shared secret Z via scalar mult
    ECDH->>KDF: runKdf(Z, algId, apu, apv)
    KDF->>KDF: SHA-256 with counter, structured inputs
    KDF-->>ECDH: Derived key material
    ECDH->>Wrapper: Wrap CEK with derived key (if A*KW variant)
    Wrapper-->>ECDH: Encrypted key
    ECDH-->>Sender: {derivedKey, ephemeralPublicKey}
Loading
sequenceDiagram
    participant Recipient
    participant ECDH as ECDH Module
    participant KDF as Concat KDF
    participant Unwrapper as Key Unwrapper
    participant Decryption as Decryption Engine

    Recipient->>ECDH: ecdhEsDecrypt(privKey, ephemeralPubKey, algId, keyLen)
    ECDH->>ECDH: Validate key types & curves match
    ECDH->>ECDH: Compute shared secret Z via scalar mult
    ECDH->>KDF: runKdf(Z, algId, apu, apv)
    KDF->>KDF: SHA-256 with counter, structured inputs
    KDF-->>ECDH: Derived key material
    ECDH->>Unwrapper: Unwrap encrypted key (if A*KW variant)
    Unwrapper-->>ECDH: Content encryption key
    ECDH-->>Recipient: CEK
    Recipient->>Decryption: Decrypt payload with CEK
    Decryption-->>Recipient: Plaintext
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A rabbit hops through curves so fine,
P-256, P-384, secrets divine!
Ephemeral keys dance, KDF spins—
Shared secrets whispered where agreement begins.
Key wrapping secure, from sender to friend,
ECDH-ES magic makes encryption blend! 🔐✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically identifies the main change: implementing ECDH-ES key agreement with its variants (ECDH-ES and ECDH-ES+A*KW). This directly matches the large changeset that adds new ECDH cryptographic functionality across multiple files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ecdh-es

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
lib/src/jwe.dart (1)

297-328: Missing apu/apv (Agreement PartyInfo) support in encryption.

The ecdhEsDerive call omits apu and apv parameters, and JsonWebEncryptionBuilder has no mechanism to set these values. While optional per RFC 7518 §4.6.1, omitting them could cause interoperability issues when decrypting JWEs from other libraries that include these values.

Consider adding apu/apv setters to the builder for completeness:

♻️ Proposed enhancement to support apu/apv

Add to JsonWebEncryptionBuilder:

/// Agreement PartyUInfo for ECDH key agreement (base64url-encoded)
Uint8List? agreementPartyUInfo;

/// Agreement PartyVInfo for ECDH key agreement (base64url-encoded)
Uint8List? agreementPartyVInfo;

Then update the ecdhEsDerive call:

 final result = ecdhEsDerive(
   recipientPublicKey: key,
   algorithmId: algId,
   keyDataLen: keyLen,
+  apu: agreementPartyUInfo,
+  apv: agreementPartyVInfo,
 );

+// Add apu/apv to header if present
+if (agreementPartyUInfo != null) {
+  unprotectedHeaderParams['apu'] = encodeBase64EncodedBytes(agreementPartyUInfo!);
+}
+if (agreementPartyVInfo != null) {
+  unprotectedHeaderParams['apv'] = encodeBase64EncodedBytes(agreementPartyVInfo!);
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/src/jwe.dart` around lines 297 - 328, Add optional Agreement PartyInfo
support by exposing two fields on JsonWebEncryptionBuilder (e.g., Uint8List?
agreementPartyUInfo and Uint8List? agreementPartyVInfo) and pass them into
ecdhEsDerive when computing the ECDH-ES key; also, when non-null,
base64url-encode and include apu/apv in the unprotectedHeaderParams alongside
'epk'. Update the ecdhEsDerive call site in the ECDH-ES branch (the block that
computes algId/keyLen and calls ecdhEsDerive) to accept and forward these
values, and ensure ecdhEsDerive signature accepts apu/apv, and that
ecdhEsWrapKey/CEK logic remains unchanged.
lib/src/ecdh.dart (1)

406-409: Missing validation: cek['k'] could be null for non-oct keys.

The ecdhEsWrapKey function assumes cek['k'] exists. If called with a non-symmetric key, this will throw a less helpful error from decodeBase64EncodedBytes.

♻️ Add defensive validation
 List<int> ecdhEsWrapKey(Uint8List derivedKey, JsonWebKey cek) {
+  final kValue = cek['k'];
+  if (kValue == null) {
+    throw ArgumentError('CEK must be a symmetric key with "k" parameter');
+  }
-  final cekBytes = Uint8List.fromList(decodeBase64EncodedBytes(cek['k']));
+  final cekBytes = Uint8List.fromList(decodeBase64EncodedBytes(kValue));
   return _aesKeyWrap(derivedKey, cekBytes);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/src/ecdh.dart` around lines 406 - 409, ecdhEsWrapKey currently assumes
cek['k'] exists and will pass null into decodeBase64EncodedBytes; add defensive
validation at the start of ecdhEsWrapKey to verify that cek contains a non-null,
non-empty 'k' entry (and optionally that cek['k'] is a String) and throw a clear
ArgumentError (or other domain-specific exception) if missing/invalid; after
validation proceed to call decodeBase64EncodedBytes and _aesKeyWrap as before so
errors are explicit and handled.
lib/src/jwa.dart (1)

172-186: Hardcoded curve: 'P-256' is inconsistent with actual multi-curve support.

RFC 7518 §4.6 specifies that ECDH-ES supports P-256, P-384, and P-521 curves. The runtime implementation in ecdh.dart correctly derives the curve dynamically from the key's crv field, and tests verify all three curves work. However, hardcoding curve: 'P-256' here is misleading because:

  1. generateRandomKey() will always generate P-256 keys even when other curves are valid
  2. The curve metadata doesn't reflect actual algorithm capabilities

Consider either:

  • Removing the curve parameter for ECDH-ES algorithms (let the key determine the curve)
  • Or documenting that P-256 is merely the default for key generation
♻️ Option: Remove hardcoded curve to reflect actual behavior
   /// ECDH-ES using Concat KDF
   static const ecdh_es =
-      JsonWebAlgorithm('ECDH-ES', type: 'EC', use: 'key', curve: 'P-256');
+      JsonWebAlgorithm('ECDH-ES', type: 'EC', use: 'key');

   /// ECDH-ES using Concat KDF and A128KW wrapping
   static const ecdh_es_a128kw =
-      JsonWebAlgorithm('ECDH-ES+A128KW', type: 'EC', use: 'key', curve: 'P-256');
+      JsonWebAlgorithm('ECDH-ES+A128KW', type: 'EC', use: 'key');

   /// ECDH-ES using Concat KDF and A192KW wrapping
   static const ecdh_es_a192kw =
-      JsonWebAlgorithm('ECDH-ES+A192KW', type: 'EC', use: 'key', curve: 'P-256');
+      JsonWebAlgorithm('ECDH-ES+A192KW', type: 'EC', use: 'key');

   /// ECDH-ES using Concat KDF and A256KW wrapping
   static const ecdh_es_a256kw =
-      JsonWebAlgorithm('ECDH-ES+A256KW', type: 'EC', use: 'key', curve: 'P-256');
+      JsonWebAlgorithm('ECDH-ES+A256KW', type: 'EC', use: 'key');

Note: This would require updating generateCryptoKeyPair() (line 284-285) to handle curve == null for EC types, defaulting to P-256 there explicitly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/src/jwa.dart` around lines 172 - 186, The ECDH-ES constants (ecdh_es,
ecdh_es_a128kw, ecdh_es_a192kw, ecdh_es_a256kw) currently hardcode curve:
'P-256', which misrepresents multi-curve support; remove the curve parameter
from these JsonWebAlgorithm declarations (or set it to null) so the key's crv
drives behavior, and update generateCryptoKeyPair / generateRandomKey (the
functions that read algorithm.curve) to explicitly default to 'P-256' only when
algorithm.curve is null for EC types—ensure generateRandomKey no longer always
forces P-256 and that generateCryptoKeyPair handles null curve by using P-256 as
the default at generation time.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/src/ecdh.dart`:
- Around line 49-62: The multiply(BigInt k, BigInt p, BigInt a) method uses a
branching double-and-add loop that leaks scalar bits via timing; replace it with
a constant-time scalar multiplication (e.g., Montgomery ladder) to remove branch
dependence on n.isOdd. Update _ECPoint.multiply to implement a constant-time
ladder (or a constant-time windowed algorithm) that uses only fixed-time
conditional swaps and uniform operations on _ECPoint (refer to the _ECPoint.add
and _ECPoint.double_ helpers), ensuring no data-dependent branches or early loop
exits and that the loop runs a fixed number of iterations based on the scalar
bit-length.

---

Nitpick comments:
In `@lib/src/ecdh.dart`:
- Around line 406-409: ecdhEsWrapKey currently assumes cek['k'] exists and will
pass null into decodeBase64EncodedBytes; add defensive validation at the start
of ecdhEsWrapKey to verify that cek contains a non-null, non-empty 'k' entry
(and optionally that cek['k'] is a String) and throw a clear ArgumentError (or
other domain-specific exception) if missing/invalid; after validation proceed to
call decodeBase64EncodedBytes and _aesKeyWrap as before so errors are explicit
and handled.

In `@lib/src/jwa.dart`:
- Around line 172-186: The ECDH-ES constants (ecdh_es, ecdh_es_a128kw,
ecdh_es_a192kw, ecdh_es_a256kw) currently hardcode curve: 'P-256', which
misrepresents multi-curve support; remove the curve parameter from these
JsonWebAlgorithm declarations (or set it to null) so the key's crv drives
behavior, and update generateCryptoKeyPair / generateRandomKey (the functions
that read algorithm.curve) to explicitly default to 'P-256' only when
algorithm.curve is null for EC types—ensure generateRandomKey no longer always
forces P-256 and that generateCryptoKeyPair handles null curve by using P-256 as
the default at generation time.

In `@lib/src/jwe.dart`:
- Around line 297-328: Add optional Agreement PartyInfo support by exposing two
fields on JsonWebEncryptionBuilder (e.g., Uint8List? agreementPartyUInfo and
Uint8List? agreementPartyVInfo) and pass them into ecdhEsDerive when computing
the ECDH-ES key; also, when non-null, base64url-encode and include apu/apv in
the unprotectedHeaderParams alongside 'epk'. Update the ecdhEsDerive call site
in the ECDH-ES branch (the block that computes algId/keyLen and calls
ecdhEsDerive) to accept and forward these values, and ensure ecdhEsDerive
signature accepts apu/apv, and that ecdhEsWrapKey/CEK logic remains unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e5619368-6a74-4fe6-b161-821e9275d2f3

📥 Commits

Reviewing files that changed from the base of the PR and between a0b51cd and b4651d3.

📒 Files selected for processing (5)
  • lib/src/ecdh.dart
  • lib/src/jose.dart
  • lib/src/jwa.dart
  • lib/src/jwe.dart
  • test/ecdh_test.dart

Comment thread lib/src/ecdh.dart
Comment on lines +49 to +62
/// 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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

❓ Verification inconclusive

What are constant-time scalar multiplication algorithms for elliptic curves?


Constant-time (a.k.a. regular/uniform) elliptic-curve scalar multiplication means computing ([k]P) with a control-flow and memory-access pattern that does not depend on the secret scalar bits/digits (k) (to resist timing/simple power/cache attacks). Common constant-time algorithm families include:

  • Double-and-add-always (regular binary method): per bit, always do one doubling and one addition, then use a constant-time select to keep the correct result. This produces a fixed-length, uniform trace. [1]

  • Ladder methods (one add + one double per bit)

    • Montgomery ladder: maintains two related points and, for each bit, performs one differential addition and one doubling with constant-time conditional swaps (cswap) to avoid secret-dependent branches; widely used for X25519-style x-coordinate-only arithmetic. [1][2]
    • Variants / generalized ladders: many “ladder-like” designs exist (e.g., for different coordinate systems or with “data-veiling” to avoid secret-dependent memory behavior) while preserving the per-bit uniform operation mix. [2]
  • Fixed-window / windowed methods implemented regularly: precompute a table of small multiples and process the scalar in fixed-size windows, using constant-time table selection (no secret-indexed loads). These can be faster than ladders while remaining regular. [3][4]

  • Multi-scalar (Shamir/Straus/interleaving) in fixed windows: for computing (\sum [k_i]P_i), interleaved fixed-window approaches can be made constant-time with constant-time table access and a fixed schedule. (Often used in signature verification.) [5]

  • Uniform “pseudomultiplication + recovery” frameworks: compute on an x-line/Kummer surface with fast, uniform differential operations, then recover the full point—giving constant-time scalar multiplication beyond pure Diffie–Hellman use-cases. [4]

In practice, constant-time scalar multiplication also relies on exception-free curve formulas (e.g., complete addition laws on Edwards-type models) so the arithmetic itself doesn’t trigger special-case branches on rare inputs. [6]

Sources:
[1] https://www.hyperelliptic.org/tanja/teaching/crypto21/ecc-8.pdf
[2] https://eprint.iacr.org/2017/350.pdf
[3] https://eprint.iacr.org/2011/338
[4] https://arxiv.org/abs/1510.03174
[5] https://doc.cuprate.org/curve25519_dalek/backend/serial/scalar_mul/straus/struct.Straus.html
[6] https://cr.yp.to/newelliptic/edwards2-20080611.pdf


Script executed:

# Search for usage of multiply() method in the codebase
rg "multiply\(" lib/src/ecdh.dart -A 2 -B 2

# Check for any documentation about security implications
rg "timing|constant-time|side.channel" lib/src/ecdh.dart

# Look for the overall context of the file
head -20 lib/src/ecdh.dart

Repository: Bdaya-Dev/jose


Repository: Bdaya-Dev/jose
Exit code: 0

stdout:


  /// Scalar multiplication using double-and-add.
  _ECPoint multiply(BigInt k, BigInt p, BigInt a) {
    var result = _ECPoint.infinity();
    var base = this;
--
  }

  final result = q.multiply(privateKey.eccPrivateKey, curve.p, curve.a);

  if (result.isInfinity) {
/// 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;

Script executed:

# Search for how this library is documented and used
find . -name "README*" -o -name "*.md" | head -10

# Check for any security warnings or advisories in the codebase
rg "warning|WARNING|security|Security" lib/src/ecdh.dart -i

Repository: Bdaya-Dev/jose


Repository: Bdaya-Dev/jose
Exit code: 1

stdout:

./CHANGELOG.md
./README.md

Script executed:

# Look for how multiply is called and what keys are being used
rg "multiply" lib/src/ecdh.dart -B 5 -A 5

Repository: Bdaya-Dev/jose


Repository: Bdaya-Dev/jose
Exit code: 0

stdout:

    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) {
--
  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;

Script executed:

# Search for privateKey definition and context
rg "privateKey.*eccPrivateKey" lib/src/ecdh.dart -B 10 -A 5

# Look for the ECDH function signature
rg "perform.*ecdh|ecdh.*agreement" lib/src/ecdh.dart -i -B 2 -A 10

# Check for test files that show usage
find . -name "*test*" -o -name "*_test.dart" | head -5

Repository: Bdaya-Dev/jose


Repository: Bdaya-Dev/jose
Exit code: 0

stdout:


  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;
/// 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';

// ---------------------------------------------------------------------------
--
// ---------------------------------------------------------------------------

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');
--
}

/// 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) {
--
  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 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,
./test
./test/ecdh_test.dart
./test/jwa_test.dart
./test/jwe_test.dart
./test/jwk_test.dart

Script executed:

# Check the full ECDH function context to understand the flow
sed -n '65,120p' lib/src/ecdh.dart

Repository: Bdaya-Dev/jose


Repository: Bdaya-Dev/jose
Exit code: 0

stdout:

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',

Script executed:

# Look at how private keys are used in the JOSE/ECDH context
rg "eccPrivateKey" lib/src/ -B 3 -A 3

Repository: Bdaya-Dev/jose


Repository: Bdaya-Dev/jose
Exit code: 0

stdout:

lib/src/jwk.dart-
lib/src/jwk.dart-      return JsonWebKey.ec(
lib/src/jwk.dart-        curve: toCurveName(privateKey.curve),
lib/src/jwk.dart:        privateKey: privateKey.eccPrivateKey,
lib/src/jwk.dart-        xCoordinate: (publicKey as EcPublicKey?)?.xCoordinate,
lib/src/jwk.dart-        yCoordinate: publicKey?.yCoordinate,
lib/src/jwk.dart-        keyId: keyId,
--
lib/src/jwa.dart-            (keyPair.privateKey as RsaPrivateKey).secondPrimeFactor),
lib/src/jwa.dart-      },
lib/src/jwa.dart-      if (type == 'EC') ...{
lib/src/jwa.dart:        'd': encodeBigInt((keyPair.privateKey as EcPrivateKey).eccPrivateKey),
lib/src/jwa.dart-        'x': encodeBigInt((keyPair.publicKey as EcPublicKey).xCoordinate),
lib/src/jwa.dart-        'y': encodeBigInt((keyPair.publicKey as EcPublicKey).yCoordinate),
lib/src/jwa.dart-        'crv': curve,
--
lib/src/ecdh.dart-    throw ArgumentError('Public key point is not on the curve');
lib/src/ecdh.dart-  }
lib/src/ecdh.dart-
lib/src/ecdh.dart:  final result = q.multiply(privateKey.eccPrivateKey, curve.p, curve.a);
lib/src/ecdh.dart-
lib/src/ecdh.dart-  if (result.isInfinity) {
lib/src/ecdh.dart-    throw StateError('ECDH produced point at infinity');

Scalar multiplication is not constant-time (timing side-channel risk).

The double-and-add algorithm branches on bits of the scalar k, making it vulnerable to timing attacks. While this implementation is used with both ephemeral keys (sender side) and long-term/static keys (receiver side), the timing vulnerability is particularly relevant for the static key case, where the same key is reused across multiple ECDH operations.

Consider using Montgomery ladder or windowed methods for constant-time execution if constant-time properties are required.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/src/ecdh.dart` around lines 49 - 62, The multiply(BigInt k, BigInt p,
BigInt a) method uses a branching double-and-add loop that leaks scalar bits via
timing; replace it with a constant-time scalar multiplication (e.g., Montgomery
ladder) to remove branch dependence on n.isOdd. Update _ECPoint.multiply to
implement a constant-time ladder (or a constant-time windowed algorithm) that
uses only fixed-time conditional swaps and uniform operations on _ECPoint (refer
to the _ECPoint.add and _ECPoint.double_ helpers), ensuring no data-dependent
branches or early loop exits and that the loop runs a fixed number of iterations
based on the scalar bit-length.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements JWE key management via ECDH-ES (direct agreement) and ECDH-ES+A*KW (agreement + AES Key Wrap) and adds tests validating ECDH-ES roundtrips and header structure.

Changes:

  • Added an ecdh.dart implementation of ECDH-ES (Concat KDF) plus AES Key Wrap/Unwrap helpers.
  • Integrated ECDH-ES / ECDH-ES+A*KW into JWE build + decrypt flows, including epk header support.
  • Added a new test suite covering compact/JSON roundtrips, curves, and header expectations.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
test/ecdh_test.dart Adds ECDH-ES and ECDH-ES+A*KW roundtrip + header verification tests.
lib/src/jwe.dart Adds ECDH-ES CEK derivation / unwrap logic and emits epk during build.
lib/src/jwa.dart Registers new JWA algorithm identifiers for ECDH-ES and ECDH-ES+A*KW.
lib/src/jose.dart Extends JoseHeader with epk, apu, apv accessors.
lib/src/ecdh.dart New ECDH-ES implementation: curve math, Concat KDF, AES-KW wrap/unwrap.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/src/jwe.dart
Comment on lines +302 to +306
final result = ecdhEsDerive(
recipientPublicKey: key,
algorithmId: algId,
keyDataLen: keyLen,
);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ecdhEsDerive(...) is always called without apu/apv, but decryption does incorporate apu/apv from the JOSE header. If a caller sets apu/apv via setProtectedHeader, encryption will derive a different key than decryption expects and roundtrips/interop will fail. Please plumb apu/apv (decoded from the header’s base64url values) into ecdhEsDerive and ensure the same values are written to the header.

Copilot uses AI. Check for mistakes.
Comment thread lib/src/ecdh.dart
Comment on lines +54 to +58
while (n > BigInt.zero) {
if (n.isOdd) {
result = result.add(base, p, a);
}
base = base.double_(p, a);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_ECPoint.multiply uses a classic double-and-add loop with a conditional branch on each bit of the scalar (n.isOdd). Because this is used with private scalars during ECDH, it is not constant-time and can leak key-dependent timing information. Prefer a constant-time ECDH implementation from a vetted crypto provider (or ensure scalar multiplication is constant-time) rather than custom BigInt-based point arithmetic.

Copilot uses AI. Check for mistakes.
Comment thread lib/src/ecdh.dart
final curve = _curveForName(curveName);

final q = _ECPoint(publicKey.xCoordinate, publicKey.yCoordinate);

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public key validation in _ecdhAgreement reduces coordinates modulo p when checking the curve equation, but it does not reject non-canonical coordinates (e.g., x >= p or y >= p). For stricter input validation and to reduce attack surface, explicitly check that 0 <= x < p and 0 <= y < p before the on-curve test (and reject otherwise).

Suggested change
if (q.x < BigInt.zero ||
q.x >= curve.p ||
q.y < BigInt.zero ||
q.y >= curve.p) {
throw ArgumentError('Public key coordinates are out of range');
}

Copilot uses AI. Check for mistakes.
Comment thread lib/src/jwa.dart
Comment on lines +174 to +186
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');
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new ECDH-ES algorithm constants hardcode curve: 'P-256', which means JsonWebKey.generate('ECDH-ES') (and the +A*KW variants) will always generate P-256 keys even though the feature is intended to be compatible with P-384 and P-521 as well. Consider not pinning a specific curve for these algorithms (or add a way to choose the curve when generating ECDH keys) so callers can generate P-384/P-521 ECDH keys without workarounds.

Suggested change
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');
JsonWebAlgorithm('ECDH-ES', type: 'EC', use: 'key');
/// ECDH-ES using Concat KDF and A128KW wrapping
static const ecdh_es_a128kw =
JsonWebAlgorithm('ECDH-ES+A128KW', type: 'EC', use: 'key');
/// ECDH-ES using Concat KDF and A192KW wrapping
static const ecdh_es_a192kw =
JsonWebAlgorithm('ECDH-ES+A192KW', type: 'EC', use: 'key');
/// ECDH-ES using Concat KDF and A256KW wrapping
static const ecdh_es_a256kw =
JsonWebAlgorithm('ECDH-ES+A256KW', type: 'EC', use: 'key');

Copilot uses AI. Check for mistakes.
Comment thread test/ecdh_test.dart
Comment on lines +249 to +250
..addRecipient(JsonWebKey.fromJson({'kty': 'oct', 'k': ''})!,
algorithm: 'none');
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the inner JWS using alg: 'none', you can pass null as the recipient key instead of constructing an oct JWK with an empty k value. Using a dummy empty symmetric key is unnecessary here and may become brittle if JWK parsing tightens validation.

Suggested change
..addRecipient(JsonWebKey.fromJson({'kty': 'oct', 'k': ''})!,
algorithm: 'none');
..addRecipient(null, algorithm: 'none');

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants