All checks were successful
NYX Security Scan / nyx-scan (pull_request) Successful in 5m43s
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>
197 lines
7.5 KiB
Markdown
197 lines
7.5 KiB
Markdown
# 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:
|
|
|
|
### `JwtQuoteVerifier` (recommended)
|
|
|
|
Talks to a remote verification service that returns a signed JWT of appraised
|
|
claims — Intel Trust Authority, [Veraison](https://github.com/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:
|
|
|
|
```bash
|
|
npm install jose
|
|
```
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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.
|