Decryption workflow

Simplified guide to decrypting JWE using ECDH-ES and AES-GCM, with security features highlighted.

By subscribing to the Notabene webhook events, your system will be notified of a satisfied requirePresentation PII policy. Your system can GET the Transfer including the received, encrypted PII, which you now are able to decrypt on your servers.

Decryption Workflow Steps

Step 1: JWE Parsing and Validation

First, split the JWE string into its five components. Ensure the encrypted key segment is empty, indicating direct ECDH‑ES mode, and verify the algorithm and encryption headers match the expected values. Parse the ephemeral public key and confirm it is valid.

// Parse JWE compact serialization
const parts = jwe.split(".");
if (parts.length !== 5) {
  throw new Error("Invalid JWE compact serialization");
}
const [headerB64, encryptedKeyB64, ivB64, ciphertextB64, tagB64] = parts;

// ECDH-ES requires empty encrypted_key segment
if (encryptedKeyB64 !== "") {
  throw new Error("Expected direct ECDH-ES (empty encrypted_key)");
}

// Parse and validate protected header
const header = JSON.parse(
  new TextDecoder().decode(jose.base64url.decode(headerB64)),
);
if (header.alg !== "ECDH-ES" || header.enc !== "A256GCM") {
  throw new Error(`Unsupported algorithm: ${header.alg}/${header.enc}`);
}

Step 2: Ephemeral Key Validation

Validate the ephemeral public key from the header to ensure it is on the correct curve.

// Validate ephemeral public key from header
if (!header.epk || header.epk.kty !== "EC" || header.epk.crv !== "P-256") {
  throw new Error("Invalid or missing ephemeral public key");
}

// Verify point is on P-256 curve
const epkX = jose.base64url.decode(header.epk.x);
const epkY = jose.base64url.decode(header.epk.y);
assertOnP256(epkX, epkY); // Throws if invalid point

Step 3: Sealed Private Key Operations

Retrieve your private key from a Hardware Security Module or cloud KMS. In Notabene’s reference implementation the key is imported into the Web Crypto API as a non‑exportable JWK. The code below derives the public coordinates and imports the private key:

// Load private key from KMS (never exported)
const privateKeyStore = this.kms.keyStore;
const privateKey = await privateKeyStore.getKey({ alias: kid });

// Derive public coordinates from private key
const dBytes = hexToFixed(privateKey.privateKeyHex, 32);
const { xB64u, yB64u } = this.deriveXYFromDHex(privateKey.privateKeyHex);

// Import non-exportable private key into Web Crypto
const privJwk = {
  kty: "EC",
  crv: "P-256",
  d: jose.base64url.encode(dBytes),
  x: xB64u,
  y: yB64u,
};
const sk = await wc.subtle.importKey(
  "jwk",
  privJwk,
  { name: "ECDH", namedCurve: "P-256" },
  false, // non-exportable
  ["deriveBits"],
);

Step 4: ECDH and Key Derivation

Import the sender’s ephemeral public key from the JWE header and perform ECDH with your private key to derive the shared secret. Then use the same ConcatKDF to derive the CEK. If the JWE header contains apu or apv, verify that they match the expected SHA‑256 hashes of the sender’s DID and the key identifier. Reject mismatches to mitigate unknown‑key‑share attacks.

// Import ephemeral public key
const ephemeralPubKey = await wc.subtle.importKey(
  "jwk",
  header.epk,
  { name: "ECDH", namedCurve: "P-256" },
  false,
  [],
);

// Perform ECDH to derive shared secret
const sharedSecretBits = await wc.subtle.deriveBits(
  { name: "ECDH", public: ephemeralPubKey },
  sk,
  256,
);

// Validate APU/APV bindings (UKS protection)
let apu = new Uint8Array(0), apv = new Uint8Array(0);
if (header.apu) {
  const headerApu = jose.base64url.decode(header.apu);
  if (expectedSenderId) {
    const apuExpected = await sha256(expectedSenderId);
    if (!timingSafeEqual(headerApu, apuExpected)) {
      throw new Error("APU mismatch - potential UKS attack");
    }
  }
  apu = headerApu;
}

// Derive CEK using same ConcatKDF as encryption
const cek = await concatKdf(sharedSecret, {
  algId: "A256GCM",
  apu,
  apv,
  keyDataLenBits: 256,
});

Step 5: AES-GCM Decryption

Decode the necessary components and perform AES-GCM decryption to retrieve the original payload.

// Decode JWE components
const iv = jose.base64url.decode(ivB64);
const ciphertext = jose.base64url.decode(ciphertextB64);
const tag = jose.base64url.decode(tagB64);

// Import CEK for AES-GCM
const aesKey = await wc.subtle.importKey(
  "raw",
  cek,
  { name: "AES-GCM" },
  false,
  ["decrypt"],
);

// Decrypt with AAD = protected header bytes
const aad = new TextEncoder().encode(headerB64);
const ciphertextWithTag = new Uint8Array(ciphertext.length + tag.length);
ciphertextWithTag.set(ciphertext);
ciphertextWithTag.set(tag, ciphertext.length);

const plaintext = await wc.subtle.decrypt(
  { name: "AES-GCM", iv, additionalData: aad, tagLength: 128 },
  aesKey,
  ciphertextWithTag,
);
🔒

Security Features:

  • Sealed Operations: Private keys never leave KMS boundary
  • UKS Protection: APU/APV validation prevents unknown key-share attacks
  • Timing-Safe Comparisons: Constant-time validation of bindings
  • Curve Validation: Ephemeral keys verified to be on P-256 curve

Did this page help you?