Compare commits

...
Sign in to create a new pull request.

1 commit
main ... sgx

Author SHA1 Message Date
7c06b960b1
feat: prep SGX attestation 2026-06-16 16:26:30 +02:00
7 changed files with 680 additions and 12 deletions

View file

@ -97,6 +97,31 @@ asyncio.run(main())
- **Guaranteed zeroing**: Memory is zeroed after encryption completes
- **Fallback mechanism**: Graceful degradation if SecureMemory module unavailable
### SGX Enclave Attestation (operator-blind, opt-in)
For the `high`/`maximum` tiers the server runs inside an Intel SGX enclave. The
client can **verify a hardware attestation quote before sending any plaintext**,
proving the server public key it encrypts to was generated inside a genuine
enclave running the expected code — making the channel operator-blind. This is
opt-in and additive; without it the client behaves exactly as before.
```python
from nomyo import SecureChatCompletion, AttestationPolicy, JwtQuoteVerifier
client = SecureChatCompletion(
base_url="https://api.nomyo.ai", api_key="...",
attestation_policy=AttestationPolicy(
pinned_mrenclave={"<hex>"}, enforce=False, # rollout: warn; True = hard-fail
),
quote_verifier=JwtQuoteVerifier(verify_url="https://attest.example/verify",
jwks_url="https://attest.example/certs"),
)
```
Install the verifier extra with `pip install nomyo[attestation]`. See
[doc/attestation.md](doc/attestation.md) for policy, rollout/enforce, liveness,
and how to self-host the verification service.
## 🔄 OpenAI Compatibility
The `SecureChatCompletion` class provides **exact API compatibility** with OpenAI's `ChatCompletion.create()` method.

134
doc/attestation.md Normal file
View file

