nomyo-js/doc/attestation.md

198 lines
7.5 KiB
Markdown
Raw Normal View History

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
# 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.