◐ Off-By-One · answer catalog

js-opaque-pake-augmented-transcript-binding-ke2

2 answer(s)jsnode20jsnode20

A naive OPRF + 3DH collapses the augmented-PAKE security properties. The failure modes this solution fixes:

📦 Source in repository (JSON)

Answer 1

The solution is complete and verified. Files are in ~/opaque/: opaque.mjs (implementation), test.mjs (tests), SOLUTION.md (full self-contained write-up with both files embedded), and package.json.

Here is the solution write-up:

OPAQUE-3DH (RFC 9807) in Node.js — transcript binding, key schedule, and augmented-PAKE failure semantics

Root-cause analysis

A naive OPRF + 3DH collapses the augmented-PAKE security properties. The failure modes this solution fixes:

  1. OPRF "twist" + randomized password. RFC 9497 Finalize does not return just the unblinded point — it hashes the original input back in: oprf_output = Hash(I2OSP(len(input),2)||input || I2OSP(len(unblinded),2)||unblinded || "Finalize"). OPAQUE then uses randomized_password = Extract("", oprf_output || Stretch(oprf_output)) (Extract, not Expand; 128-byte IKM → 64-byte output). Using the raw OPRF output as the seed removes the password/OPRF binding.

  2. Per-credential OPRF key. The server must derive seed = Expand(oprf_seed, credential_identifier || "OprfKey", Nok) then DeriveKeyPair(seed, "OPAQUE-DeriveKeyPair"). Evaluating with the global oprf_seed shares OPRF keys across users (enumeration).

  3. Envelope / key recovery. masking_key, auth_key, export_key, and the client AKE seed are all Expand(randomized_password, nonce||"…") with distinct labels. The envelope auth_tag = MAC(auth_key, nonce || server_pk || len(sid)||sid || len(cid)||cid). Deriving the AKE key from oprf_output instead of the nonce-bound seed, or omitting identities, breaks key recovery and identity binding.

  4. Transcript order and key schedule. Preamble = "OPAQUEv1-" || ctx || client_identity || KE1 || server_identity || CredentialResponse || server_nonce || server_keyshare. Then prk = Extract("", ikm); handshake_secret = Derive-Secret(prk,"HandshakeSecret",Hash(preamble)) session_key = Derive-Secret(prk,"SessionKey",Hash(preamble)) Km2 = Derive-Secret(handshake_secret,"ServerMAC","") // empty context Km3 = Derive-Secret(handshake_secret,"ClientMAC","") server_mac = MAC(Km2, Hash(preamble)) expected_client_mac = MAC(Km3, Hash(preamble || server_mac)) Derive-Secret = Expand-Label with the TLS-1.3 HkdfLabel encoding and the "OPAQUE-" prefix.

  5. 3DH operand ordering. Both sides must multiply the same scalar by the same point in the fixed list (eph·eph, eph·static, static·eph). Mismatched operands/order yields different ikm.

  6. Uniform auth failure. Wrong password → wrong OPRF → wrong randomized_password → envelope MAC fails. The client must surface one indistinguishable error, otherwise it becomes an oracle revealing whether the server's OPRF evaluation was valid — destroying pre-computation resistance.

Note: RFC 9807 defines KE1/KE2/KE3; there is no wire KE4. The exchange terminates in AuthServerFinalize (server computes the session key after verifying KE3). The implementation provides the complete 3-message flow.

Exact fix

mkdir opaque && cd opaque && npm init -y
npm install @noble/curves @noble/hashes
# package.json: "type": "module"
node test.mjs

Full source is ~/opaque/opaque.mjs and tests are ~/opaque/test.mjs. The critical pieces:

OPRF + randomized password (the twist):

export function deriveOprfKey(oprfSeed, credentialIdentifier) {
  const seed = Expand(oprfSeed, concat(credentialIdentifier, ascii('OprfKey')), Params.Nok);
  return ristretto255_oprf.oprf.deriveKeyPair(seed, ascii('OPAQUE-DeriveKeyPair')).secretKey;
}
// ...
const oprfOutput = oprfFinalize(password, blind, evaluatedElement);
const randomizedPassword = Extract('', concat(oprfOutput, Stretch(oprfOutput)));

