198 lines
7.5 KiB
Markdown
198 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.
|