Completes the parity work (step 4). Request timeout now defaults to 900 s, matching Python, instead of 60 s. Encrypted inference cannot stream, so an entire completion arrives in one response; a long generation on a busy backend legitimately takes minutes and was timing out here while succeeding in the Python client. Error types now distinguish malformed data from integrity failures. Python raises ValueError for a bad package, a non-200 or unparseable /pki/public_key, and plaintext that will not parse, reserving SecurityError for crypto failures. This port wrapped nearly all of it in SecurityError — so a server sending malformed JSON was reported as an authentication failure, pointing debugging in exactly the wrong direction. Malformed data is now a plain Error (the JS equivalent of ValueError), carried past the deliberately opaque catch-all by a symbol marker rather than a new exported class. Genuine crypto failures still report a single vague message so they cannot serve as a decryption oracle. Also adds the missing guard Python has: decrypting without a private key now says so, instead of failing later and being reported as an integrity failure. doc/attestation.md ports the Python attestation guide to the JS API, and documents the two deliberate divergences: no verify_ssl escape hatch, and jose injection instead of a runtime dynamic import. Version 0.1.0 -> 0.3.0 to match the Python client's feature level, now that the two are at parity. Not ported: Python's warning when secure_memory=True but the SecureMemory module is unavailable. There is no JS equivalent — zeroing is always available, and the weaker case (mlock unavailable) is already reported honestly by getProtectionInfo(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.5 KiB
SGX enclave attestation (operator-blind verification)
For the high and maximum security tiers, the NOMYO server runs CPU inference
inside an Intel SGX enclave. SGX attestation lets the client prove — before
sending any plaintext — that the RSA public key it is about to encrypt to was
generated inside a genuine enclave running the expected (measured) code. This
upgrades the channel from "encrypted in transit, operator-trusted" to
"operator-blind": only the enclave can decrypt, not the host operator.
This is opt-in and additive. With no attestation configured the client behaves exactly as before.
How it works
On each secure request for a tier that requires attestation, the client:
- Fetches the server public key (
GET /pki/public_key). - Fetches a hardware-signed DCAP quote (
GET /pki/attestation). - Verifies the quote (ECDSA signature + Intel PCK chain + TCB) via a
QuoteVerifieryou provide (step 4). - Checks policy:
tcbStatus, pinnedMRENCLAVE(and optionallyMRSIGNER/prodId/ minimumisvSvn). - Checks the key binding:
SHA-512(server_pubkey_der [|| challenge])must equal thereportDatainside the verified quote (constant-time compare). - Only if all checks pass does it encrypt the payload to that exact attested
key and send it. In
enforcemode any failure throws and nothing is sent.
The key fetched in step 1 is the key used in step 6 — it is not re-fetched between verification and encryption, so there is no window in which an attested key could be swapped for another.
Quote verification (QuoteVerifier)
Verifying a DCAP quote must not be hand-rolled. The client delegates step 4 to a
QuoteVerifier. Three ways to supply one:
JwtQuoteVerifier (recommended)
Talks to a remote verification service that returns a signed JWT of appraised claims — Intel Trust Authority, Veraison, or a self-hosted wrapper around the Intel DCAP Quote Verification Library (QVL). The native/PCCS dependency lives on that one service, not on every client.
Requires the optional peer dependency:
npm install jose
import {
SecureChatCompletion,
AttestationPolicy,
JwtQuoteVerifier,
} from 'nomyo-js';
const verifier = new JwtQuoteVerifier(
'https://attest.example.com/appraisal/v1/attest', // verifyUrl
'https://attest.example.com/certs', // jwksUrl
{
issuer: 'https://attest.example.com', // optional
audience: 'nomyo-client', // optional
// claimMap: { ... } // override if your service uses different claim names
},
);
const policy = new AttestationPolicy({
pinnedMrenclave: ['<hex from gramine/build-enclave.sh>'],
allowedTcbStatuses: ['UpToDate'],
enforce: false, // rollout: warn and proceed. Set true to hard-fail.
});
const client = new SecureChatCompletion({
baseUrl: 'https://api.nomyo.ai',
apiKey: process.env.NOMYO_API_KEY,
attestationPolicy: policy,
quoteVerifier: verifier,
});
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Hello!' }],
security_tier: 'high',
});
The default JWT claim names are in DEFAULT_CLAIM_MAP; override claimMap if
your service differs (e.g. Veraison vs ITA).
CommonJS and ESM-only jose
jose v5+ is ESM-only. Under CommonJS, require('jose') fails, and this client
deliberately does not work around that with a runtime-constructed dynamic
import: that requires new Function/eval, which any Content-Security-Policy
without unsafe-eval blocks — and the failure would land in the attestation
path in browsers. Import the module yourself and pass it in instead:
import * as jose from 'jose';
const verifier = new JwtQuoteVerifier(verifyUrl, jwksUrl, { jose });
Unlike the Python client there is no verify_ssl escape hatch: TLS certificate
verification against the appraisal service is always enforced.
CallableQuoteVerifier — bring your own
import { CallableQuoteVerifier, VerifiedQuote } from 'nomyo-js';
async function myVerify(quote: Uint8Array): Promise<VerifiedQuote> {
// call your verifier (local QVL binding, another service, ...)
return {
mrenclave: '...',
mrsigner: '...',
isvProdId: 1,
isvSvn: 3,
tcbStatus: 'UpToDate',
reportData: new Uint8Array(64),
};
}
const client = new SecureChatCompletion({
attestationPolicy: policy,
quoteVerifier: new CallableQuoteVerifier(myVerify),
});
myVerify may be sync or async and must throw on any verification failure.
Any QuoteVerifier
Implement the interface directly:
interface QuoteVerifier {
verify(quote: Uint8Array): Promise<VerifiedQuote>;
}
Policy (AttestationPolicy)
| Option | Meaning | Default |
|---|---|---|
pinnedMrenclave |
Allowed enclave measurements (a set so you can roll builds) | [] (not verified) |
pinnedMrsigner |
Allowed signer (optional) | undefined |
pinnedProdId / minIsvSvn |
Product id / minimum security version (optional) | undefined |
allowedTcbStatuses |
Accepted platform TCB levels | ['UpToDate'] |
enforce |
Hard-fail vs warn-and-proceed | false |
requireAttestationForTier |
Tiers that must be operator-blind | ['high', 'maximum'] |
liveness |
Use a fresh per-request challenge (see below) | false |
Hex values are compared case-insensitively; pins are lowercased on construction.
Pin MRENCLAVE values as configuration, not constants — they change whenever
the enclave build or its file paths change. Printed by gramine/build-enclave.sh
at sign time. With no pinnedMrenclave the client logs a warning on every
verification, because enclave identity is then not actually being checked. Do
not silently allow OutOfDate or Revoked TCB statuses.
Rollout vs enforce
standardtier: attestation not expected — proceeds normally, and no attestation request is made at all.high/maximum,enforce: false(rollout, default): if attestation is unavailable (server not yet under SGX) or fails, a loud warning is logged and the request proceeds. Lets you deploy clients before SGX is live everywhere.high/maximum,enforce: true(target): unavailable or failed attestation ⇒ the request is refused withAttestationError, no plaintext fallback. The payload is never even constructed.
AttestationError extends SecurityError, so existing
catch (e) { if (e instanceof SecurityError) ... } blocks treat a failed
attestation as the security failure it is.
Fetching attestation over plain HTTP throws regardless of enforce — a
MITM-able channel defeats the purpose. Use allowHttp: true only for local
development.
Liveness (optional)
The default quote is bound to the key only and may be cached server-side (proves
binding, not recency). Set liveness: true to send a random 32-byte challenge
per handshake; the server returns a fresh, uncached quote bound to
SHA-512(pubkey_der || challenge), proving the enclave is alive now.
Liveness bypasses the client-side verification cache, so every request performs a full round trip and quote verification. Without it, a successful verification is cached per server public key for the session and re-verified if that key changes.
Self-hosting the verifier
A self-hosted equivalent of Intel Trust Authority is a small service that runs
the Intel DCAP QVL (libsgx_dcap_quoteverify) with a reachable PCCS, and exposes
POST /verify {quote} -> {token: <JWT>} plus a JWKS endpoint. Veraison is an
open-source option. Run that one service and point JwtQuoteVerifier at it.