Identical DH ordering:

// client
const dh1 = DiffieHellman(clientState.client_secret, ar.server_public_keyshare);
const dh2 = DiffieHellman(clientState.client_secret, cc.serverPublicKey);
const dh3 = DiffieHellman(clientPrivateKey, ar.server_public_keyshare);
// server — same scalars, same points
const dh1 = DiffieHellman(serverPrivateKeyshare, ke1.auth_request.client_public_keyshare);
const dh2 = DiffieHellman(serverPrivateKey,    ke1.auth_request.client_public_keyshare);
const dh3 = DiffieHellman(serverPrivateKeyshare, clientPublicKey);

Key schedule (label-sensitive):

export function deriveKeys(ikm, preambleBytes, labels = {}) {
  const serverLabel = labels.serverLabel ?? 'ServerMAC';
  const clientLabel = labels.clientLabel ?? 'ClientMAC';
  const prk = Extract('', ikm);
  const hash = Hash(preambleBytes);
  const handshakeSecret = deriveSecret(prk, 'HandshakeSecret', hash);
  const sessionKey      = deriveSecret(prk, 'SessionKey', hash);
  const Km2 = deriveSecret(handshakeSecret, serverLabel, new Uint8Array(0));
  const Km3 = deriveSecret(handshakeSecret, clientLabel, new Uint8Array(0));
  return { Km2, Km3, sessionKey };
}

Uniform failure (no OPRF oracle):

if (!ctEqual(authTag, expectedTag)) throw new OPAQUEError('authentication failed');
// ...and identically in authClientFinalize / authServerFinalize

Verification

npm test → 10 passed, 0 failed:

[1] RFC 9807 Appendix C.1.1 -- Real Test Vector 1 (ristretto255-SHA512)
  ok  - registration: request/response/oprf_key/upload/export_key all match
  ok  - AKE: KE1/KE2/KE3/session_key/intermediates all match
[2] Randomised end-to-end round trip
  ok  - registration + login agree on session_key and export_key (with identities)
  ok  - login works with default-to-public-key identities (no identities)
[3] Negative tests
  ok  - swapped key-schedule labels change Km2/Km3 and are rejected
  ok  - replay of a stale KE1 cannot be combined with an old KE2 (transcript binds KE1)
  ok  - replayed KE1 gets a fresh KE2; the stale KE3 then fails
  ok  - reflection: server_mac used as client_mac is rejected
  ok  - wrong password: KE2 fails MAC verification with a uniform error (no OPRF oracle)
  ok  - transcript tamper: MITM changing the client nonce in KE1 is rejected

The first test reproduces every RFC 9807 Appendix C.1.1 intermediate byte-for-byte — oprf_key, auth_key, randomized_password, envelope, handshake_secret, server_mac_key, client_mac_key, registration_request/response/upload, KE1/KE2/KE3, export_key, session_key — which independently confirms the transcript order, Derive-Secret labels, DH ordering, and envelope derivation.

The negative tests assert the single uniform OPAQUEError("authentication failed") and that a valid OPRF evaluation with a tampered envelope is indistinguishable from a wrong-password attempt, satisfying the key-recovery- and pre-computation-resistance requirement.

Evidence & signatures

# Evidence
- Problem class: js-opaque-pake-augmented-transcript-binding-ke2
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-29T22:27:34.266Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement RFC 9807 OPAQUE augmented PAKE end to end in Node: OPRF blind/evaluate/finalize with the twist, randomized-password envelope derivation, KE1/KE2/KE3/KE4 message flow, the 3DH AKE sharing the exact same Diffie-Hellman operand ordering on both sides, and HMAC transcript binding over all prior messages. The hard parts are the exact transcript hashing order, the Derive-Secret label key schedule, and keeping the client key-recovery- and pre-computation-resistant property that a malicious server cannot test a password guess offline without a full AKE exchange. Include negative tests for swapped key-schedule labels, replay of a stale KE1, reflection of a server message back to itself, and a wrong-password KE2 that must fail MAC verification without revealing whether the OPRF evaluation was valid.", "environment": "node20", "language": "js", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "js-opaque-pake-augmented-transcript-binding-ke2", "provider": "openrouter", "solved_at": "2026-09-29T22:27:34.266Z", "version": "20"}

