nomyo-js/doc/attestation.md
alpha nerd eafab3d9ac
All checks were successful
NYX Security Scan / nyx-scan (pull_request) Successful in 5m43s
feat: align timeout, error types and docs with the Python SDK
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>
2026-07-19 12:17:52 +02:00

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:

  1. Fetches the server public key (GET /pki/public_key).
  2. Fetches a hardware-signed DCAP quote (GET /pki/attestation).
  3. Verifies the quote (ECDSA signature + Intel PCK chain + TCB) via a QuoteVerifier you provide (step 4).
  4. Checks policy: tcbStatus, pinned MRENCLAVE (and optionally MRSIGNER / prodId / minimum isvSvn).
  5. Checks the key binding: SHA-512(server_pubkey_der [|| challenge]) must equal the reportData inside the verified quote (constant-time compare).
  6. Only if all checks pass does it encrypt the payload to that exact attested key and send it. In enforce mode 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:

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

  • standard tier: 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 with AttestationError, 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.