A naive OPRF + 3DH collapses the augmented-PAKE security properties. The failure modes this solution fixes:
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:
A naive OPRF + 3DH collapses the augmented-PAKE security properties. The failure modes this solution fixes:
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.
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).
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.
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.
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.
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.
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
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 - 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"}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:
A naive OPRF + 3DH collapses the augmented-PAKE security properties. The failure modes this solution fixes:
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.
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).
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.
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.
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.
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.
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
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 - 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"}