Answer 2

The solution is complete and verified. Files are in ~/opaque/: opaque.mjs (implementation), test.mjs (tests), SOLUTION.md (full self-contained write-up with both files embedded), and package.json.

Here is the solution write-up:

OPAQUE-3DH (RFC 9807) in Node.js — transcript binding, key schedule, and augmented-PAKE failure semantics

Root-cause analysis

A naive OPRF + 3DH collapses the augmented-PAKE security properties. The failure modes this solution fixes:

  1. OPRF "twist" + randomized password. RFC 9497 Finalize does not return just the unblinded point — it hashes the original input back in: oprf_output = Hash(I2OSP(len(input),2)||input || I2OSP(len(unblinded),2)||unblinded || "Finalize"). OPAQUE then uses randomized_password = Extract("", oprf_output || Stretch(oprf_output)) (Extract, not Expand; 128-byte IKM → 64-byte output). Using the raw OPRF output as the seed removes the password/OPRF binding.

  2. Per-credential OPRF key. The server must derive seed = Expand(oprf_seed, credential_identifier || "OprfKey", Nok) then DeriveKeyPair(seed, "OPAQUE-DeriveKeyPair"). Evaluating with the global oprf_seed shares OPRF keys across users (enumeration).

  3. Envelope / key recovery. masking_key, auth_key, export_key, and the client AKE seed are all Expand(randomized_password, nonce||"…") with distinct labels. The envelope auth_tag = MAC(auth_key, nonce || server_pk || len(sid)||sid || len(cid)||cid). Deriving the AKE key from oprf_output instead of the nonce-bound seed, or omitting identities, breaks key recovery and identity binding.

  4. Transcript order and key schedule. Preamble = "OPAQUEv1-" || ctx || client_identity || KE1 || server_identity || CredentialResponse || server_nonce || server_keyshare. Then prk = Extract("", ikm); handshake_secret = Derive-Secret(prk,"HandshakeSecret",Hash(preamble)) session_key = Derive-Secret(prk,"SessionKey",Hash(preamble)) Km2 = Derive-Secret(handshake_secret,"ServerMAC","") // empty context Km3 = Derive-Secret(handshake_secret,"ClientMAC","") server_mac = MAC(Km2, Hash(preamble)) expected_client_mac = MAC(Km3, Hash(preamble || server_mac)) Derive-Secret = Expand-Label with the TLS-1.3 HkdfLabel encoding and the "OPAQUE-" prefix.

  5. 3DH operand ordering. Both sides must multiply the same scalar by the same point in the fixed list (eph·eph, eph·static, static·eph). Mismatched operands/order yields different ikm.

  6. Uniform auth failure. Wrong password → wrong OPRF → wrong randomized_password → envelope MAC fails. The client must surface one indistinguishable error, otherwise it becomes an oracle revealing whether the server's OPRF evaluation was valid — destroying pre-computation resistance.

Note: RFC 9807 defines KE1/KE2/KE3; there is no wire KE4. The exchange terminates in AuthServerFinalize (server computes the session key after verifying KE3). The implementation provides the complete 3-message flow.

Exact fix

mkdir opaque && cd opaque && npm init -y
npm install @noble/curves @noble/hashes
# package.json: "type": "module"
node test.mjs

Full source is ~/opaque/opaque.mjs and tests are ~/opaque/test.mjs. The critical pieces:

OPRF + randomized password (the twist):

export function deriveOprfKey(oprfSeed, credentialIdentifier) {
  const seed = Expand(oprfSeed, concat(credentialIdentifier, ascii('OprfKey')), Params.Nok);
  return ristretto255_oprf.oprf.deriveKeyPair(seed, ascii('OPAQUE-DeriveKeyPair')).secretKey;
}
// ...
const oprfOutput = oprfFinalize(password, blind, evaluatedElement);
const randomizedPassword = Extract('', concat(oprfOutput, Stretch(oprfOutput)));

Identical DH ordering:

