Browser/Node TypeScript client for the Hofmann Elimination OPRF and OPAQUE server.
Implements:
- RFC 9497 — Oblivious Pseudorandom Functions (OPRF)
- RFC 9807 — OPAQUE-3DH password-authenticated key exchange
Supported cipher suites:
| Suite | Curve | Hash | Nh | Npk |
|---|---|---|---|---|
P256_SHA256 |
P-256 | SHA-256 | 32 bytes | 33 bytes |
P384_SHA384 |
P-384 | SHA-384 | 48 bytes | 49 bytes |
P521_SHA512 |
P-521 | SHA-512 | 64 bytes | 67 bytes |
The active suite is negotiated automatically from the server's /opaque/config endpoint — no hardcoding required.
All cryptography is built on @noble/curves and @noble/hashes. No native bindings, no WebAssembly (except the optional Argon2id KSF via hash-wasm).
Security notice: This implementation has not undergone a formal security audit. See the parent project for full details.
- Installation
- Quick Start
- Cipher Suites
- Configuration
- API Reference
- Publishing to npm
- Interactive Demo
- Running Tests
- Building
- Project Structure
npm install @codeheadsystems/hofmann-typescriptDependencies pulled in automatically:
| Package | Purpose |
|---|---|
@noble/curves |
P-256, P-384, P-521 elliptic curve arithmetic and hash-to-curve |
@noble/hashes |
SHA-256, SHA-384, SHA-512, HMAC, HKDF |
hash-wasm |
Argon2id key stretching (only loaded when used) |
The recommended way to create a client is via OpaqueHttpClient.create(). It fetches the server's /opaque/config endpoint and automatically configures the cipher suite, protocol context, and Argon2id parameters — no manual configuration needed:
import { OpaqueHttpClient } from '@codeheadsystems/hofmann-typescript';
// Fetches /opaque/config → resolves cipher suite (P-256/P-384/P-521),
// context string, and Argon2id parameters automatically.
const client = await OpaqueHttpClient.create('https://your-server.example.com');
// Register a new user (run once)
await client.register('alice@example.com', 'hunter2');
// Authenticate (every login) — returns a JWT bearer token
const token = await client.authenticate('alice@example.com', 'hunter2');
console.log('JWT:', token);
// Delete a registration (requires a valid token)
await client.deleteRegistration('alice@example.com', token);import { OprfHttpClient } from '@codeheadsystems/hofmann-typescript';
import { strToBytes } from '@codeheadsystems/hofmann-typescript';
// Fetches /oprf/config → resolves cipher suite automatically.
const client = await OprfHttpClient.create('https://your-server.example.com');
const result = await client.evaluate(strToBytes('my-secret-input'));
// result is a stable Nh-byte Uint8Array — same every time for the same input and server key
// Nh = 32 (P-256), 48 (P-384), or 64 (P-521) depending on server configurationA CipherSuite encapsulates all curve-specific operations (hash-to-curve, scalar arithmetic, hash/MAC/HKDF with the appropriate hash function) and the RFC 9497 domain-separation strings.
import { P256_SHA256, P384_SHA384, P521_SHA512, getCipherSuite } from '@codeheadsystems/hofmann-typescript';
import type { CipherSuite } from '@codeheadsystems/hofmann-typescript';| Export | Curve | Hash | Nh | Npk | Nsk | L |
|---|---|---|---|---|---|---|
P256_SHA256 |
P-256 | SHA-256 | 32 | 33 | 32 | 48 |
P384_SHA384 |
P-384 | SHA-384 | 48 | 49 | 48 | 72 |
P521_SHA512 |
P-521 | SHA-512 | 64 | 67 | 66 | 98 |
Nh = hash output length · Npk = compressed public key size · Nsk = scalar size · L = hashToScalar expand length
Resolves a suite by the name string returned in server config responses:
import { getCipherSuite } from '@codeheadsystems/hofmann-typescript';
const suite = getCipherSuite('P384_SHA384'); // returns P384_SHA384Accepted values: "P256_SHA256", "P384_SHA384", "P521_SHA512". Throws for any other value.
const suite = P384_SHA384;
// Blind an input
const { blind, blindedElement } = suite.blind(inputBytes);
// Finalize after server evaluation (returns Nh=48 bytes for P-384)
const output = suite.finalize(inputBytes, blind, evaluatedElement);
// Hash/MAC/HKDF using the suite's hash function (SHA-384 for P-384)
const hash = suite.hash(data);
const mac = suite.hmac(key, data);
const prk = suite.hkdfExtract(undefined, ikm);
const keyMat = suite.hkdfExpand(prk, info, 48);OpaqueHttpClient.create() and OprfHttpClient.create() call the server's config endpoint and set everything automatically. Use these factories in production and in the integration tests.
const client = await OpaqueHttpClient.create('https://your-server.example.com');
console.log(client.configResponse);
// {
// cipherSuite: "P384_SHA384",
// context: "my-app",
// argon2MemoryKib: 65536,
// argon2Iterations: 3,
// argon2Parallelism: 1
// }If you need to construct the client manually (e.g., to pin a specific cipher suite or supply a custom KSF):
import { OpaqueHttpClient, P384_SHA384, argon2idKsf } from '@codeheadsystems/hofmann-typescript';
const client = new OpaqueHttpClient('http://localhost:8080', {
suite: P384_SHA384,
context: 'my-app',
ksf: argon2idKsf(65536, 3, 1),
});The context string and Argon2id parameters must exactly match the server's configuration. A mismatch causes silent authentication failure that is indistinguishable from a wrong password.
For a Dropwizard server, look at the YAML configuration:
# hofmann-testserver/config/config.yml (example)
cipherSuite: P384_SHA384
context: hofmann-testserver
argon2MemoryKib: 65536
argon2Iterations: 3
argon2Parallelism: 1If the server has argon2MemoryKib: 0 (identity KSF, no Argon2), the create() factory handles this automatically. When constructing manually, omit ksf or pass identityKsf:
import { OpaqueHttpClient, identityKsf } from '@codeheadsystems/hofmann-typescript';
const client = new OpaqueHttpClient('http://localhost:8080', {
context: 'my-test-context',
// ksf defaults to identityKsf when omitted
});Do not use identity KSF against a production server. It disables password hardening.
The main entry point. Handles the full OPAQUE-3DH protocol over HTTP.
Fetches GET /opaque/config, resolves the cipher suite and KSF automatically, and returns a configured client.
const client = await OpaqueHttpClient.create('https://your-server.example.com');
// client.configResponse holds the raw server responseManual constructor. All options default to P-256/SHA-256 with identity KSF when omitted.
OpaqueHttpClientOptions
| Field | Type | Default | Description |
|---|---|---|---|
suite |
CipherSuite |
P256_SHA256 |
Cipher suite. Must match the server's configured suite. |
context |
string |
"" |
OPAQUE protocol context. Must match context in server config. |
ksf |
KSF |
identityKsf |
Key stretching function. Use argon2idKsf(...) for production. |
Runs the three-message OPAQUE registration flow:
- Sends
blindedElementtoPOST /opaque/registration/start - Receives
evaluatedElementandserverPublicKey - Derives the envelope locally using the KSF
- Uploads
clientPublicKey,maskingKey, andenvelopetoPOST /opaque/registration/finish
The password never leaves the client in plaintext.
await client.register('alice@example.com', 's3cr3t');
// With explicit identities (advanced — must match the server's identity config)
await client.register('alice@example.com', 's3cr3t', 'server.example.com', 'alice@example.com');Runs the three-message OPAQUE-3DH authentication flow (KE1 → KE2 → KE3):
- Generates KE1 (blind password, generate ephemeral AKE key pair)
- Sends to
POST /opaque/auth/start, receives KE2 - Verifies the server MAC — throws if wrong password or server mismatch
- Sends KE3 (client MAC) to
POST /opaque/auth/finish - Returns the JWT bearer token from the server
const token = await client.authenticate('alice@example.com', 's3cr3t');
// Use token in subsequent requests:
// Authorization: Bearer <token>Throws an Error if the password is incorrect, the server MAC fails, or a network/HTTP error occurs.
Sends DELETE /opaque/registration with the Authorization: Bearer <token> header.
await client.deleteRegistration('alice@example.com', token);Standalone OPRF client for the /oprf endpoint. Useful when you want a server-keyed pseudorandom function without the full OPAQUE flow.
Fetches GET /oprf/config, resolves the cipher suite, and returns a configured client.
const client = await OprfHttpClient.create('https://your-server.example.com');
// client.cachedConfig.cipherSuite tells you which suite the server usesManual constructor. Defaults to P256_SHA256 when suite is omitted.
Returns a stable Nh-byte value. The same input always produces the same output for a given server key. The server learns nothing about input — it sees only the blinded EC point.
Output length matches the suite: 32 bytes (P-256), 48 bytes (P-384), or 64 bytes (P-521).
const client = await OprfHttpClient.create('https://your-server.example.com');
const output = await client.evaluate(strToBytes('my-input'));
console.log(output.length); // 32, 48, or 64 depending on server suiteThe OpaqueClient class implements OPAQUE cryptographic operations without any HTTP transport. Pass a CipherSuite to the constructor to select the suite; defaults to P256_SHA256.
import { OpaqueClient, P384_SHA384, identityKsf, argon2idKsf } from '@codeheadsystems/hofmann-typescript';
import type { KSF, CipherSuite } from '@codeheadsystems/hofmann-typescript';
const suite: CipherSuite = P384_SHA384;
const client = new OpaqueClient(suite);
const ksf: KSF = argon2idKsf(65536, 3, 1);
// ── Registration ────────────────────────────────────────────────────────────
// Step 1a: create registration request
const regState = client.createRegistrationRequest(passwordBytes);
// regState.blindedElement (Npk bytes) → send to server
// Step 1c: finalize registration (after receiving server response)
const record = await client.finalizeRegistration(
regState,
{ evaluatedElement, serverPublicKey }, // from server
null, // serverIdentity (null = use serverPublicKey)
null, // clientIdentity (null = use derived clientPublicKey)
undefined, // envelopeNonce (undefined = random)
ksf // key stretching function
);
// record.clientPublicKey (Npk bytes), record.maskingKey (Nh bytes),
// record.envelope.nonce (32 bytes), record.envelope.authTag (Nh bytes) → upload to server
// ── Authentication ──────────────────────────────────────────────────────────
// Step 2a: generate KE1
const { state, ke1Bytes } = client.generateKE1(passwordBytes);
// ke1Bytes = blindedElement (Npk) || clientNonce (32) || clientAkePk (Npk) → send to server
// Step 2c: generate KE3 after receiving KE2 from server
const authResult = await client.generateKE3(
state,
ke2, // KE2 object with evaluatedElement, maskingNonce, maskedResponse, etc.
null, // clientIdentity
null, // serverIdentity
contextBytes, // application context (must match server)
ksf // key stretching function
);
// authResult.clientMac (Nh bytes) → send to server as KE3
// authResult.sessionKey (Nh bytes) → shared session key
// authResult.exportKey (Nh bytes) → optional export key for application useKey sizes scale with the suite's Nh and Npk — for P-256 these are 32 and 33 bytes respectively; for P-384, 48 and 49; for P-521, 64 and 67.
Deterministic variants for testing:
// Fixed blind scalar — produces identical blinded elements for RFC test vectors
const regState = client.createRegistrationRequestDeterministic(password, blindScalar);
// Fixed nonces and AKE seed — produces identical KE1 for RFC test vectors
const { state } = client.generateKE1Deterministic(
password, blindScalar, clientNonce, clientAkeSeed
);The KSF is applied to the raw OPRF output before HKDF derives randomizedPwd. The client and server must use the same KSF and parameters at all times — changing them after registration invalidates all existing credentials.
import { identityKsf, argon2idKsf, type KSF } from '@codeheadsystems/hofmann-typescript';No stretching — the OPRF output is used directly. Appropriate only for testing with servers configured with argon2MemoryKib: 0. This is the default when ksf is omitted from OpaqueHttpClientOptions.
Returns an Argon2id key stretching function with a 32-byte all-zero salt and 32-byte output, matching the server's implementation.
// Matching hofmann-testserver defaults
const ksf = argon2idKsf(65536, 3, 1);hash-wasm is loaded on demand the first time argon2idKsf is invoked; there is no startup cost if identity KSF is used.
The KSF type is (input: Uint8Array) => Promise<Uint8Array>. Any async function works:
const myKsf: KSF = async (input) => {
return stretchedOutput; // custom key stretching
};This package is published to the public npm registry under the @codeheadsystems scope.
-
Create an npm account at https://www.npmjs.com/signup if you don't have one.
-
Create the
@codeheadsystemsorganization on npm (if it doesn't exist yet):- Go to https://www.npmjs.com/org/create
- Name:
codeheadsystems - Choose the free/public tier
-
Log in to npm from your terminal:
npm login
cd hofmann-typescript
npm run build
npm publish --access publicThe --access public flag is required on the first publish of a scoped package. After the first publish, the "access": "public" setting in publishConfig makes it automatic.
-
Bump the version. npm will not allow you to republish the same version number.
npm version patch # 0.1.0 → 0.1.1 (bug fixes, minor changes) npm version minor # 0.1.1 → 0.2.0 (new features, backward compatible) npm version major # 0.2.0 → 1.0.0 (breaking changes)
This updates
package.jsonand creates a git tag (e.g.,v0.1.1). -
Build and publish:
npm run build npm publish
-
Push the version commit and tag:
git push && git push --tags
A browser-based demo page is included for manual testing against a running server.
npm run demoThis starts a Vite dev server at http://localhost:5173/demo.html and proxies /opaque and /oprf requests to http://localhost:8080, avoiding CORS issues.
On page load the demo automatically calls GET /opaque/config and populates the Server Configuration panel with the cipher suite, context, and Argon2id parameters from the server. Every subsequent operation re-fetches the config so it is always in sync with the server.
To target a different server:
HOFMANN_SERVER=http://other-server:8080 npm run demoThe demo provides:
- Server Configuration — displays cipher suite, context, and Argon2id params loaded from the server (↺ Load Config button refreshes manually)
- OPAQUE Registration — enter credential ID and password, click Register
- OPAQUE Authentication — returns a JWT token (auto-fills the Delete form)
- Delete Registration — removes the credential (requires the JWT from authentication)
- Standalone OPRF — evaluate an arbitrary plaintext; output length shown dynamically
- Activity log — timestamped protocol step log
npm testRuns 34 tests:
test/oprf.test.ts— RFC 9497 P-256/SHA-256 vectors (DSTs,blind,finalize,deriveKeyPair); P-384 and P-521 constant and DST verification;getCipherSuite()lookup; per-suite OPRF round-trip consistency checks for all three suitestest/opaque.test.ts— RFC 9807 OPAQUE-3DH vectors against P-256/SHA-256 CFRG test vectors (registration records, KE1 bytes, server MAC, full AKE); multi-suite round-trip tests for P-256, P-384, and P-521
TEST_SERVER_URL=http://localhost:8080 npm test -- integrationUses OpaqueHttpClient.create() and OprfHttpClient.create() so the cipher suite and Argon2id parameters are read from the server — no hardcoded values. Runs:
- Cipher suite and Argon2id config verification (asserts
configResponsefields) - Full register → authenticate → wrong-password-rejection → delete flow
Each OPAQUE operation has a 30-second timeout to accommodate Argon2id processing time. Tests are skipped automatically when TEST_SERVER_URL is not set.
npm run test:watchnpm run buildProduces two bundles in dist/:
| File | Format | Description |
|---|---|---|
dist/hofmann-typescript.js |
ESM | For modern bundlers and <script type="module"> |
dist/hofmann-typescript.umd.cjs |
UMD | For CommonJS environments and direct <script> tags |
dist/index.d.ts |
TypeScript declarations | Included automatically by bundlers |
@noble/curves and @noble/hashes are external in the build — they are not bundled and must be present in the consuming project's node_modules. hash-wasm is a regular dependency and is bundled.
npm run typecheck # type-check without emitting fileshofmann-typescript/
├── src/
│ ├── index.ts # Public re-exports
│ ├── crypto/
│ │ ├── primitives.ts # i2osp, concat, xor, constantTimeEqual, fromHex, toHex
│ │ ├── encoding.ts # base64Encode/Decode, strToBytes/bytesToStr
│ │ └── hkdf.ts # hkdfExtract, hkdfExpand, hkdfExpandLabel (SHA-256)
│ ├── oprf/
│ │ ├── suite.ts # CipherSuite interface; P256_SHA256, P384_SHA384,
│ │ │ # P521_SHA512 constants; getCipherSuite()
│ │ ├── client.ts # blind(), finalize(), deriveKeyPair(), hashToScalar()
│ │ └── http.ts # OprfHttpClient → POST /oprf, GET /oprf/config
│ └── opaque/
│ ├── types.ts # Envelope, KE1, KE2, AuthResult, RegistrationRecord, …
│ ├── ksf.ts # KSF type, identityKsf, argon2idKsf()
│ ├── envelope.ts # storeEnvelope(), recoverEnvelope() — suite-aware sizes
│ ├── ake.ts # buildPreamble(), derive3DHKeys(), verifyServerMac(),
│ │ # computeClientMac() — suite-aware hash/HMAC/HKDF
│ ├── client.ts # OpaqueClient(suite?) — crypto only, no HTTP
│ └── http.ts # OpaqueHttpClient — full HTTP flow, create() factory
├── test/
│ ├── oprf.test.ts # RFC 9497 vectors + multi-suite constants + round-trips
│ ├── opaque.test.ts # RFC 9807 vectors + multi-suite round-trips
│ └── integration.test.ts # Live server tests (skipped without TEST_SERVER_URL)
├── demo.html # Interactive browser demo UI
├── demo.ts # Demo logic — auto-loads config from server on startup
├── vite.config.ts # Library build config (ESM + UMD)
└── vite.demo.config.ts # Dev server config with proxy for demo.html
For the full REST endpoint listing, request/response format, and server setup, see the server configuration guide.