@ -0,0 +1,134 @@
# 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: `tcb_status`, pinned `MRENCLAVE` (and optionally
`MRSIGNER` / `prod_id` / minimum `isv_svn`).
5. Checks the **key binding**: `SHA-512(server_pubkey_der [|| challenge])` must
equal the `report_data` 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 raises and nothing is sent.
## 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 dependency:
```bash
pip install nomyo[attestation]
```
```python
from nomyo import SecureChatCompletion, AttestationPolicy, JwtQuoteVerifier
verifier = JwtQuoteVerifier(
verify_url="https://attest.example.com/appraisal/v1/attest",
jwks_url="https://attest.example.com/certs",
issuer="https://attest.example.com", # optional
audience="nomyo-client", # optional
# claim_map=... # override if your service uses different claim names
)
policy = AttestationPolicy(
pinned_mrenclave={"<hex from gramine/build-enclave.sh>"},
allowed_tcb_statuses={"UpToDate"},
enforce=False, # rollout: warn and proceed. Set True to hard-fail.
)
client = SecureChatCompletion(
base_url="https://api.nomyo.ai",
api_key="...",
attestation_policy=policy,
quote_verifier=verifier,
)
resp = await client.create(
model="...", messages=[...], security_tier="high",
)
```
The default JWT claim names are in `DEFAULT_CLAIM_MAP`; override `claim_map` if
your service differs (e.g. Veraison vs ITA).
### `CallableQuoteVerifier` — bring your own
```python
from nomyo import CallableQuoteVerifier, VerifiedQuote
async def my_verify(quote: bytes) -> VerifiedQuote:
# call your verifier (local QVL FFI, another service, ...)
return VerifiedQuote(mrenclave=..., mrsigner=..., isv_prod_id=...,
isv_svn=..., tcb_status="UpToDate", report_data=...)
client = SecureChatCompletion(..., attestation_policy=policy,
quote_verifier=CallableQuoteVerifier(my_verify))
```
`my_verify` may be sync or async and must raise on any verification failure.
## Policy (`AttestationPolicy`)
| Field | Meaning | Default |
|---|---|---|
| `pinned_mrenclave` | Allowed enclave measurements (a **set** so you can roll builds) | `frozenset()` |
| `pinned_mrsigner` | Allowed signer (optional) | `None` |
| `pinned_prod_id` / `min_isv_svn` | Product id / minimum security version (optional) | `None` |
| `allowed_tcb_statuses` | Accepted platform TCB levels | `{"UpToDate"}` |
| `enforce` | Hard-fail vs warn-and-proceed | `False` |
| `require_attestation_for_tier` | Tiers that must be operator-blind | `{"high","maximum"}` |
| `liveness` | Use a fresh per-request challenge (see below) | `False` |
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. Do **not** silently allow `OutOfDate` or
`Revoked` TCB statuses.
## Rollout vs enforce (spec §6)
- **`standard` tier:** attestation not expected — proceeds normally.
- **`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**.
## Liveness (spec §7, optional)
The default quote is bound to the key only and is 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.
## 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.

View file

@ -76,7 +76,7 @@ class SecureCompletionClient:
- Response parsing
"""
def __init__(self, router_url: str = "https://api.nomyo.ai", allow_http: bool = False, secure_memory: bool = True, max_retries: int = 2):
def __init__(self, router_url: str = "https://api.nomyo.ai", allow_http: bool = False, secure_memory: bool = True, max_retries: int = 2, attestation_policy=None, quote_verifier=None):
"""
Initialize the secure completion client.
@ -87,6 +87,13 @@ class SecureCompletionClient:
max_retries: Number of retries on retryable errors (429, 500, 502, 503, 504,
network errors). Uses exponential backoff. Default 2, matching
the OpenAI Python SDK default.
attestation_policy: Optional AttestationPolicy enabling SGX DCAP attestation
verification before plaintext is sent. When None (default),
attestation is disabled and behavior is unchanged.
quote_verifier: Optional QuoteVerifier used for the step-4 DCAP quote
verification. Required to actually verify a quote; when None
and a policy is set, attestation is treated as unverifiable
(warn-and-proceed unless policy.enforce is True).
"""
self.router_url = router_url.rstrip('/')
self.private_key = None
@ -96,6 +103,18 @@ class SecureCompletionClient:
self._use_secure_memory = _SECURE_MEMORY_AVAILABLE and secure_memory
self.max_retries = max_retries
# Optional SGX attestation: only construct the verifier when a policy is
# provided, so existing callers get byte-for-byte the previous behavior.
self._attestation = None
if attestation_policy is not None:
from .SgxAttestation import SgxAttestationVerifier
self._attestation = SgxAttestationVerifier(
router_url=self.router_url,
policy=attestation_policy,
verifier=quote_verifier,
allow_http=allow_http,
)
# Validate HTTPS for security
if not self.router_url.startswith("https://"):
if allow_http:
@ -337,11 +356,15 @@ class SecureCompletionClient:
except Exception:
raise ValueError("Failed to fetch server's public key")
async def _do_encrypt(self, payload_bytes: Union[bytes, bytearray], aes_key: Union[bytes, bytearray]) -> bytes:
async def _do_encrypt(self, payload_bytes: Union[bytes, bytearray], aes_key: Union[bytes, bytearray], server_public_key_pem: Optional[str] = None) -> bytes:
"""
Core AES-256-GCM + RSA-OAEP encryption. Caller is responsible for
memory protection of payload_bytes and aes_key before calling this.
Accepts bytearray to avoid creating an unzeroed immutable bytes copy.
When ``server_public_key_pem`` is provided it is used directly (the
caller has already fetched and possibly attested that exact key);
otherwise the key is fetched here, preserving the previous behavior.
"""
nonce = secrets.token_bytes(12) # 96-bit nonce for GCM
cipher = Cipher(
@ -353,7 +376,8 @@ class SecureCompletionClient:
ciphertext = encryptor.update(payload_bytes) + encryptor.finalize()
tag = encryptor.tag
server_public_key_pem = await self.fetch_server_public_key()
if server_public_key_pem is None:
server_public_key_pem = await self.fetch_server_public_key()
server_public_key = serialization.load_pem_public_key(
server_public_key_pem.encode('utf-8'),
backend=default_backend()
@ -397,7 +421,7 @@ class SecureCompletionClient:
logger.debug("Encrypted package size: %d bytes", len(package_json))
return package_json
async def encrypt_payload(self, payload: Dict[str, Any]) -> bytes:
async def encrypt_payload(self, payload: Dict[str, Any], server_public_key_pem: Optional[str] = None) -> bytes:
"""
Encrypt a payload using hybrid encryption (AES-256-GCM + RSA-OAEP).
@ -406,6 +430,10 @@ class SecureCompletionClient:
Args:
payload: Dictionary containing the chat completion request
server_public_key_pem: Optional pre-fetched server public key PEM. When
provided the payload is encrypted to exactly this key (used after
SGX attestation so we encrypt to the attested key, with no
re-fetch); otherwise the key is fetched during encryption.
Returns:
Encrypted payload as bytes
@ -441,11 +469,12 @@ class SecureCompletionClient:
with secure_bytearray(aes_key) as protected_aes_key:
return await self._do_encrypt(
protected_payload.data,
protected_aes_key.data
protected_aes_key.data,
server_public_key_pem
)
else:
logger.warning("Secure memory not available, using standard encryption")
return await self._do_encrypt(payload_json, aes_key)
return await self._do_encrypt(payload_json, aes_key, server_public_key_pem)
finally:
del aes_key
@ -641,8 +670,20 @@ class SecureCompletionClient:
f"Must be one of: {', '.join(valid_tiers)}"
)
# Step 1: Encrypt the payload
encrypted_payload = await self.encrypt_payload(payload)
# Step 0: SGX attestation handshake (when enabled). Fetch the server
# public key once, verify the enclave + key binding BEFORE building any
# plaintext, then encrypt to exactly that attested key. In enforce mode a
# failure raises here, so plaintext is never constructed or sent.
server_public_key_pem: Optional[str] = None
if self._attestation is not None:
server_public_key_pem = await self.fetch_server_public_key()
pubkey_der = self._spki_der_from_pem(server_public_key_pem)
await self._attestation.ensure_verified(
server_public_key_pem, pubkey_der, security_tier
)
# Step 1: Encrypt the payload (to the attested key when attestation ran)
encrypted_payload = await self.encrypt_payload(payload, server_public_key_pem)
# Step 2: Prepare headers
headers = {
@ -798,6 +839,24 @@ class SecureCompletionClient:
logger.exception("Unexpected error in send_secure_request")
raise APIConnectionError("Request failed due to an unexpected error")
@staticmethod
def _spki_der_from_pem(pubkey_pem: str) -> bytes:
"""
Return the X.509 SubjectPublicKeyInfo DER bytes for the server public key.
This is exactly the value the SGX report_data binding is computed over
(SHA-512 of these bytes); see doc/CLIENT_SGX_ATTESTATION.md §2.1/§2.3. We
re-serialize via the parsed key (rather than base64-decoding the PEM body
directly) so the bytes are canonical SPKI DER regardless of PEM wrapping.
"""
key = serialization.load_pem_public_key(
pubkey_pem.encode('utf-8'), backend=default_backend()
)
return key.public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
def _validate_rsa_key(self, key, key_type: str = "private") -> None:
"""
Validate that a key is a valid RSA key with appropriate size.

417
nomyo/SgxAttestation.py Normal file
View file

@ -0,0 +1,417 @@
"""
SGX DCAP attestation verification for the NOMYO secure-completion client.
This implements the client side of ``doc/CLIENT_SGX_ATTESTATION.md`` (mirrored in
the router repo): before sending any plaintext to a ``high``/``maximum`` tier
endpoint, prove that the RSA public key the client is about to encrypt to was
generated *inside* a genuine Intel SGX enclave running our measured code.
The single most important rule (spec §1): verify the quote and the key binding
**before** transmitting plaintext. A failed verification must block the request
in ``enforce`` mode, with no plaintext fallback.
Architecture
------------
Verifying a DCAP quote (ECDSA signature + PCK certificate chain + TCB/revocation)
is intentionally *not* done here it must not be hand-rolled. Instead this module
defines a small :class:`QuoteVerifier` interface and performs everything around
it (fetch, policy checks, the constant-time key binding, session caching). The
``step 4`` quote verification is delegated to an injected verifier:
* :class:`JwtQuoteVerifier` opt-in, talks to a remote verification service
(Intel Trust Authority, Veraison, or a self-hosted QVL wrapper) that returns a
signed JWT of appraised claims. Requires the ``attestation`` extra (``pyjwt``).
* :class:`CallableQuoteVerifier` wrap any user-supplied callable.
* Or any object implementing :class:`QuoteVerifier`.
With no verifier configured the orchestrator treats the quote as unverifiable and
applies the §6 rollout policy (warn-and-proceed unless ``enforce``).
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import secrets
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, FrozenSet, Optional, Union
import httpx
from .SecureCompletionClient import SecurityError
logger = logging.getLogger(__name__)
# SGX report_data is a fixed 64-byte field; the binding is the full SHA-512 digest
# of the server public key DER (optionally concatenated with a client challenge),
# with no truncation or padding (spec §2.3).
REPORT_DATA_LEN = 64
class AttestationError(SecurityError):
"""Raised when SGX attestation verification fails in ``enforce`` mode.
Subclasses :class:`SecurityError` so existing ``except SecurityError`` blocks
treat a failed attestation as the security failure it is.
"""
pass
@dataclass(frozen=True)
class VerifiedQuote:
"""Trusted result of verifying a DCAP quote.
These values come from *inside* the hardware-signed quote (as returned by a
:class:`QuoteVerifier`) never from the untrusted ``/pki/attestation`` JSON.
"""
mrenclave: str # hex (lowercase)
mrsigner: str # hex (lowercase)
isv_prod_id: int
isv_svn: int
tcb_status: str
report_data: bytes # 64 bytes, the SGX report_data field
@dataclass
class AttestationPolicy:
"""Client-side attestation policy (spec §5).
Ship the pinned values as configuration, not hard-coded constants: MRENCLAVE
changes whenever the enclave build or its file paths change, so pin a *set* to
allow rolling new builds.
"""
pinned_mrenclave: FrozenSet[str] = field(default_factory=frozenset)
pinned_mrsigner: Optional[str] = None
pinned_prod_id: Optional[int] = None
min_isv_svn: Optional[int] = None
allowed_tcb_statuses: FrozenSet[str] = field(
default_factory=lambda: frozenset({"UpToDate"})
)
enforce: bool = False
require_attestation_for_tier: FrozenSet[str] = field(
default_factory=lambda: frozenset({"high", "maximum"})
)
liveness: bool = False
def __post_init__(self) -> None:
# Accept any iterable for the set-typed fields and normalise pins to
# lowercase hex so comparisons against verifier output are case-stable.
self.pinned_mrenclave = frozenset(
m.lower() for m in self.pinned_mrenclave
)
if self.pinned_mrsigner is not None:
self.pinned_mrsigner = self.pinned_mrsigner.lower()
self.allowed_tcb_statuses = frozenset(self.allowed_tcb_statuses)
self.require_attestation_for_tier = frozenset(
self.require_attestation_for_tier
)
class QuoteVerifier:
"""Interface for step-4 DCAP quote verification.
Implementations must verify the ECDSA signature, the PCK certificate chain to
the Intel root, and revocation/TCB, then return the trusted parsed values.
Raise on any verification failure.
"""
async def verify(self, quote: bytes) -> VerifiedQuote: # pragma: no cover - interface
raise NotImplementedError
# A user callable may be sync or async and returns a VerifiedQuote.
_VerifierCallable = Callable[[bytes], Union[VerifiedQuote, Awaitable[VerifiedQuote]]]
class CallableQuoteVerifier(QuoteVerifier):
"""Adapt a (sync or async) callable into a :class:`QuoteVerifier`."""
def __init__(self, func: _VerifierCallable) -> None:
self._func = func
async def verify(self, quote: bytes) -> VerifiedQuote:
import inspect
result = self._func(quote)
if inspect.isawaitable(result):
result = await result
if not isinstance(result, VerifiedQuote):
raise AttestationError(
"quote verifier callable must return a VerifiedQuote"
)
return result
# Default mapping from JWT claim names to VerifiedQuote fields. Concrete services
# differ (Intel Trust Authority vs Veraison vs a custom QVL wrapper), so this is
# overridable per JwtQuoteVerifier instance.
DEFAULT_CLAIM_MAP: Dict[str, str] = {
"mrenclave": "sgx_mrenclave",
"mrsigner": "sgx_mrsigner",
"isv_prod_id": "sgx_isvprodid",
"isv_svn": "sgx_isvsvn",
"tcb_status": "tcb_status",
"report_data": "sgx_report_data",
}
class JwtQuoteVerifier(QuoteVerifier):
"""Verify a quote via a remote service that returns a signed JWT (spec §4A).
POSTs the base64 quote to ``verify_url``; the service replies with a JWT whose
signature is validated against ``jwks_url`` (and optional issuer/audience).
The validated claims are mapped to a :class:`VerifiedQuote`.
Requires the optional ``pyjwt`` dependency::
pip install nomyo[attestation]
"""
def __init__(
self,
verify_url: str,
jwks_url: str,
*,
issuer: Optional[str] = None,
audience: Optional[str] = None,
claim_map: Optional[Dict[str, str]] = None,
algorithms: Optional[list] = None,
timeout: float = 30.0,
verify_ssl: bool = True,
) -> None:
self.verify_url = verify_url
self.jwks_url = jwks_url
self.issuer = issuer
self.audience = audience
self.claim_map = dict(claim_map) if claim_map else dict(DEFAULT_CLAIM_MAP)
self.algorithms = algorithms or ["RS256", "ES256", "ES384"]
self.timeout = timeout
self.verify_ssl = verify_ssl
@staticmethod
def _require_jwt():
try:
import jwt # PyJWT
return jwt
except ImportError as e: # pragma: no cover - exercised via monkeypatch in tests
raise AttestationError(
"JwtQuoteVerifier requires the 'pyjwt' package. "
"Install the attestation extra: pip install nomyo[attestation]"
) from e
async def verify(self, quote: bytes) -> VerifiedQuote:
jwt = self._require_jwt()
async with httpx.AsyncClient(timeout=self.timeout, verify=self.verify_ssl) as client:
resp = await client.post(
self.verify_url,
json={"quote": base64.b64encode(quote).decode("ascii")},
)
if resp.status_code != 200:
raise AttestationError(
f"quote verification service returned HTTP {resp.status_code}"
)
try:
token = resp.json()["token"]
except (ValueError, KeyError, TypeError) as e:
raise AttestationError("verifier response missing JWT 'token'") from e
# Validate the JWT signature against the service's published JWKS.
try:
jwk_client = jwt.PyJWKClient(self.jwks_url)
signing_key = jwk_client.get_signing_key_from_jwt(token)
options = {"verify_aud": self.audience is not None}
claims = jwt.decode(
token,
signing_key.key,
algorithms=self.algorithms,
issuer=self.issuer,
audience=self.audience,
options=options,
)
except Exception as e:
raise AttestationError(f"JWT validation failed: {e}") from e
return self._claims_to_verified_quote(claims)
def _claims_to_verified_quote(self, claims: Dict[str, Any]) -> VerifiedQuote:
cm = self.claim_map
try:
report_data_hex = claims[cm["report_data"]]
report_data = bytes.fromhex(report_data_hex)
return VerifiedQuote(
mrenclave=str(claims[cm["mrenclave"]]).lower(),
mrsigner=str(claims[cm["mrsigner"]]).lower(),
isv_prod_id=int(claims[cm["isv_prod_id"]]),
isv_svn=int(claims[cm["isv_svn"]]),
tcb_status=str(claims[cm["tcb_status"]]),
report_data=report_data,
)
except (KeyError, ValueError, TypeError) as e:
raise AttestationError(
f"verifier JWT missing/invalid expected claim: {e}"
) from e
class SgxAttestationVerifier:
"""Orchestrates the §3 verification algorithm around a :class:`QuoteVerifier`.
One instance per :class:`SecureCompletionClient`. Caches the
``(pubkey -> verified)`` result per server for the session to avoid
re-verifying on every call; re-verifies if the server pubkey changes.
Liveness mode bypasses the cache (freshness).
"""
def __init__(
self,
router_url: str,
policy: AttestationPolicy,
verifier: Optional[QuoteVerifier] = None,
allow_http: bool = False,
) -> None:
self.router_url = router_url.rstrip("/")
self.policy = policy
self.verifier = verifier
self.allow_http = allow_http
# Set of sha256(pubkey_der) hex that verified OK this session.
self._verified_keys: set = set()
async def ensure_verified(
self,
pubkey_pem: str,
pubkey_der: bytes,
security_tier: Optional[str],
) -> Optional[VerifiedQuote]:
"""Run the §3 algorithm for the given server public key + tier.
Returns the :class:`VerifiedQuote` on success, ``None`` when attestation
is not required for the tier or when it failed but ``enforce`` is off.
Raises :class:`AttestationError` when verification fails and
``policy.enforce`` is True in which case the caller MUST NOT send
plaintext.
"""
tier = security_tier or "standard"
if tier not in self.policy.require_attestation_for_tier:
# e.g. standard tier: attestation not expected — proceed (spec §6).
return None
key_id = hashlib.sha256(pubkey_der).hexdigest()
if not self.policy.liveness and key_id in self._verified_keys:
logger.debug("SGX attestation: using cached verification for key %s", key_id[:16])
return None
if self.verifier is None:
return self._fail(
"no quote verifier configured (cannot perform step-4 DCAP "
"verification)"
)
challenge = secrets.token_bytes(32) if self.policy.liveness else b""
att = await self._fetch_attestation(challenge)
if att is None:
return self._fail("could not fetch attestation from server")
if not att.get("is_available"):
reason = att.get("reason", "unknown")
return self._fail(f"attestation unavailable: {reason}")
quote_b64 = att.get("quote_b64")
if not quote_b64:
return self._fail("attestation response missing quote_b64")
try:
quote = base64.b64decode(quote_b64)
except Exception:
return self._fail("attestation quote_b64 is not valid base64")
# Step 4: delegate the cryptographic quote verification.
try:
v = await self.verifier.verify(quote)
except AttestationError as e:
return self._fail(f"quote verification failed: {e}")
except Exception as e:
return self._fail(f"quote verification error: {type(e).__name__}: {e}")
# Step 5: policy checks.
policy_error = self._check_policy(v)
if policy_error is not None:
return self._fail(policy_error)
# Step 6: key-binding check (constant-time).
expected = hashlib.sha512(pubkey_der + challenge).digest()
if len(v.report_data) != REPORT_DATA_LEN or not hmac.compare_digest(
expected, v.report_data
):
return self._fail(
"key binding mismatch: report_data does not match "
"SHA-512(server_pubkey_der[ || challenge]) — the attested key is "
"not the key being encrypted to"
)
# Step 7: success — cache (non-liveness only) and proceed.
if not self.policy.liveness:
self._verified_keys.add(key_id)
logger.info(
"SGX attestation verified: mrenclave=%s tcb=%s tier=%s",
v.mrenclave[:16], v.tcb_status, tier,
)
return v
def _check_policy(self, v: VerifiedQuote) -> Optional[str]:
"""Return an error string if any policy check fails, else None."""
if v.tcb_status not in self.policy.allowed_tcb_statuses:
return (
f"TCB status '{v.tcb_status}' not in allowed "
f"{sorted(self.policy.allowed_tcb_statuses)}"
)
if self.policy.pinned_mrenclave:
if v.mrenclave.lower() not in self.policy.pinned_mrenclave:
return f"MRENCLAVE {v.mrenclave} not in pinned set"
else:
logger.warning(
"SGX attestation: no pinned_mrenclave configured — enclave "
"identity is NOT verified (configure AttestationPolicy.pinned_mrenclave)"
)
if self.policy.pinned_mrsigner is not None:
if v.mrsigner.lower() != self.policy.pinned_mrsigner:
return f"MRSIGNER {v.mrsigner} != pinned {self.policy.pinned_mrsigner}"
if self.policy.pinned_prod_id is not None:
if v.isv_prod_id != self.policy.pinned_prod_id:
return f"isv_prod_id {v.isv_prod_id} != pinned {self.policy.pinned_prod_id}"
if self.policy.min_isv_svn is not None:
if v.isv_svn < self.policy.min_isv_svn:
return f"isv_svn {v.isv_svn} < minimum {self.policy.min_isv_svn}"
return None
async def _fetch_attestation(self, challenge: bytes) -> Optional[Dict[str, Any]]:
"""GET /pki/attestation; return parsed JSON or None on transport error."""
if not self.router_url.startswith("https://") and not self.allow_http:
raise AttestationError(
"Attestation must be fetched over HTTPS to prevent MITM attacks "
"(initialize with allow_http=True only for local development)."
)
url = f"{self.router_url}/pki/attestation"
params = {"challenge": challenge.hex()} if challenge else None
verify_ssl = self.router_url.startswith("https://")
try:
async with httpx.AsyncClient(timeout=900.0, verify=verify_ssl) as client:
resp = await client.get(url, params=params)
if resp.status_code != 200:
logger.warning(
"SGX attestation: /pki/attestation returned HTTP %d", resp.status_code
)
return None
return resp.json()
except (httpx.HTTPError, ValueError) as e:
logger.warning("SGX attestation: failed to fetch attestation: %s", e)
return None
def _fail(self, reason: str) -> None:
"""Apply the §6 rollout policy: hard-fail in enforce, else warn + proceed."""
if self.policy.enforce:
raise AttestationError(f"SGX attestation failed: {reason}")
logger.warning(
"SGX attestation: %s — proceeding because enforce=False (rollout mode)",
reason,
)
return None

View file

@ -16,6 +16,15 @@ from .SecureCompletionClient import (
ServerError,
ServiceUnavailableError
)
from .SgxAttestation import (
AttestationError,
AttestationPolicy,
VerifiedQuote,
QuoteVerifier,
CallableQuoteVerifier,
JwtQuoteVerifier,
SgxAttestationVerifier,
)
__all__ = [
'SecureChatCompletion',
@ -28,6 +37,13 @@ __all__ = [
'RateLimitError',
'ServerError',
'ServiceUnavailableError',
'AttestationError',
'AttestationPolicy',
'VerifiedQuote',
'QuoteVerifier',
'CallableQuoteVerifier',
'JwtQuoteVerifier',
'SgxAttestationVerifier',
]
# Import secure memory module if available; extend __all__ only for what was imported
@ -51,6 +67,6 @@ try:
except ImportError:
pass
__version__ = "0.2.9"
__version__ = "0.3.0"
__author__ = "NOMYO AI"
__license__ = "Apache-2.0"

View file

@ -52,7 +52,7 @@ class SecureChatCompletion:
```
"""
def __init__(self, base_url: str = "https://api.nomyo.ai", allow_http: bool = False, api_key: Optional[str] = None, secure_memory: bool = True, key_dir: Optional[str] = None, max_retries: int = 2):
def __init__(self, base_url: str = "https://api.nomyo.ai", allow_http: bool = False, api_key: Optional[str] = None, secure_memory: bool = True, key_dir: Optional[str] = None, max_retries: int = 2, attestation_policy=None, quote_verifier=None):
"""
Initialize the secure chat completion client.
@ -70,13 +70,22 @@ class SecureChatCompletion:
generated in memory for this session only.
max_retries: Number of retries on retryable errors (429, 500, 502, 503, 504,
network errors). Uses exponential backoff. Default 2.
attestation_policy: Optional AttestationPolicy enabling SGX DCAP attestation
verification before any plaintext is sent (see doc/attestation.md).
When None (default) attestation is disabled.
quote_verifier: Optional QuoteVerifier performing the step-4 DCAP quote
verification (e.g. JwtQuoteVerifier). Required to actually
verify; without it a set policy treats attestation as
unverifiable (warn-and-proceed unless policy.enforce).
"""
self.client = SecureCompletionClient(router_url=base_url, allow_http=allow_http, secure_memory=secure_memory, max_retries=max_retries)
self.client = SecureCompletionClient(router_url=base_url, allow_http=allow_http, secure_memory=secure_memory, max_retries=max_retries, attestation_policy=attestation_policy, quote_verifier=quote_verifier)
self._keys_initialized = False
self._keys_lock = asyncio.Lock()
self.api_key = api_key
self._key_dir = key_dir
self._secure_memory_enabled = secure_memory
self._attestation_policy = attestation_policy
self._quote_verifier = quote_verifier
if secure_memory and not _SECURE_MEMORY_AVAILABLE:
import warnings
@ -211,6 +220,8 @@ class SecureChatCompletion:
api_key=self.api_key,
secure_memory=self._secure_memory_enabled,
key_dir=self._key_dir,
attestation_policy=self._attestation_policy,
quote_verifier=self._quote_verifier,
)
instance = temp_client
else:

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "nomyo"
version = "0.2.9"
version = "0.3.0"
description = "OpenAI-compatible secure chat client with end-to-end encryption for NOMYO Inference Endpoints"
authors = [
{name = "NOMYO.AI", email = "ichi@nomyo.ai"},
@ -40,6 +40,12 @@ dependencies = [
"typing_extensions==4.15.0",
]
[project.optional-dependencies]
# SGX attestation via a remote JWT-returning verification service (Intel Trust
# Authority / Veraison / self-hosted QVL wrapper). cryptography (core dep) covers
# the JWKS RSA/EC signature validation; pyjwt provides JWT + JWKS handling.
attestation = ["pyjwt>=2.8,<3"]
[project.urls]
Homepage = "https://www.nomyo.ai"
Documentation = "https://bitfreedom.net/code/nomyo-ai/nomyo/wiki/NOMYO-Secure-Client-Documentation"