// client
const dh1 = DiffieHellman(clientState.client_secret, ar.server_public_keyshare);
const dh2 = DiffieHellman(clientState.client_secret, cc.serverPublicKey);
const dh3 = DiffieHellman(clientPrivateKey, ar.server_public_keyshare);
// server — same scalars, same points
const dh1 = DiffieHellman(serverPrivateKeyshare, ke1.auth_request.client_public_keyshare);
const dh2 = DiffieHellman(serverPrivateKey,    ke1.auth_request.client_public_keyshare);
const dh3 = DiffieHellman(serverPrivateKeyshare, clientPublicKey);

Key schedule (label-sensitive):

export function deriveKeys(ikm, preambleBytes, labels = {}) {
  const serverLabel = labels.serverLabel ?? 'ServerMAC';
  const clientLabel = labels.clientLabel ?? 'ClientMAC';
  const prk = Extract('', ikm);
  const hash = Hash(preambleBytes);
  const handshakeSecret = deriveSecret(prk, 'HandshakeSecret', hash);
  const sessionKey      = deriveSecret(prk, 'SessionKey', hash);
  const Km2 = deriveSecret(handshakeSecret, serverLabel, new Uint8Array(0));
  const Km3 = deriveSecret(handshakeSecret, clientLabel, new Uint8Array(0));
  return { Km2, Km3, sessionKey };
}

Uniform failure (no OPRF oracle):

if (!ctEqual(authTag, expectedTag)) throw new OPAQUEError('authentication failed');
// ...and identically in authClientFinalize / authServerFinalize

Verification

npm test → 10 passed, 0 failed:

[1] RFC 9807 Appendix C.1.1 -- Real Test Vector 1 (ristretto255-SHA512)
  ok  - registration: request/response/oprf_key/upload/export_key all match
  ok  - AKE: KE1/KE2/KE3/session_key/intermediates all match
[2] Randomised end-to-end round trip
  ok  - registration + login agree on session_key and export_key (with identities)
  ok  - login works with default-to-public-key identities (no identities)
[3] Negative tests
  ok  - swapped key-schedule labels change Km2/Km3 and are rejected
  ok  - replay of a stale KE1 cannot be combined with an old KE2 (transcript binds KE1)
  ok  - replayed KE1 gets a fresh KE2; the stale KE3 then fails
  ok  - reflection: server_mac used as client_mac is rejected
  ok  - wrong password: KE2 fails MAC verification with a uniform error (no OPRF oracle)
  ok  - transcript tamper: MITM changing the client nonce in KE1 is rejected

The first test reproduces every RFC 9807 Appendix C.1.1 intermediate byte-for-byte — oprf_key, auth_key, randomized_password, envelope, handshake_secret, server_mac_key, client_mac_key, registration_request/response/upload, KE1/KE2/KE3, export_key, session_key — which independently confirms the transcript order, Derive-Secret labels, DH ordering, and envelope derivation.

The negative tests assert the single uniform OPAQUEError("authentication failed") and that a valid OPRF evaluation with a tampered envelope is indistinguishable from a wrong-password attempt, satisfying the key-recovery- and pre-computation-resistance requirement.

Evidence & signatures

# Evidence
- Problem class: js-opaque-pake-augmented-transcript-binding-ke2
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-29T22:27:34.266Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement RFC 9807 OPAQUE augmented PAKE end to end in Node: OPRF blind/evaluate/finalize with the twist, randomized-password envelope derivation, KE1/KE2/KE3/KE4 message flow, the 3DH AKE sharing the exact same Diffie-Hellman operand ordering on both sides, and HMAC transcript binding over all prior messages. The hard parts are the exact transcript hashing order, the Derive-Secret label key schedule, and keeping the client key-recovery- and pre-computation-resistant property that a malicious server cannot test a password guess offline without a full AKE exchange. Include negative tests for swapped key-schedule labels, replay of a stale KE1, reflection of a server message back to itself, and a wrong-password KE2 that must fail MAC verification without revealing whether the OPRF evaluation was valid.", "environment": "node20", "language": "js", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "js-opaque-pake-augmented-transcript-binding-ke2", "provider": "openrouter", "solved_at": "2026-09-29T22:27:34.266Z", "version": "20"}
Generated from the verified corpus · MIT licensedBack to the catalog