Merge pull request 'feat/ephemeral-keys' (#37) from feat/ephemeral-keys into main

Reviewed-on: https://bitfreedom.net/code/code/nomyo-ai/nomyo-js/pulls/37
This commit is contained in:
Alpha Nerd 2026-07-19 12:24:41 +02:00
commit 3e5ff0bf3c
33 changed files with 2619 additions and 94 deletions

12
.nyx/triage.json Normal file
View file

@ -0,0 +1,12 @@
{
"version": 1,
"decisions": [],
"suppression_rules": [
{
"by": "rule_in_file",
"value": "cfg-resource-leak:native/src/mlock.cc",
"state": "false_positive",
"note": "false_positive: flagged sites are `return Napi::Boolean::New(env, ...)` in MlockBuffer/MunlockBuffer. Napi::Boolean is a stack value wrapping a napi_value handle owned by the callback's N-API HandleScope, which the runtime destroys when the native method returns; there is no acquire/release pair, no delete and no Release() to call. The rule applies a generic 'X::New() acquires heap memory' CFG pattern to an N-API value factory. The flagged lines are themselves return statements, so 'not all exit paths release it' does not apply. The real OS resources here (mlock/VirtualLock) are released by munlockBuffer, which the JS layer pairs with every successful lock in SecureByteContext.use()."
}
]
}

View file

@ -77,7 +77,7 @@ Full documentation is in the [`doc/`](doc/) directory:
### Key Management
- **Automatic**: Keys are generated on first use and saved to `keyDir` (default: `client_keys/`). Existing keys are reloaded on subsequent runs. Node.js only.
- **Ephemeral by default**: A fresh key pair is generated in memory on first use and never written to disk. Set `keyDir` to persist and reuse keys across runs (Node.js only); existing keys in that directory are reloaded automatically.
- **Password protection**: Optional AES-encrypted private key files (minimum 8 characters).
- **Secure permissions**: Private key files saved at `0600` (owner-only).
- **Auto-rotation**: Keys rotate every 24 hours by default (configurable via `keyRotationInterval`).
@ -228,11 +228,11 @@ new SecureChatCompletion(config?: ChatCompletionConfig)
| `allowHttp` | `boolean` | `false` | Allow HTTP connections. Local development only. |
| `apiKey` | `string` | `undefined` | Bearer token for `Authorization` header. |
| `secureMemory` | `boolean` | `true` | Zero sensitive buffers immediately after use. |
| `timeout` | `number` | `60000` | Request timeout in milliseconds. |
| `timeout` | `number` | `900000` | Request timeout in milliseconds (15 min, matching the Python SDK). |
| `debug` | `boolean` | `false` | Print verbose logging to the console. |
| `keyDir` | `string` | `'client_keys'` | Directory to load/save RSA keys on startup. |
| `keyDir` | `string \| null` | `null` | Directory to load/save RSA keys on startup. Omit for ephemeral in-memory keys that are never written to disk. Node.js only. |
| `keyRotationInterval` | `number` | `86400000` | Auto-rotate keys every N ms. `0` disables rotation. |
| `keyRotationDir` | `string` | `'client_keys'` | Directory for rotated key files. Node.js only. |
| `keyRotationDir` | `string` | `keyDir` | Directory for rotated key files. Rotated keys are only persisted when `keyDir` or `keyRotationDir` is set. Node.js only. |
| `keyRotationPassword` | `string` | `undefined` | Password for encrypted rotated key files. |
| `maxRetries` | `number` | `2` | Extra retry attempts on 429/5xx/network errors. Exponential backoff (1 s, 2 s, …). |

View file

@ -27,9 +27,10 @@ console.log(response.choices[0].message.content);
3. [API Reference](api-reference.md) — complete constructor options, methods, and types
4. [Models](models.md) — available models and selection guidance
5. [Security Guide](security-guide.md) — encryption architecture, best practices, and compliance
6. [Rate Limits](rate-limits.md) — request limits, burst behaviour, and retry strategy
7. [Examples](examples.md) — real-world scenarios, browser usage, and advanced patterns
8. [Troubleshooting](troubleshooting.md) — common errors and their fixes
6. [Attestation](attestation.md) — SGX enclave verification for `high`/`maximum` tiers
7. [Rate Limits](rate-limits.md) — request limits, burst behaviour, and retry strategy
8. [Examples](examples.md) — real-world scenarios, browser usage, and advanced patterns
9. [Troubleshooting](troubleshooting.md) — common errors and their fixes
---
@ -38,7 +39,8 @@ console.log(response.choices[0].message.content);
- **End-to-end encryption** — AES-256-GCM + RSA-OAEP-4096. No plaintext ever leaves your process.
- **OpenAI-compatible API**`create()` / `acreate()` accept the same parameters as the OpenAI SDK.
- **Browser + Node.js** — single package, separate entry points for each runtime.
- **Automatic key management** — keys are generated on first use and optionally persisted to disk (Node.js).
- **Ephemeral key management** — a fresh key pair is generated in memory on first use and never written to disk unless you set `keyDir` (Node.js).
- **SGX attestation** — optionally prove the server key was generated inside a genuine enclave *before* sending plaintext ([details](attestation.md)).
- **Automatic key rotation** — RSA keys rotate on a configurable interval (default 24 h) to limit fingerprint lifetime.
- **Security tiers** — per-request routing to `standard`, `high`, or `maximum` isolation hardware.
- **Retry with exponential backoff** — automatic retries on 429 / 5xx / network errors (configurable).

View file

@ -19,13 +19,15 @@ new SecureChatCompletion(config?: ChatCompletionConfig)
| `allowHttp` | `boolean` | `false` | Allow HTTP connections.**Local development only.** |
| `apiKey` | `string` | `undefined` | Bearer token sent in`Authorization` header. |
| `secureMemory` | `boolean` | `true` | Enable immediate zeroing of sensitive buffers after use. |
| `timeout` | `number` | `60000` | Request timeout in milliseconds. |
| `timeout` | `number` | `900000` (15 min) | Request timeout in milliseconds. Encrypted inference cannot stream, so the whole completion arrives in one response; long generations legitimately take minutes. |
| `debug` | `boolean` | `false` | Print verbose logging to the console. |
| `keyDir` | `string` | `'client_keys'` | Directory to load/save RSA keys on startup. If the directory contains an existing key pair it is loaded; otherwise a new pair is generated and saved there. Node.js only. |
| `keyDir` | `string \| null` | `null` | Directory to load/save RSA keys on startup. If the directory contains an existing key pair it is loaded; otherwise a new pair is generated and saved there. Omit (or pass `null`) for ephemeral keys generated in memory for this session only and never written to disk — this is the default, matching the Python SDK. Node.js only; browsers are always ephemeral. |
| `keyRotationInterval` | `number` | `86400000` (24 h) | Auto-rotate RSA keys every N milliseconds. Set to`0` to disable. |
| `keyRotationDir` | `string` | `'client_keys'` | Directory where rotated key files are saved. Node.js only. |
| `keyRotationDir` | `string` | value of `keyDir` | Directory where rotated key files are saved. When neither this nor `keyDir` is set, rotated keys stay in memory. Node.js only. |
| `keyRotationPassword` | `string` | `undefined` | Password used to encrypt rotated key files. |
| `maxRetries` | `number` | `2` | Maximum extra attempts on retryable errors (429, 500, 502, 503, 504, network errors). Uses exponential backoff (1 s, 2 s, …). Set to`0` to disable retries. |
| `attestationPolicy` | `AttestationPolicy` | `undefined` | Enables SGX attestation verification before any plaintext is sent. Omit to disable. See [Attestation](attestation.md). |
| `quoteVerifier` | `QuoteVerifier` | `undefined` | Performs the DCAP quote verification (e.g.`JwtQuoteVerifier`). Required for a policy to actually verify; without one, a set policy treats attestation as unverifiable. |
### Methods
@ -251,6 +253,7 @@ import {
ServiceUnavailableError,
APIConnectionError,
SecurityError,
AttestationError,
DisposedError,
} from 'nomyo-js';
```
@ -267,6 +270,36 @@ import {
| `APIError` | varies | Other HTTP errors (404, 502, 504, etc.) |
| `APIConnectionError` | — | Network failure or timeout (after all retries exhausted) |
| `SecurityError` | — | HTTPS not used, header injection detected, or crypto failure |
| `AttestationError` | — | SGX attestation failed under`enforce` (extends `SecurityError`) |
| `DisposedError` | — | Method called after`dispose()` |
All errors that extend `APIError` expose `statusCode?: number` and `errorDetails?: object`.
### Malformed data vs security failures
A plain `Error` — not a `SecurityError` — is thrown when a response is
structurally wrong rather than cryptographically suspect: a missing package
field, an unsupported protocol version, a non-200 or unparseable
`/pki/public_key` response, or plaintext that decrypted and authenticated
successfully but is not valid JSON.
`SecurityError` is reserved for actual security conditions, and stays
deliberately vague about which stage failed so it cannot be used as a decryption
oracle:
```typescript
try {
await client.create({ model, messages });
} catch (error) {
if (error instanceof SecurityError) {
// integrity/authentication failure, HTTPS violation, or failed attestation
} else if (error instanceof APIError) {
// server responded with an HTTP error
} else {
// malformed response data
}
}
```
This mirrors the Python SDK, which raises `ValueError` where this client throws
a plain `Error`.

197
doc/attestation.md Normal file
View file

@ -0,0 +1,197 @@
# 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.

View file

@ -90,14 +90,27 @@ JavaScript's `delete` and variable reassignment do not zero the underlying memor
### Default Behaviour
Keys are automatically generated on first use and saved to `client_keys/` (Node.js). On subsequent runs the saved keys are reloaded automatically.
Keys are **ephemeral by default**: a fresh pair is generated in memory on first use, lives only for the lifetime of the client, and is never written to disk. Nothing to protect at rest, nothing to rotate out of a directory, no key material surviving the process. This matches the Python SDK's `key_dir=None` default.
To reuse a key pair across runs, set `keyDir` explicitly (Node.js only):
```typescript
const client = new SecureChatCompletion({
baseUrl: 'https://api.nomyo.ai',
keyDir: '/var/lib/myapp/nomyo-keys',
});
```
The directory is created on first use and reloaded on subsequent runs:
```
client_keys/
<keyDir>/
private_key.pem # permissions 0600 (owner-only)
public_key.pem # permissions 0644
```
Persisting keys is a deliberate trade-off: it buys session continuity at the cost of a long-lived private key on disk. Prefer the ephemeral default unless you specifically need key reuse.
### Configure the Key Directory
```javascript
@ -154,7 +167,7 @@ const client2 = new SecureChatCompletion({
### File Permissions
Private key files are saved with `0600` permissions (owner read/write only) on Unix-like systems. Add `client_keys/` and `*.pem` to your `.gitignore` — both are already included if you use this package's default `.gitignore`.
This applies only when you opt into persistence via `keyDir`; ephemeral keys never reach the filesystem. Private key files are saved with `0600` permissions (owner read/write only) on Unix-like systems. Add your `keyDir` and `*.pem` to your `.gitignore``client_keys/` and `*.pem` are already included if you use this package's default `.gitignore`.
---
@ -210,9 +223,10 @@ const client = new SecureChatCompletion({
- [ ] Always use HTTPS (`allowHttp` is `false` by default)
- [ ] Load API key from environment variable, not hardcoded
- [ ] Enable `secureMemory: true` (default)
- [ ] Use password-protected key files (`keyRotationPassword`)
- [ ] Store keys outside the project directory and outside version control
- [ ] Add `client_keys/` and `*.pem` to `.gitignore`
- [ ] Prefer the ephemeral key default; only set `keyDir` if you genuinely need key reuse across runs
- [ ] If persisting keys: use password-protected key files (`keyRotationPassword`)
- [ ] If persisting keys: store them outside the project directory and outside version control
- [ ] If persisting keys: add your `keyDir` and `*.pem` to `.gitignore`
- [ ] Call `client.dispose()` when the client is no longer needed
- [ ] Consider the native addon if swap-file exposure is unacceptable

View file

@ -48,7 +48,7 @@ Never set `allowHttp: true` in production — the server public key fetch and al
### `APIConnectionError: Request timed out`
The default timeout is 60 seconds. Larger models or busy endpoints may need more:
The default timeout is 900 seconds (15 minutes), matching the Python SDK. Encrypted inference cannot stream, so the entire completion arrives in a single response. Lower it if you want to fail faster:
```javascript
const client = new SecureChatCompletion({
@ -63,7 +63,7 @@ const client = new SecureChatCompletion({
### `Error: Failed to load keys: no such file or directory`
The `keyDir` directory or the PEM files inside it don't exist. On first run the library generates and saves a new key pair automatically. If you specified a custom `keyDir`, make sure the directory is writable:
The `keyDir` directory or the PEM files inside it don't exist. This only applies when you opt into persistence — by default keys are ephemeral and no directory is touched at all. On first run with a `keyDir` set, the library generates and saves a new key pair there automatically, so make sure the directory is writable:
```javascript
const client = new SecureChatCompletion({
@ -205,6 +205,23 @@ const info = getMemoryProtectionInfo();
For environments where swap-file exposure is unacceptable (HIPAA PHI, classified data), install the optional `nomyo-native` addon or run on a system with swap disabled.
### `method: 'zero-only'` even with the native addon installed
`canLock` is probed, not assumed — the addon being loaded does not mean the OS
will grant locks. `mlock` is commonly refused by `RLIMIT_MEMLOCK`, which
defaults to a few megabytes and is `0` in some container runtimes. When that
happens `details` says so explicitly, and buffers are still zeroed.
```bash
ulimit -l # current memlock limit in KB; "unlimited" or a large value is what you want
```
Raise the limit (`ulimit -l`, systemd `LimitMEMLOCK=`, or Docker
`--ulimit memlock=-1`), or grant `CAP_IPC_LOCK`.
Note also that npm 11 gates package install scripts, so a fresh `npm ci` may skip
building the addon entirely until you run `npm approve-scripts`.
---
## Node.js-Specific Issues

View file

@ -15,6 +15,7 @@ module.exports = {
skipLibCheck: true,
moduleResolution: 'node',
resolveJsonModule: true,
types: ['jest', 'node'],
allowSyntheticDefaultImports: true,
// Relax unused-var rules in tests
noUnusedLocals: false,

11
package-lock.json generated
View file

@ -1,13 +1,12 @@
{
"name": "nomyo-js",
"version": "0.1.0",
"version": "0.3.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "nomyo-js",
"version": "0.1.0",
"hasInstallScript": true,
"version": "0.3.0",
"license": "Apache-2.0",
"devDependencies": {
"@rollup/plugin-commonjs": "^29.0.0",
@ -22,6 +21,7 @@
"node-gyp-build": "^4.8.0",
"rollup": "^4.0.0",
"ts-jest": "^29.4.6",
"tslib": "^2.8.1",
"typescript": "^6.0.0"
},
"engines": {
@ -5975,7 +5975,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"optional": true
"license": "0BSD"
},
"node_modules/type-detect": {
"version": "4.0.8",
@ -10642,8 +10642,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"optional": true
"dev": true
},
"type-detect": {
"version": "4.0.8",

View file

@ -1,6 +1,6 @@
{
"name": "nomyo-js",
"version": "0.1.0",
"version": "0.3.0",
"description": "OpenAI-compatible secure chat client with end-to-end encryption",
"main": "dist/node/index.js",
"browser": "dist/browser/index.js",
@ -32,7 +32,6 @@
"build:types": "tsc --emitDeclarationOnly",
"test": "jest",
"test:browser": "karma start",
"install": "node-gyp-build",
"prepublishOnly": "npm run build && npm test"
},
"keywords": [
@ -62,6 +61,7 @@
"node-gyp-build": "^4.8.0",
"rollup": "^4.0.0",
"ts-jest": "^29.4.6",
"tslib": "^2.8.1",
"typescript": "^6.0.0"
},
"optionalDependencies": {

View file

@ -26,6 +26,8 @@ export class SecureChatCompletion {
keyRotationPassword,
maxRetries,
keyDir,
attestationPolicy,
quoteVerifier,
} = config;
this._config = config;
@ -41,6 +43,8 @@ export class SecureChatCompletion {
...(keyRotationPassword !== undefined && { keyRotationPassword }),
...(maxRetries !== undefined && { maxRetries }),
...(keyDir !== undefined && { keyDir }),
...(attestationPolicy !== undefined && { attestationPolicy }),
...(quoteVerifier !== undefined && { quoteVerifier }),
});
}

View file

@ -11,6 +11,7 @@ import { AESEncryption } from './crypto/encryption';
import { RSAOperations } from './crypto/rsa';
import { createHttpClient, HttpClient } from './http/client';
import { createSecureMemory, SecureByteContext } from './memory/secure';
import { SgxAttestationVerifier } from './attestation/SgxAttestationVerifier';
import {
SecurityError,
APIConnectionError,
@ -32,6 +33,27 @@ import {
const VALID_SECURITY_TIERS = ['standard', 'high', 'maximum'] as const;
/**
* Marks errors that describe malformed *data* rather than a failed integrity
* check, so they survive the deliberately opaque catch-all in decryptResponse.
*
* Python distinguishes these by raising ValueError vs SecurityError; the JS
* equivalent of ValueError is a plain Error, so the distinction rides on this
* symbol instead of a new exported class.
*/
const FORMAT_ERROR = Symbol('nomyo.formatError');
function formatError(message: string): Error {
const err = new Error(message);
(err as Error & { [FORMAT_ERROR]?: true })[FORMAT_ERROR] = true;
return err;
}
function isFormatError(error: unknown): error is Error {
return error instanceof Error
&& (error as Error & { [FORMAT_ERROR]?: true })[FORMAT_ERROR] === true;
}
function generateUUID(): string {
if (typeof crypto !== 'undefined' && typeof (crypto as Crypto & { randomUUID?: () => string }).randomUUID === 'function') {
return (crypto as Crypto & { randomUUID: () => string }).randomUUID();
@ -73,9 +95,16 @@ export class SecureCompletionClient {
private readonly keyRotationDir?: string;
private readonly keyRotationPassword?: string;
private readonly maxRetries: number;
private readonly keyDir: string;
/** Undefined means ephemeral: keys live in memory only and are never written to disk. */
private readonly keyDir?: string;
private _isHttps: boolean = true;
/**
* Only constructed when an attestation policy is supplied, so existing
* callers get byte-for-byte the previous behaviour.
*/
private readonly attestation?: SgxAttestationVerifier;
// Promise-based mutex: serialises concurrent ensureKeys() calls
private ensureKeysLock: Promise<void> = Promise.resolve();
@ -85,13 +114,18 @@ export class SecureCompletionClient {
allowHttp = false,
secureMemory = true,
keySize = 4096,
timeout = 60000,
// 900 s, matching the Python SDK. Encrypted inference cannot stream,
// so the whole completion arrives in one response — a long generation
// on a busy backend legitimately takes minutes.
timeout = 900000,
debug = false,
keyRotationInterval = 86400000, // 24 hours
keyRotationDir,
keyRotationPassword,
maxRetries = 2,
keyDir = 'client_keys',
keyDir,
attestationPolicy,
quoteVerifier,
} = config;
this.debugMode = debug;
@ -100,7 +134,8 @@ export class SecureCompletionClient {
this.keyRotationDir = keyRotationDir;
this.keyRotationPassword = keyRotationPassword;
this.maxRetries = maxRetries;
this.keyDir = keyDir;
// null and undefined both mean ephemeral (Python's key_dir=None default)
this.keyDir = keyDir ?? undefined;
this.keySize = keySize;
this.allowHttp = allowHttp;
this.secureMemory = secureMemory;
@ -127,6 +162,19 @@ export class SecureCompletionClient {
}
}
// Optional SGX attestation: only construct the verifier when a policy is
// provided, so existing callers see unchanged behaviour.
if (attestationPolicy !== undefined) {
this.attestation = new SgxAttestationVerifier({
routerUrl: this.routerUrl,
policy: attestationPolicy,
verifier: quoteVerifier,
allowHttp,
timeout: this.requestTimeout,
debug: this.debugMode,
});
}
// Initialize components
this.keyManager = new KeyManager(this.debugMode);
this.aes = new AESEncryption();
@ -181,10 +229,17 @@ export class SecureCompletionClient {
if (this.disposed) return;
if (this.debugMode) console.log('Key rotation: generating new key pair...');
try {
// Persist rotated keys only where a directory was configured. In
// ephemeral mode (the default) rotation stays in memory, so rotation
// never silently starts writing private keys to disk.
const rotationDir = typeof window === 'undefined'
? (this.keyRotationDir ?? this.keyDir)
: undefined;
await this.keyManager.rotateKeys({
keySize: this.keySize,
saveToFile: typeof window === 'undefined',
keyDir: this.keyRotationDir ?? 'client_keys',
saveToFile: rotationDir !== undefined,
...(rotationDir !== undefined && { keyDir: rotationDir }),
password: this.keyRotationPassword,
});
if (this.debugMode) console.log('Key rotation: complete');
@ -249,28 +304,35 @@ export class SecureCompletionClient {
private async _doEnsureKeys(): Promise<void> {
if (this.keyManager.hasKeys()) return;
// Try to load keys from the configured directory (Node.js only)
if (typeof window === 'undefined') {
try {
const fs = require('fs').promises as { access: (p: string) => Promise<void> };
const path = require('path') as { join: (...p: string[]) => string };
// Ephemeral mode (the default, matching Python's key_dir=None): the key
// pair exists only for this session and never touches disk. Also the only
// possible mode in browsers, which have no filesystem.
if (this.keyDir === undefined || typeof window !== 'undefined') {
if (this.debugMode) console.log('Generating ephemeral in-memory key pair (not persisted)');
await this.generateKeys();
return;
}
const privateKeyPath = path.join(this.keyDir, 'private_key.pem');
const publicKeyPath = path.join(this.keyDir, 'public_key.pem');
// Persistent mode: reuse the key pair in keyDir, or create and save one there.
try {
const fs = require('fs').promises as { access: (p: string) => Promise<void> };
const path = require('path') as { join: (...p: string[]) => string };
await fs.access(privateKeyPath);
await fs.access(publicKeyPath);
const privateKeyPath = path.join(this.keyDir, 'private_key.pem');
const publicKeyPath = path.join(this.keyDir, 'public_key.pem');
await this.loadKeys(privateKeyPath, publicKeyPath);
if (this.debugMode) console.log(`Loaded existing keys from ${this.keyDir}/`);
return;
} catch (_error) {
if (this.debugMode) console.log(`No existing keys found in ${this.keyDir}/, generating new keys...`);
}
await fs.access(privateKeyPath);
await fs.access(publicKeyPath);
await this.loadKeys(privateKeyPath, publicKeyPath);
if (this.debugMode) console.log(`Loaded existing keys from ${this.keyDir}/`);
return;
} catch (_error) {
if (this.debugMode) console.log(`No existing keys found in ${this.keyDir}/, generating new keys...`);
}
await this.generateKeys({
saveToFile: typeof window === 'undefined',
saveToFile: true,
keyDir: this.keyDir,
});
}
@ -306,7 +368,7 @@ export class SecureCompletionClient {
try {
await this.rsa.importPublicKey(serverPublicKey);
} catch (_error) {
throw new Error('Server returned invalid public key format');
throw formatError('Server returned invalid public key format');
}
if (this._isHttps) {
@ -317,12 +379,18 @@ export class SecureCompletionClient {
return serverPublicKey;
} else {
throw new Error(`Failed to fetch server's public key: HTTP ${response.statusCode}`);
throw formatError(`Failed to fetch server's public key: HTTP ${response.statusCode}`);
}
} catch (error) {
if (error instanceof SecurityError) {
throw error;
}
// A bad key or a bad status is a server-response problem, not a
// connection problem — mirrors Python raising ValueError here and
// reserving ConnectionError for transport failures.
if (isFormatError(error)) {
throw error;
}
if (error instanceof Error) {
throw new APIConnectionError(`Failed to fetch server's public key: ${error.message}`);
}
@ -338,8 +406,13 @@ export class SecureCompletionClient {
* - nonce: 12-byte GCM nonce
* - tag: 16-byte GCM auth tag (split from Web Crypto output)
* - encrypted_aes_key: AES key encrypted with server's RSA public key
*
* @param serverPublicKeyPem 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.
*/
async encryptPayload(payload: object): Promise<ArrayBuffer> {
async encryptPayload(payload: object, serverPublicKeyPem?: string): Promise<ArrayBuffer> {
this.assertNotDisposed();
if (!payload || typeof payload !== 'object') {
@ -361,10 +434,10 @@ export class SecureCompletionClient {
if (this.secureMemory) {
const context = new SecureByteContext(payloadBytes, true);
return await context.use(async (protectedPayload) => {
return await this.performEncryption(protectedPayload);
return await this.performEncryption(protectedPayload, serverPublicKeyPem);
});
} else {
return await this.performEncryption(payloadBytes);
return await this.performEncryption(payloadBytes, serverPublicKeyPem);
}
}
@ -375,7 +448,10 @@ export class SecureCompletionClient {
* We split the tag out to match Python's cryptography library format
* which sends ciphertext and tag as separate fields.
*/
private async performEncryption(payloadBytes: ArrayBuffer): Promise<ArrayBuffer> {
private async performEncryption(
payloadBytes: ArrayBuffer,
serverPublicKeyPem?: string
): Promise<ArrayBuffer> {
const aesKey = await this.aes.generateKey();
const aesKeyBytes = await this.aes.exportKey(aesKey);
@ -390,8 +466,8 @@ export class SecureCompletionClient {
const ciphertextOnly = ciphertextBytes.slice(0, ciphertextBytes.length - TAG_LENGTH);
const tag = ciphertextBytes.slice(ciphertextBytes.length - TAG_LENGTH);
const serverPublicKeyPem = await this.fetchServerPublicKey();
const serverPublicKey = await this.rsa.importPublicKey(serverPublicKeyPem);
const keyPem = serverPublicKeyPem ?? await this.fetchServerPublicKey();
const serverPublicKey = await this.rsa.importPublicKey(keyPem);
const encryptedAesKey = await this.rsa.encryptKey(protectedAesKey, serverPublicKey);
const encryptedPackage = {
@ -474,6 +550,15 @@ export class SecureCompletionClient {
}
}
// Guard: the private key must exist before we attempt decryption, so a
// missing key reports itself instead of hiding behind the opaque
// integrity-failure message below (matches Python).
if (!this.keyManager.hasKeys()) {
throw new SecurityError(
'Private key not initialized. Call generateKeys() or loadKeys() first.'
);
}
try {
const encryptedAesKey = base64ToArrayBuffer(packageData.encrypted_aes_key as string);
const privateKey = this.keyManager.getPrivateKey();
@ -497,7 +582,15 @@ export class SecureCompletionClient {
const plaintextContext = new SecureByteContext(plaintext, this.secureMemory);
return await plaintextContext.use(async (protectedPlaintext) => {
const responseJson = arrayBufferToString(protectedPlaintext);
const decoded = JSON.parse(responseJson) as Record<string, unknown>;
let decoded: Record<string, unknown>;
try {
decoded = JSON.parse(responseJson) as Record<string, unknown>;
} catch (_parseError) {
// GCM already authenticated this plaintext, so bad JSON
// is the peer sending us garbage, not an integrity failure.
throw formatError('Decrypted response is not valid JSON');
}
// Validate required ChatCompletionResponse fields
if (
@ -529,7 +622,16 @@ export class SecureCompletionClient {
if (this.debugMode) console.log('Response decrypted successfully');
return response;
} catch (error) {
// Don't leak specific decryption errors (timing attacks)
// Malformed-but-authenticated plaintext is a format problem; keep it
// distinguishable instead of reporting it as an integrity failure.
if (isFormatError(error)) {
throw error;
}
// Our own schema rejection already carries a safe, specific message.
if (error instanceof SecurityError) {
throw error;
}
// Anything else is a genuine crypto failure — stay opaque (timing attacks)
throw new SecurityError('Decryption failed: integrity check or authentication failed');
}
}
@ -570,6 +672,17 @@ export class SecureCompletionClient {
await this.ensureKeys();
// 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 throws here, so plaintext is never constructed or sent.
let serverPublicKeyPem: string | undefined;
if (this.attestation !== undefined) {
serverPublicKeyPem = await this.fetchServerPublicKey();
const pubkeyDer = await this.spkiDerFromPem(serverPublicKeyPem);
await this.attestation.ensureVerified(pubkeyDer, securityTier);
}
const publicKeyPem = await this.keyManager.getPublicKeyPEM();
const headers: Record<string, string> = {
'X-Payload-ID': payloadId,
@ -607,7 +720,7 @@ export class SecureCompletionClient {
// Re-encrypt each attempt (throws non-retryable errors like SecurityError
// or DisposedError — let those propagate immediately)
const encryptedPayload = await this.encryptPayload(payload);
const encryptedPayload = await this.encryptPayload(payload, serverPublicKeyPem);
let response: { statusCode: number; body: ArrayBuffer };
try {
@ -722,6 +835,19 @@ export class SecureCompletionClient {
}
}
/**
* 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). 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.
*/
private async spkiDerFromPem(pubkeyPem: string): Promise<ArrayBuffer> {
const key = await this.rsa.importPublicKey(pubkeyPem);
return await this.rsa.exportPublicKeyDer(key);
}
/**
* Validate RSA key size (minimum 2048 bits)
*/

View file

@ -0,0 +1,269 @@
/**
* SGX DCAP attestation orchestration.
*
* 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.
*
* Port of Python's SgxAttestationVerifier.
*/
import { QuoteVerifier, VerifiedQuote, REPORT_DATA_LEN } from '../../types/attestation';
import { AttestationPolicy } from './policy';
import { AttestationError } from '../../errors';
import { createHttpClient, HttpClient } from '../http/client';
import {
arrayBufferToString,
base64ToArrayBuffer,
bytesToHex,
constantTimeEqual,
generateRandomBytes,
sha256,
sha512,
} from '../crypto/utils';
export interface SgxAttestationVerifierOptions {
/** Base URL of the NOMYO router */
routerUrl: string;
/** Policy controlling what counts as an acceptable enclave */
policy: AttestationPolicy;
/** Step-4 DCAP quote verifier. Without one, quotes are unverifiable. */
verifier?: QuoteVerifier;
/** Allow fetching attestation over plain HTTP (local development only) */
allowHttp?: boolean;
/** Request timeout in milliseconds (default: 900000) */
timeout?: number;
/** Emit verbose progress logs */
debug?: boolean;
}
/**
* Runs the §3 verification algorithm around a QuoteVerifier.
*
* One instance per SecureCompletionClient. Caches the (pubkey -> verified)
* result per server for the session to avoid re-verifying on every call, and
* re-verifies if the server pubkey changes. Liveness mode bypasses the cache.
*/
export class SgxAttestationVerifier {
private readonly routerUrl: string;
private readonly policy: AttestationPolicy;
private readonly verifier?: QuoteVerifier;
private readonly allowHttp: boolean;
private readonly timeout: number;
private readonly debug: boolean;
private readonly httpClient: HttpClient;
/** Hex sha256(pubkey_der) values that verified OK this session. */
private readonly verifiedKeys = new Set<string>();
constructor(options: SgxAttestationVerifierOptions) {
const { routerUrl, policy, verifier, allowHttp = false, timeout = 900000, debug = false } = options;
this.routerUrl = routerUrl.replace(/\/$/, '');
this.policy = policy;
this.verifier = verifier;
this.allowHttp = allowHttp;
this.timeout = timeout;
this.debug = debug;
this.httpClient = createHttpClient();
}
/**
* Run the §3 algorithm for the given server public key + tier.
*
* Returns the VerifiedQuote on success, or null when attestation is not
* required for the tier, when a cached verification covers this key, or when
* it failed but `enforce` is off.
*
* @throws AttestationError when verification fails and `policy.enforce` is
* true in which case the caller MUST NOT send plaintext.
*/
async ensureVerified(
pubkeyDer: ArrayBuffer,
securityTier?: string
): Promise<VerifiedQuote | null> {
const tier = securityTier ?? 'standard';
if (!this.policy.requireAttestationForTier.has(tier)) {
// e.g. standard tier: attestation not expected — proceed (spec §6).
return null;
}
const keyId = bytesToHex(await sha256(pubkeyDer));
if (!this.policy.liveness && this.verifiedKeys.has(keyId)) {
if (this.debug) {
console.log(`SGX attestation: using cached verification for key ${keyId.slice(0, 16)}`);
}
return null;
}
if (this.verifier === undefined) {
return this.fail(
'no quote verifier configured (cannot perform step-4 DCAP verification)'
);
}
const challenge = this.policy.liveness
? generateRandomBytes(32)
: new Uint8Array(0);
const att = await this.fetchAttestation(challenge);
if (att === null) {
return this.fail('could not fetch attestation from server');
}
if (!att.is_available) {
const reason = typeof att.reason === 'string' ? att.reason : 'unknown';
return this.fail(`attestation unavailable: ${reason}`);
}
const quoteB64 = att.quote_b64;
if (typeof quoteB64 !== 'string' || quoteB64.length === 0) {
return this.fail('attestation response missing quote_b64');
}
let quote: Uint8Array;
try {
quote = new Uint8Array(base64ToArrayBuffer(quoteB64));
} catch (_error) {
return this.fail('attestation quote_b64 is not valid base64');
}
// Step 4: delegate the cryptographic quote verification.
let v: VerifiedQuote;
try {
v = await this.verifier.verify(quote);
} catch (error) {
if (error instanceof AttestationError) {
return this.fail(`quote verification failed: ${error.message}`);
}
const name = error instanceof Error ? error.name : typeof error;
const message = error instanceof Error ? error.message : String(error);
return this.fail(`quote verification error: ${name}: ${message}`);
}
// Step 5: policy checks.
const policyError = this.checkPolicy(v);
if (policyError !== null) {
return this.fail(policyError);
}
// Step 6: key-binding check (constant-time).
const bound = new Uint8Array(pubkeyDer.byteLength + challenge.byteLength);
bound.set(new Uint8Array(pubkeyDer), 0);
bound.set(challenge, pubkeyDer.byteLength);
const expected = new Uint8Array(await sha512(bound));
if (v.reportData.length !== REPORT_DATA_LEN || !constantTimeEqual(expected, v.reportData)) {
return this.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 (!this.policy.liveness) {
this.verifiedKeys.add(keyId);
}
if (this.debug) {
console.log(
`SGX attestation verified: mrenclave=${v.mrenclave.slice(0, 16)} ` +
`tcb=${v.tcbStatus} tier=${tier}`
);
}
return v;
}
/**
* Return an error string if any policy check fails, else null.
*/
private checkPolicy(v: VerifiedQuote): string | null {
if (!this.policy.allowedTcbStatuses.has(v.tcbStatus)) {
const allowed = Array.from(this.policy.allowedTcbStatuses).sort();
return `TCB status '${v.tcbStatus}' not in allowed ${JSON.stringify(allowed)}`;
}
if (this.policy.pinnedMrenclave.size > 0) {
if (!this.policy.pinnedMrenclave.has(v.mrenclave.toLowerCase())) {
return `MRENCLAVE ${v.mrenclave} not in pinned set`;
}
} else {
console.warn(
'SGX attestation: no pinnedMrenclave configured — enclave identity ' +
'is NOT verified (configure AttestationPolicy.pinnedMrenclave)'
);
}
if (this.policy.pinnedMrsigner !== undefined) {
if (v.mrsigner.toLowerCase() !== this.policy.pinnedMrsigner) {
return `MRSIGNER ${v.mrsigner} != pinned ${this.policy.pinnedMrsigner}`;
}
}
if (this.policy.pinnedProdId !== undefined) {
if (v.isvProdId !== this.policy.pinnedProdId) {
return `isvProdId ${v.isvProdId} != pinned ${this.policy.pinnedProdId}`;
}
}
if (this.policy.minIsvSvn !== undefined) {
if (v.isvSvn < this.policy.minIsvSvn) {
return `isvSvn ${v.isvSvn} < minimum ${this.policy.minIsvSvn}`;
}
}
return null;
}
/**
* GET /pki/attestation; return parsed JSON, or null on transport error.
*
* @throws AttestationError if the router URL is not HTTPS and HTTP was not
* explicitly allowed regardless of `enforce`, since fetching attestation
* over a MITM-able channel defeats its purpose.
*/
private async fetchAttestation(challenge: Uint8Array): Promise<Record<string, unknown> | null> {
if (!this.routerUrl.startsWith('https://') && !this.allowHttp) {
throw new AttestationError(
'Attestation must be fetched over HTTPS to prevent MITM attacks ' +
'(initialize with allowHttp=true only for local development).'
);
}
let url = `${this.routerUrl}/pki/attestation`;
if (challenge.length > 0) {
url += `?challenge=${encodeURIComponent(bytesToHex(challenge))}`;
}
try {
const response = await this.httpClient.get(url, { timeout: this.timeout });
if (response.statusCode !== 200) {
console.warn(
`SGX attestation: /pki/attestation returned HTTP ${response.statusCode}`
);
return null;
}
return JSON.parse(arrayBufferToString(response.body)) as Record<string, unknown>;
} catch (error) {
console.warn(
`SGX attestation: failed to fetch attestation: ${error instanceof Error ? error.message : 'unknown error'}`
);
return null;
}
}
/**
* Apply the §6 rollout policy: hard-fail in enforce mode, else warn + proceed.
*/
private fail(reason: string): null {
if (this.policy.enforce) {
throw new AttestationError(`SGX attestation failed: ${reason}`);
}
console.warn(
`SGX attestation: ${reason} — proceeding because enforce=false (rollout mode)`
);
return null;
}
}

View file

@ -0,0 +1,13 @@
/**
* SGX DCAP attestation public surface.
*/
export { AttestationPolicy } from './policy';
export { SgxAttestationVerifier } from './SgxAttestationVerifier';
export type { SgxAttestationVerifierOptions } from './SgxAttestationVerifier';
export {
CallableQuoteVerifier,
JwtQuoteVerifier,
DEFAULT_CLAIM_MAP,
} from './verifiers';
export type { VerifierCallable, JwtQuoteVerifierOptions, JoseModule } from './verifiers';

View file

@ -0,0 +1,42 @@
/**
* Client-side SGX attestation policy.
*
* Port of Python's `AttestationPolicy` dataclass, including the `__post_init__`
* normalisation: pins are lowercased so comparisons against verifier output are
* case-stable, and the set-typed fields accept any iterable.
*/
import { AttestationPolicyOptions } from '../../types/attestation';
export class AttestationPolicy {
readonly pinnedMrenclave: ReadonlySet<string>;
readonly pinnedMrsigner?: string;
readonly pinnedProdId?: number;
readonly minIsvSvn?: number;
readonly allowedTcbStatuses: ReadonlySet<string>;
readonly enforce: boolean;
readonly requireAttestationForTier: ReadonlySet<string>;
readonly liveness: boolean;
constructor(options: AttestationPolicyOptions = {}) {
const {
pinnedMrenclave = [],
pinnedMrsigner,
pinnedProdId,
minIsvSvn,
allowedTcbStatuses = ['UpToDate'],
enforce = false,
requireAttestationForTier = ['high', 'maximum'],
liveness = false,
} = options;
this.pinnedMrenclave = new Set(Array.from(pinnedMrenclave, m => m.toLowerCase()));
this.pinnedMrsigner = pinnedMrsigner?.toLowerCase();
this.pinnedProdId = pinnedProdId;
this.minIsvSvn = minIsvSvn;
this.allowedTcbStatuses = new Set(allowedTcbStatuses);
this.enforce = enforce;
this.requireAttestationForTier = new Set(requireAttestationForTier);
this.liveness = liveness;
}
}

View file

@ -0,0 +1,275 @@
/**
* Step-4 DCAP quote verifiers.
*
* Verifying a DCAP quote (ECDSA signature + PCK certificate chain + TCB /
* revocation) is intentionally *not* done here it must not be hand-rolled.
* These adapters delegate that step to a remote appraisal service or to a
* user-supplied callable, and return the trusted parsed values.
*
* Port of Python's CallableQuoteVerifier / JwtQuoteVerifier.
*/
import { QuoteVerifier, VerifiedQuote } from '../../types/attestation';
import { AttestationError } from '../../errors';
import { createHttpClient, HttpClient } from '../http/client';
import { arrayBufferToBase64, arrayBufferToString, hexToBytes } from '../crypto/utils';
/** A user callable may be sync or async and returns a VerifiedQuote. */
export type VerifierCallable = (quote: Uint8Array) => VerifiedQuote | Promise<VerifiedQuote>;
/**
* Adapt a (sync or async) callable into a QuoteVerifier.
*/
export class CallableQuoteVerifier implements QuoteVerifier {
constructor(private readonly func: VerifierCallable) {}
async verify(quote: Uint8Array): Promise<VerifiedQuote> {
const result = await this.func(quote);
if (!isVerifiedQuote(result)) {
throw new AttestationError('quote verifier callable must return a VerifiedQuote');
}
return result;
}
}
/**
* Structural check the JS port has no dataclass to `instanceof` against, so
* validate the shape the rest of the pipeline depends on.
*/
function isVerifiedQuote(value: unknown): value is VerifiedQuote {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.mrenclave === 'string' &&
typeof v.mrsigner === 'string' &&
typeof v.isvProdId === 'number' &&
typeof v.isvSvn === 'number' &&
typeof v.tcbStatus === 'string' &&
v.reportData instanceof Uint8Array
);
}
/**
* Default mapping from VerifiedQuote field to JWT claim name. Concrete services
* differ (Intel Trust Authority vs Veraison vs a custom QVL wrapper), so this is
* overridable per JwtQuoteVerifier instance.
*/
export const DEFAULT_CLAIM_MAP: Record<keyof VerifiedQuote, string> = {
mrenclave: 'sgx_mrenclave',
mrsigner: 'sgx_mrsigner',
isvProdId: 'sgx_isvprodid',
isvSvn: 'sgx_isvsvn',
tcbStatus: 'tcb_status',
reportData: 'sgx_report_data',
};
export interface JwtQuoteVerifierOptions {
/** Expected JWT issuer */
issuer?: string;
/** Expected JWT audience */
audience?: string;
/** Override the claim-name mapping for a non-standard service */
claimMap?: Partial<Record<keyof VerifiedQuote, string>>;
/** Accepted JWS algorithms (default: RS256, ES256, ES384) */
algorithms?: string[];
/** Request timeout in milliseconds (default: 30000) */
timeout?: number;
/**
* The `jose` module to use for JWT validation.
*
* Normally omitted it is loaded via require('jose'). Supply it explicitly
* when that cannot work: jose v5+ is ESM-only, so a CommonJS bundle cannot
* require() it. In that case import it yourself and pass it in:
*
* import * as jose from 'jose';
* new JwtQuoteVerifier(verifyUrl, jwksUrl, { jose });
*/
jose?: JoseModule;
}
/** Minimal surface of the optional `jose` dependency that we rely on. */
export interface JoseModule {
createRemoteJWKSet(url: URL): unknown;
jwtVerify(
token: string,
key: unknown,
options: { issuer?: string; audience?: string; algorithms?: string[] }
): Promise<{ payload: Record<string, unknown> }>;
}
let joseModule: JoseModule | null = null;
const JOSE_MISSING =
"JwtQuoteVerifier requires the 'jose' package. Install it alongside nomyo-js " +
'(npm install jose). If your project is CommonJS and the installed jose is ' +
"ESM-only (v5+), import it yourself and pass it in: new JwtQuoteVerifier(url, jwks, { jose }).";
/**
* Resolve the optional `jose` package.
*
* Deliberately does NOT fall back to a dynamic `import()` for ESM-only builds.
* Reaching `import()` from CommonJS output requires constructing the call at
* runtime (`new Function`/`eval`), which is blocked by any Content-Security-
* Policy without 'unsafe-eval'. This library ships a browser bundle, and that
* failure would land squarely in the attestation path so callers under
* CommonJS pass the module in via `options.jose` instead.
*/
function resolveJose(injected?: JoseModule): JoseModule {
if (injected) return injected;
if (joseModule) return joseModule;
if (typeof require === 'function') {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
joseModule = require('jose') as JoseModule;
return joseModule;
} catch (_notLoadable) {
// Missing, or ESM-only under CommonJS — both need explicit injection
}
}
throw new AttestationError(JOSE_MISSING);
}
/**
* Verify a quote via a remote service that returns a signed JWT (spec §4A).
*
* POSTs the base64 quote to `verifyUrl`; the service replies with a JWT whose
* signature is validated against `jwksUrl` (and optional issuer/audience). The
* validated claims are mapped to a VerifiedQuote.
*
* Requires the optional `jose` dependency: `npm install jose`
*
* Note: unlike the Python client there is no `verify_ssl` escape hatch TLS
* certificate verification against the appraisal service is always enforced.
*/
export class JwtQuoteVerifier implements QuoteVerifier {
private readonly issuer?: string;
private readonly audience?: string;
private readonly claimMap: Record<keyof VerifiedQuote, string>;
private readonly algorithms: string[];
private readonly timeout: number;
private readonly httpClient: HttpClient;
private readonly injectedJose?: JoseModule;
constructor(
private readonly verifyUrl: string,
private readonly jwksUrl: string,
options: JwtQuoteVerifierOptions = {}
) {
const { issuer, audience, claimMap, algorithms, timeout = 30000, jose } = options;
this.issuer = issuer;
this.audience = audience;
this.claimMap = { ...DEFAULT_CLAIM_MAP, ...claimMap };
this.algorithms = algorithms ?? ['RS256', 'ES256', 'ES384'];
this.timeout = timeout;
this.injectedJose = jose;
this.httpClient = createHttpClient();
}
async verify(quote: Uint8Array): Promise<VerifiedQuote> {
const jose = resolveJose(this.injectedJose);
let response: { statusCode: number; body: ArrayBuffer };
try {
response = await this.httpClient.post(this.verifyUrl, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
quote: arrayBufferToBase64(quote.buffer.slice(
quote.byteOffset,
quote.byteOffset + quote.byteLength
) as ArrayBuffer),
}),
timeout: this.timeout,
});
} catch (error) {
throw new AttestationError(
`could not reach quote verification service: ${error instanceof Error ? error.message : 'unknown error'}`
);
}
if (response.statusCode !== 200) {
throw new AttestationError(
`quote verification service returned HTTP ${response.statusCode}`
);
}
let token: unknown;
try {
const parsed = JSON.parse(arrayBufferToString(response.body)) as Record<string, unknown>;
token = parsed.token;
} catch (_error) {
throw new AttestationError("verifier response missing JWT 'token'");
}
if (typeof token !== 'string' || token.length === 0) {
throw new AttestationError("verifier response missing JWT 'token'");
}
// Validate the JWT signature against the service's published JWKS.
let claims: Record<string, unknown>;
try {
const jwks = jose.createRemoteJWKSet(new URL(this.jwksUrl));
const result = await jose.jwtVerify(token, jwks, {
...(this.issuer !== undefined && { issuer: this.issuer }),
...(this.audience !== undefined && { audience: this.audience }),
algorithms: this.algorithms,
});
claims = result.payload;
} catch (error) {
throw new AttestationError(
`JWT validation failed: ${error instanceof Error ? error.message : 'unknown error'}`
);
}
return this.claimsToVerifiedQuote(claims);
}
private claimsToVerifiedQuote(claims: Record<string, unknown>): VerifiedQuote {
const cm = this.claimMap;
const required = (field: keyof VerifiedQuote): unknown => {
const value = claims[cm[field]];
if (value === undefined || value === null) {
throw new AttestationError(
`verifier JWT missing/invalid expected claim: '${cm[field]}'`
);
}
return value;
};
const toInt = (field: keyof VerifiedQuote): number => {
const value = required(field);
const parsed = typeof value === 'number' ? value : Number(value);
if (!Number.isInteger(parsed)) {
throw new AttestationError(
`verifier JWT missing/invalid expected claim: '${cm[field]}' is not an integer`
);
}
return parsed;
};
let reportData: Uint8Array;
try {
reportData = hexToBytes(String(required('reportData')));
} catch (error) {
throw new AttestationError(
`verifier JWT missing/invalid expected claim: '${cm.reportData}' is not valid hex`
);
}
return {
mrenclave: String(required('mrenclave')).toLowerCase(),
mrsigner: String(required('mrsigner')).toLowerCase(),
isvProdId: toInt('isvProdId'),
isvSvn: toInt('isvSvn'),
tcbStatus: String(required('tcbStatus')),
reportData,
};
}
}

View file

@ -3,7 +3,7 @@
* Matches the Python implementation using AES-256-GCM with random nonces
*/
import { getCrypto, arrayBufferToBase64, base64ToArrayBuffer, generateRandomBytes } from './utils';
import { getCrypto, generateRandomBytes } from './utils';
export class AESEncryption {
private subtle: SubtleCrypto;

View file

@ -3,7 +3,7 @@
* Matches the Python implementation using RSA-OAEP with SHA-256
*/
import { getCrypto, pemToArrayBuffer, arrayBufferToPem, stringToArrayBuffer, arrayBufferToString, generateRandomBytes } from './utils';
import { getCrypto, pemToArrayBuffer, arrayBufferToPem, stringToArrayBuffer, generateRandomBytes } from './utils';
import { SecureByteContext } from '../memory/secure';
export class RSAOperations {
@ -76,6 +76,17 @@ export class RSAOperations {
return arrayBufferToPem(exported, 'PUBLIC');
}
/**
* Export public key to canonical SPKI DER bytes.
*
* Used for the SGX attestation key binding, which is computed over exactly
* these bytes.
* @param publicKey RSA public key
*/
async exportPublicKeyDer(publicKey: CryptoKey): Promise<ArrayBuffer> {
return await this.subtle.exportKey('spki', publicKey);
}
/**
* Import public key from PEM format
* @param pem PEM-encoded public key

View file

@ -109,6 +109,69 @@ export function arrayBufferToPem(buffer: ArrayBuffer, type: 'PUBLIC' | 'PRIVATE'
return `${header}\n${formatted}\n${footer}`;
}
/**
* Convert bytes to a lowercase hex string
*/
export function bytesToHex(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let hex = '';
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, '0');
}
return hex;
}
/**
* Parse a hex string into bytes.
* @throws Error if the string is not valid, even-length hex
*/
export function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string: odd length');
}
if (hex.length > 0 && !/^[0-9a-fA-F]+$/.test(hex)) {
throw new Error('Invalid hex string: non-hex characters');
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
/**
* Compare two byte sequences in constant time (no early exit).
*
* Equivalent to Python's `hmac.compare_digest`. Used for the SGX report_data
* key-binding check so a mismatch leaks no positional information via timing.
*/
export function constantTimeEqual(a: ArrayBuffer | Uint8Array, b: ArrayBuffer | Uint8Array): boolean {
const x = a instanceof Uint8Array ? a : new Uint8Array(a);
const y = b instanceof Uint8Array ? b : new Uint8Array(b);
if (x.length !== y.length) {
return false;
}
let diff = 0;
for (let i = 0; i < x.length; i++) {
diff |= x[i] ^ y[i];
}
return diff === 0;
}
/**
* SHA-256 digest
*/
export async function sha256(data: ArrayBuffer | Uint8Array): Promise<ArrayBuffer> {
return await getCrypto().digest('SHA-256', data as BufferSource);
}
/**
* SHA-512 digest
*/
export async function sha512(data: ArrayBuffer | Uint8Array): Promise<ArrayBuffer> {
return await getCrypto().digest('SHA-512', data as BufferSource);
}
/**
* Get the Web Crypto API (works in both browser and Node.js)
*/

View file

@ -20,6 +20,18 @@ export class BrowserSecureMemory implements SecureMemory {
view.fill(0);
}
/**
* Locking is impossible in a browser: there is no mlock equivalent exposed
* to page scripts. Always false, so callers degrade to zeroing only.
*/
lockMemory(_data: ArrayBuffer): boolean {
return false;
}
unlockMemory(_data: ArrayBuffer): boolean {
return false;
}
/**
* Get protection information
*/
@ -28,7 +40,9 @@ export class BrowserSecureMemory implements SecureMemory {
canLock: false,
isPlatformSecure: false,
method: 'zero-only',
details: 'Browser environment: memory locking not available. Using immediate zeroing only.',
platform: 'browser',
hasSecureZeroing: false,
details: 'Browser environment: memory locking not available. Using best-effort zeroing only.',
};
}
}

View file

@ -33,14 +33,42 @@ try {
// Native addon not installed — degrade gracefully
}
/**
* Whether mlock actually succeeds in this process, determined once by trying it.
*
* Having the addon loaded is not the same as being allowed to lock: mlock is
* routinely refused by RLIMIT_MEMLOCK (often only a few MB, and 0 in some
* containers) without any elevated privileges. Reporting the capability rather
* than the reality would overstate the protection actually in force.
*/
let lockProbeResult: boolean | null = null;
function canActuallyLock(): boolean {
if (lockProbeResult !== null) return lockProbeResult;
if (nativeAddon === null) {
lockProbeResult = false;
return false;
}
try {
const probe = Buffer.alloc(nativeAddon.getPageSize());
const locked = nativeAddon.mlockBuffer(probe);
if (locked) {
nativeAddon.munlockBuffer(probe);
}
lockProbeResult = locked;
} catch (_e) {
lockProbeResult = false;
}
return lockProbeResult;
}
export class NodeSecureMemory implements SecureMemory {
private readonly hasNative: boolean;
constructor() {
// Deliberately silent: a library must not write to stdout on construction.
// Callers surface this via getProtectionInfo() under their own debug flag.
this.hasNative = nativeAddon !== null;
if (this.hasNative) {
console.log('nomyo-native addon loaded: mlock + secure-zero available');
}
}
/**
@ -80,26 +108,49 @@ export class NodeSecureMemory implements SecureMemory {
/**
* Get memory protection information.
*
* Reports what is actually in force, not what is theoretically supported
* `canLock` reflects a real mlock attempt.
*/
getProtectionInfo(): ProtectionInfo {
if (this.hasNative) {
if (!this.hasNative) {
return {
canLock: true,
isPlatformSecure: true,
method: 'mlock',
canLock: false,
isPlatformSecure: false,
method: 'zero-only',
platform: process.platform,
hasSecureZeroing: false,
details:
'Node.js with nomyo-native addon: mlock + explicit_bzero/SecureZeroMemory available.',
'Node.js (pure JS): memory locking not available without the native addon. ' +
'Using best-effort zeroing only. ' +
'Build the optional addon for mlock support: cd native && npm install && npm run build',
};
}
if (!canActuallyLock()) {
return {
canLock: false,
isPlatformSecure: false,
method: 'zero-only',
platform: process.platform,
hasSecureZeroing: true,
pageSize: nativeAddon!.getPageSize(),
details:
'Node.js with nomyo-native addon: secure zeroing available, but mlock was ' +
'refused by the OS (typically RLIMIT_MEMLOCK). Sensitive buffers may still ' +
'be swapped to disk. Raise the memlock limit or grant CAP_IPC_LOCK to enable locking.',
};
}
return {
canLock: false,
isPlatformSecure: false,
method: 'zero-only',
canLock: true,
isPlatformSecure: true,
method: 'mlock',
platform: process.platform,
hasSecureZeroing: true,
pageSize: nativeAddon!.getPageSize(),
details:
'Node.js (pure JS): memory locking not available without native addon. ' +
'Using immediate zeroing only. ' +
'Build the optional native addon for mlock support: cd native && npm install && npm run build',
'Node.js with nomyo-native addon: mlock + explicit_bzero/SecureZeroMemory active.',
};
}
}

View file

@ -48,6 +48,19 @@ export interface SecureMemory {
*/
zeroMemory(data: ArrayBuffer): void;
/**
* Attempt to lock the buffer into physical memory so it cannot be swapped
* to disk. Best-effort: returns false where locking is unavailable (no
* native addon, browsers) or refused (RLIMIT_MEMLOCK).
*/
lockMemory(data: ArrayBuffer): boolean;
/**
* Release a previously acquired lock. Returns false if nothing was locked
* or the call failed.
*/
unlockMemory(data: ArrayBuffer): boolean;
/**
* Get memory protection information
*/
@ -66,28 +79,66 @@ export class SecureByteContext {
private data: ArrayBuffer;
private secureMemory: SecureMemory;
private useSecure: boolean;
private shouldLock: boolean;
private isLocked = false;
constructor(data: ArrayBuffer, useSecure: boolean = _globalSecureMemoryEnabled) {
/**
* @param data Buffer to protect
* @param useSecure Apply protection at all (defaults to the global flag)
* @param lock Attempt to lock the buffer into physical memory
* (default true, mirroring Python's `secure_bytearray(lock=True)`)
*/
constructor(
data: ArrayBuffer,
useSecure: boolean = _globalSecureMemoryEnabled,
lock: boolean = true
) {
this.data = data;
this.useSecure = useSecure;
this.shouldLock = lock;
this.secureMemory = createSecureMemory();
}
/** Whether this buffer is currently locked into physical memory. */
get locked(): boolean {
return this.isLocked;
}
/**
* Use the secure data within a function
* Ensures memory is zeroed after use, even if an exception occurs
* Use the secure data within a function.
*
* Locks the buffer for the duration where the platform supports it, and
* always zeroes afterwards even if the callback throws. Locking is
* best-effort: failure downgrades to zeroing only, which still has value.
*/
async use<T>(fn: (data: ArrayBuffer) => Promise<T>): Promise<T> {
if (this.useSecure && this.shouldLock) {
try {
this.isLocked = this.secureMemory.lockMemory(this.data);
} catch (_lockErr) {
this.isLocked = false;
}
}
try {
return await fn(this.data);
} finally {
// Always zero memory, even if exception occurred
if (this.useSecure) {
// Zero first, while the pages are still locked and resident, so
// the cleartext cannot reach swap between zeroing and unlocking.
try {
this.secureMemory.zeroMemory(this.data);
} catch (_zeroErr) {
// zeroMemory failure must not mask the original error
}
if (this.isLocked) {
try {
this.secureMemory.unlockMemory(this.data);
} catch (_unlockErr) {
// ditto
}
this.isLocked = false;
}
}
}
}

View file

@ -85,6 +85,23 @@ export class SecurityError extends Error {
}
}
/**
* Raised when SGX attestation verification fails in `enforce` mode.
*
* Extends SecurityError so existing `instanceof SecurityError` checks treat a
* failed attestation as the security failure it is.
*/
export class AttestationError extends SecurityError {
constructor(message: string) {
super(message);
this.name = 'AttestationError';
if (captureStackTrace) {
captureStackTrace(this, this.constructor);
}
}
}
export class DisposedError extends Error {
constructor(message = 'This client instance has been disposed and can no longer be used') {
super(message);

View file

@ -6,7 +6,12 @@
export { SecureChatCompletion } from './api/SecureChatCompletion';
export { SecureCompletionClient } from './core/SecureCompletionClient';
// SGX DCAP attestation — mirrors Python's AttestationPolicy, QuoteVerifier,
// CallableQuoteVerifier, JwtQuoteVerifier and SgxAttestationVerifier
export * from './core/attestation';
// Export types
export * from './types/attestation';
export * from './types/api';
export * from './types/client';
export * from './types/crypto';

89
src/types/attestation.ts Normal file
View file

@ -0,0 +1,89 @@
/**
* SGX DCAP attestation types.
*
* Port of Python's SgxAttestation dataclasses and QuoteVerifier interface.
* See doc/attestation.md for the protocol this implements.
*/
/**
* 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).
*/
export const REPORT_DATA_LEN = 64;
/**
* Trusted result of verifying a DCAP quote.
*
* These values come from *inside* the hardware-signed quote (as returned by a
* QuoteVerifier) never from the untrusted `/pki/attestation` JSON.
*/
export interface VerifiedQuote {
/** Enclave measurement, lowercase hex */
mrenclave: string;
/** Enclave signer measurement, lowercase hex */
mrsigner: string;
/** ISV product ID */
isvProdId: number;
/** ISV security version number */
isvSvn: number;
/** TCB status string, e.g. "UpToDate" */
tcbStatus: string;
/** The 64-byte SGX report_data field */
reportData: Uint8Array;
}
/**
* Client-side attestation policy options (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.
*/
export interface AttestationPolicyOptions {
/** Accepted MRENCLAVE values (hex, case-insensitive). Empty = not verified. */
pinnedMrenclave?: Iterable<string>;
/** Required MRSIGNER (hex, case-insensitive) */
pinnedMrsigner?: string;
/** Required ISV product ID */
pinnedProdId?: number;
/** Minimum acceptable ISV SVN */
minIsvSvn?: number;
/** Accepted TCB statuses (default: {"UpToDate"}) */
allowedTcbStatuses?: Iterable<string>;
/**
* When true, a failed verification throws AttestationError and no plaintext
* is sent. When false (default), failures warn and proceed (rollout mode).
*/
enforce?: boolean;
/** Tiers that require attestation (default: {"high", "maximum"}) */
requireAttestationForTier?: Iterable<string>;
/**
* When true, send a fresh random challenge with every request and bypass
* the session cache, proving the quote is live rather than replayed.
*/
liveness?: boolean;
}
/**
* 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.
* Throw on any verification failure.
*/
export interface QuoteVerifier {
verify(quote: Uint8Array): Promise<VerifiedQuote>;
}

View file

@ -2,7 +2,28 @@
* Client configuration types
*/
export interface ClientConfig {
import { AttestationPolicy } from '../core/attestation/policy';
import { QuoteVerifier } from './attestation';
/** Options shared by ClientConfig and ChatCompletionConfig for SGX attestation. */
export interface AttestationConfig {
/**
* Optional AttestationPolicy enabling SGX DCAP attestation verification
* before any plaintext is sent (see doc/attestation.md).
* When omitted (default) attestation is disabled.
*/
attestationPolicy?: AttestationPolicy;
/**
* 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`).
*/
quoteVerifier?: QuoteVerifier;
}
export interface ClientConfig extends AttestationConfig {
/** Base URL of the NOMYO router (e.g., https://api.nomyo.ai) */
routerUrl: string;
@ -18,7 +39,7 @@ export interface ClientConfig {
/** Optional API key for authentication */
apiKey?: string;
/** Request timeout in milliseconds (default: 60000) */
/** Request timeout in milliseconds (default: 900000 = 15 min, matching the Python SDK) */
timeout?: number;
/** Enable debug logging (default: false) */
@ -36,10 +57,15 @@ export interface ClientConfig {
/**
* Directory to load/save RSA keys on startup.
* If the directory contains an existing key pair it is loaded; otherwise a
* new pair is generated and saved there. Default: 'client_keys'.
* Matches the Python SDK's `key_dir` constructor parameter.
* new pair is generated and saved there.
*
* Omit (or pass null) for ephemeral keys: a fresh pair is generated in
* memory for this session only and never written to disk. This is the
* default, matching the Python SDK's `key_dir=None`.
*
* Always ephemeral in browsers, which have no filesystem.
*/
keyDir?: string;
keyDir?: string | null;
/**
* Maximum number of retries on retryable errors (429, 500, 502, 503, 504,
@ -70,7 +96,7 @@ export interface KeyPaths {
publicKeyPath?: string;
}
export interface ChatCompletionConfig {
export interface ChatCompletionConfig extends AttestationConfig {
/** Base URL of the NOMYO router */
baseUrl?: string;
@ -83,7 +109,7 @@ export interface ChatCompletionConfig {
/** Enable secure memory protection */
secureMemory?: boolean;
/** Request timeout in milliseconds (default: 60000) */
/** Request timeout in milliseconds (default: 900000 = 15 min, matching the Python SDK) */
timeout?: number;
/** Enable debug logging (default: false) */
@ -102,10 +128,14 @@ export interface ChatCompletionConfig {
* Directory to load/save RSA keys on startup.
* If the directory contains an existing key pair it is loaded; otherwise a
* new pair is generated and saved there.
* Omit (or set to undefined) to use the default 'client_keys/' directory.
* Matches the Python SDK's `key_dir` constructor parameter.
*
* Omit (or pass null) for ephemeral keys: a fresh pair is generated in
* memory for this session only and never written to disk. This is the
* default, matching the Python SDK's `key_dir=None`.
*
* Always ephemeral in browsers, which have no filesystem.
*/
keyDir?: string;
keyDir?: string | null;
/**
* Maximum number of retries on retryable errors (429, 500, 502, 503, 504,

View file

@ -31,17 +31,31 @@ export interface EncryptedPackage {
}
export interface ProtectionInfo {
/** Whether memory can be locked (mlock) */
/**
* Whether memory locking actually works in this process.
*
* This is probed, not assumed: mlock is commonly refused by RLIMIT_MEMLOCK
* even when the native addon is loaded and the syscall is available.
*/
canLock: boolean;
/** Whether the platform provides secure memory protection */
isPlatformSecure: boolean;
/** Method used for memory protection */
/** Method actually in use for memory protection */
method: 'mlock' | 'zero-only' | 'none';
/** Additional information about protection status */
details?: string;
/** Platform identifier, e.g. "linux", "win32", "browser" */
platform?: string;
/** Whether a zeroing primitive the compiler cannot elide is available */
hasSecureZeroing?: boolean;
/** OS page size in bytes (Node.js with the native addon only) */
pageSize?: number;
}
export interface SecureMemoryConfig {

View file

@ -0,0 +1,528 @@
/**
* Unit tests for SGX DCAP attestation.
*
* Mirrors the Python suite in ../nomyo/test/test_attestation.py: a mock router
* serves /pki/attestation with a quote whose report_data is the real
* SHA-512(server_pubkey_der [|| challenge]) binding, and a "faithful" verifier
* decodes that quote back into a VerifiedQuote.
*/
import {
AttestationPolicy,
SgxAttestationVerifier,
CallableQuoteVerifier,
JwtQuoteVerifier,
} from '../../src/core/attestation';
import { VerifiedQuote } from '../../src/types/attestation';
import { AttestationError, SecurityError } from '../../src/errors';
import { SecureCompletionClient } from '../../src/core/SecureCompletionClient';
import { RSAOperations } from '../../src/core/crypto/rsa';
import {
bytesToHex,
hexToBytes,
constantTimeEqual,
sha512,
stringToArrayBuffer,
arrayBufferToBase64,
arrayBufferToString,
} from '../../src/core/crypto/utils';
// ---- fixtures --------------------------------------------------------------
const MRENCLAVE = 'a'.repeat(64);
const MRSIGNER = 'b'.repeat(64);
interface HttpResponseLike {
statusCode: number;
headers: Record<string, string>;
body: ArrayBuffer;
}
function jsonResponse(statusCode: number, body: object): HttpResponseLike {
return {
statusCode,
headers: { 'content-type': 'application/json' },
body: stringToArrayBuffer(JSON.stringify(body)),
};
}
/**
* Mock router: serves the server public key and an attestation quote binding
* that key (plus any challenge) via SHA-512, exactly as the real enclave does.
*/
class MockServer {
pubkeyPem!: string;
pubkeyDer!: ArrayBuffer;
/** Overrides for negative-path tests */
isAvailable = true;
reason = 'sgx not supported';
bindToWrongKey = false;
mrenclave = MRENCLAVE;
mrsigner = MRSIGNER;
isvProdId = 1;
isvSvn = 3;
tcbStatus = 'UpToDate';
/** Challenges observed on /pki/attestation */
seenChallenges: (string | null)[] = [];
attestationCalls = 0;
async init(): Promise<void> {
const rsa = new RSAOperations();
const pair = await rsa.generateKeyPair(2048);
this.pubkeyPem = await rsa.exportPublicKey(pair.publicKey);
this.pubkeyDer = await rsa.exportPublicKeyDer(pair.publicKey);
}
/**
* The quote is opaque to the orchestrator; we encode the claims a real DCAP
* verifier would recover from it, so `faithfulVerify` can play that role.
*/
private async quoteB64(challenge: Uint8Array): Promise<string> {
const keyBytes = this.bindToWrongKey
? new Uint8Array(this.pubkeyDer.byteLength).fill(0x41)
: new Uint8Array(this.pubkeyDer);
const bound = new Uint8Array(keyBytes.length + challenge.length);
bound.set(keyBytes, 0);
bound.set(challenge, keyBytes.length);
const reportData = new Uint8Array(await sha512(bound));
const claims = {
mrenclave: this.mrenclave,
mrsigner: this.mrsigner,
isv_prod_id: this.isvProdId,
isv_svn: this.isvSvn,
tcb_status: this.tcbStatus,
report_data: bytesToHex(reportData),
};
return arrayBufferToBase64(stringToArrayBuffer(JSON.stringify(claims)));
}
/** Drop-in replacement for the HttpClient interface. */
client() {
const handler = async (url: string): Promise<HttpResponseLike> => {
const parsed = new URL(url);
if (parsed.pathname === '/pki/public_key') {
return {
statusCode: 200,
headers: {},
body: stringToArrayBuffer(this.pubkeyPem),
};
}
if (parsed.pathname === '/pki/attestation') {
this.attestationCalls += 1;
const challengeHex = parsed.searchParams.get('challenge');
this.seenChallenges.push(challengeHex);
if (!this.isAvailable) {
return jsonResponse(200, { is_available: false, reason: this.reason });
}
const challenge = challengeHex ? hexToBytes(challengeHex) : new Uint8Array(0);
return jsonResponse(200, {
is_available: true,
quote_b64: await this.quoteB64(challenge),
});
}
return jsonResponse(404, { detail: 'not found' });
};
return {
get: jest.fn((url: string) => handler(url)),
post: jest.fn((url: string) => handler(url)),
};
}
}
/** Plays the role of a real DCAP verifier: recovers claims from the quote. */
async function faithfulVerify(quote: Uint8Array): Promise<VerifiedQuote> {
const claims = JSON.parse(arrayBufferToString(quote.buffer as ArrayBuffer)) as Record<string, unknown>;
return {
mrenclave: String(claims.mrenclave),
mrsigner: String(claims.mrsigner),
isvProdId: Number(claims.isv_prod_id),
isvSvn: Number(claims.isv_svn),
tcbStatus: String(claims.tcb_status),
reportData: hexToBytes(String(claims.report_data)),
};
}
function makeOrchestrator(server: MockServer, policy: AttestationPolicy): SgxAttestationVerifier {
const orchestrator = new SgxAttestationVerifier({
routerUrl: 'https://router.test',
policy,
verifier: new CallableQuoteVerifier(faithfulVerify),
});
(orchestrator as unknown as { httpClient: unknown }).httpClient = server.client();
return orchestrator;
}
/** Run the §3 algorithm against the mock server at the "high" tier. */
async function ensure(
server: MockServer,
policy: AttestationPolicy,
tier = 'high'
): Promise<VerifiedQuote | null> {
return makeOrchestrator(server, policy).ensureVerified(server.pubkeyDer, tier);
}
let server: MockServer;
let warnSpy: jest.SpyInstance;
beforeEach(async () => {
server = new MockServer();
await server.init();
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
warnSpy.mockRestore();
});
// ---- crypto helpers --------------------------------------------------------
describe('crypto helpers', () => {
test('hex round-trips', () => {
const bytes = new Uint8Array([0x00, 0x0f, 0xff, 0xa5]);
expect(bytesToHex(bytes)).toBe('000fffa5');
expect(Array.from(hexToBytes('000fffa5'))).toEqual(Array.from(bytes));
});
test('hexToBytes rejects malformed input', () => {
expect(() => hexToBytes('abc')).toThrow('odd length');
expect(() => hexToBytes('zzzz')).toThrow('non-hex');
});
test('constantTimeEqual compares correctly', () => {
expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true);
expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]))).toBe(false);
expect(constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))).toBe(false);
});
});
// ---- policy ----------------------------------------------------------------
describe('AttestationPolicy', () => {
test('normalises pins to lowercase and applies defaults', () => {
const policy = new AttestationPolicy({
pinnedMrenclave: ['ABCDEF'],
pinnedMrsigner: 'FEDCBA',
});
expect(policy.pinnedMrenclave.has('abcdef')).toBe(true);
expect(policy.pinnedMrsigner).toBe('fedcba');
expect(Array.from(policy.allowedTcbStatuses)).toEqual(['UpToDate']);
expect(policy.requireAttestationForTier.has('high')).toBe(true);
expect(policy.requireAttestationForTier.has('maximum')).toBe(true);
expect(policy.enforce).toBe(false);
expect(policy.liveness).toBe(false);
});
});
// ---- orchestrator §3 algorithm ---------------------------------------------
describe('SgxAttestationVerifier', () => {
test('happy path returns the verified quote', async () => {
const policy = new AttestationPolicy({
pinnedMrenclave: [MRENCLAVE],
enforce: true,
});
const v = await ensure(server, policy);
expect(v).not.toBeNull();
expect(v!.mrenclave).toBe(MRENCLAVE);
expect(v!.tcbStatus).toBe('UpToDate');
});
test('standard tier skips attestation entirely', async () => {
const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true });
const v = await ensure(server, policy, 'standard');
expect(v).toBeNull();
expect(server.attestationCalls).toBe(0);
});
test('wrong key binding throws in enforce mode', async () => {
server.bindToWrongKey = true;
const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true });
await expect(ensure(server, policy)).rejects.toThrow(AttestationError);
await expect(ensure(server, policy)).rejects.toThrow('key binding mismatch');
});
test('wrong key binding warns and proceeds when enforce is off', async () => {
server.bindToWrongKey = true;
const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: false });
await expect(ensure(server, policy)).resolves.toBeNull();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('key binding mismatch'));
});
test('MRENCLAVE outside the pinned set fails', async () => {
server.mrenclave = 'c'.repeat(64);
const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true });
await expect(ensure(server, policy)).rejects.toThrow('not in pinned set');
});
test('disallowed TCB status fails', async () => {
server.tcbStatus = 'OutOfDate';
const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true });
await expect(ensure(server, policy)).rejects.toThrow("TCB status 'OutOfDate' not in allowed");
});
test('MRSIGNER, prod id and SVN checks are enforced', async () => {
const base = { pinnedMrenclave: [MRENCLAVE], enforce: true };
await expect(
ensure(server, new AttestationPolicy({ ...base, pinnedMrsigner: 'd'.repeat(64) }))
).rejects.toThrow('MRSIGNER');
await expect(
ensure(server, new AttestationPolicy({ ...base, pinnedProdId: 99 }))
).rejects.toThrow('isvProdId');
await expect(
ensure(server, new AttestationPolicy({ ...base, minIsvSvn: 10 }))
).rejects.toThrow('isvSvn 3 < minimum 10');
// All satisfied
await expect(
ensure(server, new AttestationPolicy({
...base,
pinnedMrsigner: MRSIGNER.toUpperCase(),
pinnedProdId: 1,
minIsvSvn: 3,
}))
).resolves.not.toBeNull();
});
test('warns when no MRENCLAVE is pinned', async () => {
await ensure(server, new AttestationPolicy({ enforce: true }));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('enclave identity is NOT verified'));
});
test('unavailable attestation throws in enforce mode', async () => {
server.isAvailable = false;
const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true });
await expect(ensure(server, policy)).rejects.toThrow('attestation unavailable: sgx not supported');
});
test('unavailable attestation warns and proceeds when enforce is off', async () => {
server.isAvailable = false;
const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE] });
await expect(ensure(server, policy)).resolves.toBeNull();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('attestation unavailable'));
});
test('missing quote verifier throws in enforce mode', async () => {
const orchestrator = new SgxAttestationVerifier({
routerUrl: 'https://router.test',
policy: new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true }),
});
(orchestrator as unknown as { httpClient: unknown }).httpClient = server.client();
await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high'))
.rejects.toThrow('no quote verifier configured');
});
test('liveness sends a fresh challenge and binds to it', async () => {
const policy = new AttestationPolicy({
pinnedMrenclave: [MRENCLAVE],
enforce: true,
liveness: true,
});
const orchestrator = makeOrchestrator(server, policy);
await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high')).resolves.not.toBeNull();
await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high')).resolves.not.toBeNull();
// Cache bypassed: both calls hit the server with distinct 32-byte challenges
expect(server.attestationCalls).toBe(2);
expect(server.seenChallenges).toHaveLength(2);
expect(server.seenChallenges[0]).toHaveLength(64);
expect(server.seenChallenges[0]).not.toBe(server.seenChallenges[1]);
});
test('session cache skips the second verification', async () => {
const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true });
const orchestrator = makeOrchestrator(server, policy);
await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high')).resolves.not.toBeNull();
// Second call is served from the session cache (null, no network)
await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high')).resolves.toBeNull();
expect(server.attestationCalls).toBe(1);
expect(server.seenChallenges[0]).toBeNull();
});
test('refuses to fetch attestation over plain HTTP', async () => {
const orchestrator = new SgxAttestationVerifier({
routerUrl: 'http://router.test',
policy: new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE] }),
verifier: new CallableQuoteVerifier(faithfulVerify),
});
(orchestrator as unknown as { httpClient: unknown }).httpClient = server.client();
// Throws regardless of enforce — a MITM-able channel defeats attestation
await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high'))
.rejects.toThrow('must be fetched over HTTPS');
});
test('allows plain HTTP when explicitly opted in', async () => {
const orchestrator = new SgxAttestationVerifier({
routerUrl: 'http://router.test',
policy: new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true }),
verifier: new CallableQuoteVerifier(faithfulVerify),
allowHttp: true,
});
(orchestrator as unknown as { httpClient: unknown }).httpClient = server.client();
await expect(orchestrator.ensureVerified(server.pubkeyDer, 'high')).resolves.not.toBeNull();
});
test('AttestationError is a SecurityError', async () => {
server.isAvailable = false;
const policy = new AttestationPolicy({ pinnedMrenclave: [MRENCLAVE], enforce: true });
await expect(ensure(server, policy)).rejects.toBeInstanceOf(SecurityError);
});
});
// ---- verifiers -------------------------------------------------------------
describe('CallableQuoteVerifier', () => {
test('accepts a sync callable', async () => {
const quote: VerifiedQuote = {
mrenclave: MRENCLAVE,
mrsigner: MRSIGNER,
isvProdId: 1,
isvSvn: 1,
tcbStatus: 'UpToDate',
reportData: new Uint8Array(64),
};
await expect(new CallableQuoteVerifier(() => quote).verify(new Uint8Array(0)))
.resolves.toEqual(quote);
});
test('rejects a callable that returns the wrong shape', async () => {
const bad = new CallableQuoteVerifier(() => ({ mrenclave: 'x' } as unknown as VerifiedQuote));
await expect(bad.verify(new Uint8Array(0)))
.rejects.toThrow('must return a VerifiedQuote');
});
});
describe('JwtQuoteVerifier', () => {
test('throws a helpful AttestationError when jose is not installed', async () => {
const verifier = new JwtQuoteVerifier('https://verify.test/quote', 'https://verify.test/jwks');
await expect(verifier.verify(new Uint8Array([1, 2, 3])))
.rejects.toThrow(/requires the 'jose' package/);
});
test('accepts an injected jose module (CommonJS + ESM-only jose)', async () => {
const claims = {
sgx_mrenclave: MRENCLAVE.toUpperCase(),
sgx_mrsigner: MRSIGNER,
sgx_isvprodid: 1,
sgx_isvsvn: '4',
tcb_status: 'UpToDate',
sgx_report_data: bytesToHex(new Uint8Array(64).fill(7)),
};
const jose = {
createRemoteJWKSet: jest.fn(() => ({ kid: 'stub' })),
jwtVerify: jest.fn(async () => ({ payload: claims })),
};
const verifier = new JwtQuoteVerifier(
'https://verify.test/quote',
'https://verify.test/jwks',
{ jose, issuer: 'https://verify.test', algorithms: ['ES256'] }
);
(verifier as unknown as { httpClient: unknown }).httpClient = {
post: jest.fn(async () => jsonResponse(200, { token: 'header.payload.sig' })),
get: jest.fn(),
};
const v = await verifier.verify(new Uint8Array([1, 2, 3]));
expect(v.mrenclave).toBe(MRENCLAVE); // normalised to lowercase
expect(v.isvSvn).toBe(4); // string claim coerced to int
expect(v.reportData).toHaveLength(64);
expect(jose.jwtVerify).toHaveBeenCalledWith(
'header.payload.sig',
expect.anything(),
expect.objectContaining({ issuer: 'https://verify.test', algorithms: ['ES256'] })
);
});
test('rejects a verifier response without a token', async () => {
const verifier = new JwtQuoteVerifier('https://verify.test/quote', 'https://verify.test/jwks', {
jose: { createRemoteJWKSet: jest.fn(), jwtVerify: jest.fn() },
});
(verifier as unknown as { httpClient: unknown }).httpClient = {
post: jest.fn(async () => jsonResponse(200, { nope: true })),
get: jest.fn(),
};
await expect(verifier.verify(new Uint8Array([1]))).rejects.toThrow("missing JWT 'token'");
});
test('surfaces a non-200 from the verification service', async () => {
const verifier = new JwtQuoteVerifier('https://verify.test/quote', 'https://verify.test/jwks', {
jose: { createRemoteJWKSet: jest.fn(), jwtVerify: jest.fn() },
});
(verifier as unknown as { httpClient: unknown }).httpClient = {
post: jest.fn(async () => jsonResponse(503, { detail: 'down' })),
get: jest.fn(),
};
await expect(verifier.verify(new Uint8Array([1]))).rejects.toThrow('returned HTTP 503');
});
});
// ---- end-to-end through SecureCompletionClient ------------------------------
describe('SecureCompletionClient with attestation', () => {
test('enforce failure never sends plaintext', async () => {
server.bindToWrongKey = true;
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keyRotationInterval: 0,
keySize: 2048,
attestationPolicy: new AttestationPolicy({
pinnedMrenclave: [MRENCLAVE],
enforce: true,
}),
quoteVerifier: new CallableQuoteVerifier(faithfulVerify),
});
const http = server.client();
(client as unknown as { httpClient: unknown }).httpClient = http;
(client as unknown as { attestation: { httpClient: unknown } }).attestation.httpClient = http;
await expect(
client.sendSecureRequest({ model: 'm', messages: [] }, 'pid', undefined, 'high')
).rejects.toThrow(AttestationError);
// The completion endpoint was never POSTed to
expect(http.post).not.toHaveBeenCalled();
client.dispose();
});
test('no attestation configured leaves behaviour unchanged', async () => {
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keyRotationInterval: 0,
keySize: 2048,
});
expect((client as unknown as { attestation?: unknown }).attestation).toBeUndefined();
const http = server.client();
(client as unknown as { httpClient: unknown }).httpClient = http;
// Reaches the router (mock returns 404 for the completion endpoint) —
// i.e. no attestation step ran and no attestation request was made.
await expect(
client.sendSecureRequest({ model: 'm', messages: [] }, 'pid', undefined, 'high')
).rejects.toThrow('Endpoint not found');
expect(server.attestationCalls).toBe(0);
client.dispose();
});
});

View file

@ -0,0 +1,213 @@
/**
* Unit tests for error-type fidelity with the Python SDK.
*
* Python distinguishes malformed *data* (ValueError) from a failed integrity
* check (SecurityError). The JS port previously collapsed both into
* SecurityError, which hid real bugs behind a security-sounding message.
* Malformed data now surfaces as a plain Error; only genuine crypto failures
* stay opaque.
*/
import { SecureCompletionClient } from '../../src/core/SecureCompletionClient';
import { SecurityError, APIConnectionError } from '../../src/errors';
import { AESEncryption } from '../../src/core/crypto/encryption';
import { RSAOperations } from '../../src/core/crypto/rsa';
import {
arrayBufferToBase64,
stringToArrayBuffer,
} from '../../src/core/crypto/utils';
const TAG_LENGTH = 16;
/** Build a wire-format encrypted package addressed to `pubPem`. */
async function sealTo(pubPem: string, plaintext: Uint8Array): Promise<ArrayBuffer> {
const aes = new AESEncryption();
const rsa = new RSAOperations();
const aesKey = await aes.generateKey();
const rawKey = await aes.exportKey(aesKey);
const { ciphertext, nonce } = await aes.encrypt(
plaintext.buffer.slice(
plaintext.byteOffset,
plaintext.byteOffset + plaintext.byteLength
) as ArrayBuffer,
aesKey
);
// Web Crypto appends the GCM tag; the wire format keeps them separate
const combined = new Uint8Array(ciphertext);
const ct = combined.slice(0, combined.length - TAG_LENGTH);
const tag = combined.slice(combined.length - TAG_LENGTH);
const serverKey = await rsa.importPublicKey(pubPem);
const encryptedAesKey = await rsa.encryptKey(rawKey, serverKey);
return stringToArrayBuffer(JSON.stringify({
version: '1.0',
algorithm: 'hybrid-aes256-rsa4096',
encrypted_payload: {
ciphertext: arrayBufferToBase64(ct.buffer),
nonce: arrayBufferToBase64(nonce),
tag: arrayBufferToBase64(tag.buffer),
},
encrypted_aes_key: arrayBufferToBase64(encryptedAesKey),
key_algorithm: 'RSA-OAEP-SHA256',
payload_algorithm: 'AES-256-GCM',
}));
}
function newClient(): SecureCompletionClient {
return new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
});
}
async function withKeys(client: SecureCompletionClient): Promise<string> {
await client.generateKeys();
return (client as unknown as {
keyManager: { getPublicKeyPEM: () => Promise<string> };
}).keyManager.getPublicKeyPEM();
}
describe('decryptResponse error types', () => {
test('authenticated-but-unparseable plaintext is a format error, not a security error', async () => {
const client = newClient();
const pubPem = await withKeys(client);
const sealed = await sealTo(pubPem, new TextEncoder().encode('not json at all'));
const error = await client.decryptResponse(sealed, 'pid').catch((e: unknown) => e);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(SecurityError);
expect((error as Error).message).toBe('Decrypted response is not valid JSON');
client.dispose();
});
test('valid JSON of the wrong shape is reported as a schema failure', async () => {
const client = newClient();
const pubPem = await withKeys(client);
const sealed = await sealTo(pubPem, new TextEncoder().encode('{"not":"a completion"}'));
await expect(client.decryptResponse(sealed, 'pid'))
.rejects.toThrow('does not conform to expected schema');
client.dispose();
});
test('a tampered ciphertext stays opaque', async () => {
const client = newClient();
const pubPem = await withKeys(client);
const sealed = await sealTo(pubPem, new TextEncoder().encode('{"a":1}'));
// Flip a byte of the ciphertext so GCM authentication fails
const pkg = JSON.parse(new TextDecoder().decode(sealed)) as {
encrypted_payload: { ciphertext: string };
};
const raw = Buffer.from(pkg.encrypted_payload.ciphertext, 'base64');
raw[0] ^= 0xff;
pkg.encrypted_payload.ciphertext = raw.toString('base64');
const error = await client
.decryptResponse(stringToArrayBuffer(JSON.stringify(pkg)), 'pid')
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(SecurityError);
// No detail about which stage failed
expect((error as Error).message)
.toBe('Decryption failed: integrity check or authentication failed');
client.dispose();
});
test('decrypting without keys reports the missing key, not an integrity failure', async () => {
const client = newClient();
const other = newClient();
const pubPem = await withKeys(other);
const sealed = await sealTo(pubPem, new TextEncoder().encode('{"a":1}'));
const error = await client.decryptResponse(sealed, 'pid').catch((e: unknown) => e);
expect(error).toBeInstanceOf(SecurityError);
expect((error as Error).message).toMatch(/Private key not initialized/);
client.dispose();
other.dispose();
});
test('a malformed package is rejected before any crypto runs', async () => {
const client = newClient();
await withKeys(client);
await expect(
client.decryptResponse(stringToArrayBuffer('{"version":"1.0"}'), 'pid')
).rejects.toThrow('Missing required field');
client.dispose();
});
});
describe('fetchServerPublicKey error types', () => {
function clientWithResponse(response: { statusCode: number; body: ArrayBuffer }) {
const client = newClient();
(client as unknown as { httpClient: unknown }).httpClient = {
get: jest.fn(async () => ({ ...response, headers: {} })),
post: jest.fn(),
};
return client;
}
test('a non-200 is a server-response problem, not a connection problem', async () => {
const client = clientWithResponse({
statusCode: 500,
body: stringToArrayBuffer('boom'),
});
const error = await client.fetchServerPublicKey().catch((e: unknown) => e);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(APIConnectionError);
expect((error as Error).message).toMatch(/HTTP 500/);
client.dispose();
});
test('an unparseable key is a format problem, not a connection problem', async () => {
const client = clientWithResponse({
statusCode: 200,
body: stringToArrayBuffer('-----BEGIN PUBLIC KEY-----\nnonsense\n-----END PUBLIC KEY-----'),
});
const error = await client.fetchServerPublicKey().catch((e: unknown) => e);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(APIConnectionError);
expect((error as Error).message).toBe('Server returned invalid public key format');
client.dispose();
});
test('a transport failure is still a connection error', async () => {
const client = newClient();
(client as unknown as { httpClient: unknown }).httpClient = {
get: jest.fn(async () => { throw new Error('ECONNREFUSED'); }),
post: jest.fn(),
};
await expect(client.fetchServerPublicKey()).rejects.toBeInstanceOf(APIConnectionError);
client.dispose();
});
});
describe('defaults', () => {
test('request timeout matches the Python SDK (900 s)', () => {
const client = newClient();
expect((client as unknown as { requestTimeout: number }).requestTimeout).toBe(900000);
client.dispose();
});
test('an explicit timeout still wins', () => {
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keyRotationInterval: 0,
timeout: 5000,
});
expect((client as unknown as { requestTimeout: number }).requestTimeout).toBe(5000);
client.dispose();
});
});

View file

@ -0,0 +1,182 @@
/**
* Unit tests for key persistence vs. ephemeral key handling.
*
* Mirrors the Python SDK's `key_dir` semantics: omitting it (the default)
* generates a key pair in memory for this session only and never writes to
* disk; supplying it reuses or creates a pair in that directory.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { SecureCompletionClient } from '../../src/core/SecureCompletionClient';
/** Drive key initialisation without any network traffic. */
async function initKeys(client: SecureCompletionClient): Promise<void> {
await (client as unknown as { ensureKeys: () => Promise<void> }).ensureKeys();
}
function publicKeyOf(client: SecureCompletionClient): Promise<string> {
return (client as unknown as {
keyManager: { getPublicKeyPEM: () => Promise<string> };
}).keyManager.getPublicKeyPEM();
}
/**
* Watch for any attempt to write keys to disk.
*
* Asserting on the filesystem alone is unreliable here a stale `client_keys/`
* from before ephemeral keys became the default would make such a check pass or
* fail for the wrong reason. This asserts the intent directly.
*/
function watchSaves(client: SecureCompletionClient): jest.SpyInstance {
const keyManager = (client as unknown as { keyManager: object }).keyManager;
return jest.spyOn(keyManager as { saveKeys: (...args: never[]) => Promise<void> }, 'saveKeys');
}
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nomyo-keys-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('ephemeral keys (default)', () => {
test('writes nothing to disk when no keyDir is configured', async () => {
const cwdBefore = fs.readdirSync(process.cwd());
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
});
const saveSpy = watchSaves(client);
await initKeys(client);
expect(saveSpy).not.toHaveBeenCalled();
// And no new directory materialised in the working directory
expect(fs.readdirSync(process.cwd())).toEqual(cwdBefore);
client.dispose();
});
test('each client gets its own key pair', async () => {
const a = new SecureCompletionClient({
routerUrl: 'https://router.test', keySize: 2048, keyRotationInterval: 0,
});
const b = new SecureCompletionClient({
routerUrl: 'https://router.test', keySize: 2048, keyRotationInterval: 0,
});
await initKeys(a);
await initKeys(b);
expect(await publicKeyOf(a)).not.toBe(await publicKeyOf(b));
a.dispose();
b.dispose();
});
test('keyDir: null is treated as ephemeral', async () => {
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
keyDir: null,
});
await initKeys(client);
expect(fs.readdirSync(tmpDir)).toEqual([]);
client.dispose();
});
test('key rotation stays in memory', async () => {
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
});
await initKeys(client);
const before = await publicKeyOf(client);
const saveSpy = watchSaves(client);
await (client as unknown as { rotateKeys: () => Promise<void> }).rotateKeys();
// Rotated to a genuinely new pair, still with nothing written to disk
expect(await publicKeyOf(client)).not.toBe(before);
expect(saveSpy).not.toHaveBeenCalled();
client.dispose();
});
});
describe('persistent keys (explicit keyDir)', () => {
test('generates and saves a key pair on first use', async () => {
const keyDir = path.join(tmpDir, 'keys');
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
keyDir,
});
await initKeys(client);
expect(fs.existsSync(path.join(keyDir, 'private_key.pem'))).toBe(true);
expect(fs.existsSync(path.join(keyDir, 'public_key.pem'))).toBe(true);
client.dispose();
});
test('private key is written owner-only (0600)', async () => {
const keyDir = path.join(tmpDir, 'keys');
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
keyDir,
});
await initKeys(client);
const mode = fs.statSync(path.join(keyDir, 'private_key.pem')).mode & 0o777;
expect(mode).toBe(0o600);
client.dispose();
});
test('reuses the existing key pair across clients', async () => {
const keyDir = path.join(tmpDir, 'keys');
const config = {
routerUrl: 'https://router.test',
keySize: 2048 as const,
keyRotationInterval: 0,
keyDir,
};
const first = new SecureCompletionClient(config);
await initKeys(first);
const firstKey = await publicKeyOf(first);
first.dispose();
const second = new SecureCompletionClient(config);
await initKeys(second);
expect(await publicKeyOf(second)).toBe(firstKey);
second.dispose();
});
test('rotation persists into the configured directory', async () => {
const keyDir = path.join(tmpDir, 'keys');
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
keyDir,
});
await initKeys(client);
const before = fs.readFileSync(path.join(keyDir, 'public_key.pem'), 'utf-8');
await (client as unknown as { rotateKeys: () => Promise<void> }).rotateKeys();
const after = fs.readFileSync(path.join(keyDir, 'public_key.pem'), 'utf-8');
expect(after).not.toBe(before);
expect(after.trim()).toBe((await publicKeyOf(client)).trim());
client.dispose();
});
});

249
tests/unit/memory.test.ts Normal file
View file

@ -0,0 +1,249 @@
/**
* Unit tests for secure memory handling.
*
* Mirrors Python's SecureBuffer/secure_bytearray lifecycle: lock on entry,
* then zero *before* unlocking on exit, with every step best-effort so that a
* platform without mlock still gets zeroing.
*/
import {
SecureByteContext,
createSecureMemory,
getMemoryProtectionInfo,
disableSecureMemory,
enableSecureMemory,
SecureMemory,
} from '../../src/core/memory/secure';
import { NodeSecureMemory } from '../../src/core/memory/node';
import { BrowserSecureMemory } from '../../src/core/memory/browser';
/** A SecureMemory stub that records the order of operations. */
function recordingMemory(overrides: Partial<SecureMemory> = {}) {
const calls: string[] = [];
const impl: SecureMemory = {
zeroMemory: jest.fn((data: ArrayBuffer) => {
calls.push('zero');
new Uint8Array(data).fill(0);
}),
lockMemory: jest.fn(() => {
calls.push('lock');
return true;
}),
unlockMemory: jest.fn(() => {
calls.push('unlock');
return true;
}),
getProtectionInfo: jest.fn(() => ({
canLock: true,
isPlatformSecure: true,
method: 'mlock' as const,
})),
...overrides,
};
return { impl, calls };
}
function contextWith(data: ArrayBuffer, memory: SecureMemory, useSecure = true, lock = true) {
const ctx = new SecureByteContext(data, useSecure, lock);
(ctx as unknown as { secureMemory: SecureMemory }).secureMemory = memory;
return ctx;
}
function filledBuffer(byte = 0xab, size = 32): ArrayBuffer {
const buf = new ArrayBuffer(size);
new Uint8Array(buf).fill(byte);
return buf;
}
describe('SecureByteContext lifecycle', () => {
test('locks before use and zeroes before unlocking', async () => {
const { impl, calls } = recordingMemory();
const buf = filledBuffer();
await contextWith(buf, impl).use(async (data) => {
calls.push('use');
// Still intact while in scope
expect(new Uint8Array(data).every(b => b === 0xab)).toBe(true);
});
// Zeroing must happen while the pages are still locked
expect(calls).toEqual(['lock', 'use', 'zero', 'unlock']);
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
test('zeroes and unlocks even when the callback throws', async () => {
const { impl, calls } = recordingMemory();
const buf = filledBuffer();
await expect(
contextWith(buf, impl).use(async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');
expect(calls).toEqual(['lock', 'zero', 'unlock']);
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
test('a failed lock still zeroes, and does not unlock', async () => {
const { impl, calls } = recordingMemory({
lockMemory: jest.fn(() => false),
});
const buf = filledBuffer();
const ctx = contextWith(buf, impl);
await ctx.use(async () => undefined);
expect(ctx.locked).toBe(false);
expect(calls).toEqual(['zero']);
expect(impl.unlockMemory).not.toHaveBeenCalled();
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
test('a throwing lockMemory degrades to zeroing', async () => {
const { impl } = recordingMemory({
lockMemory: jest.fn(() => { throw new Error('mlock exploded'); }),
});
const buf = filledBuffer();
await expect(contextWith(buf, impl).use(async () => 'ok')).resolves.toBe('ok');
expect(impl.zeroMemory).toHaveBeenCalled();
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
test('a throwing zeroMemory does not mask the callback error', async () => {
const { impl } = recordingMemory({
zeroMemory: jest.fn(() => { throw new Error('zero exploded'); }),
});
await expect(
contextWith(filledBuffer(), impl).use(async () => {
throw new Error('original');
})
).rejects.toThrow('original');
});
test('lock: false skips locking but still zeroes', async () => {
const { impl, calls } = recordingMemory();
const buf = filledBuffer();
await contextWith(buf, impl, true, false).use(async () => undefined);
expect(calls).toEqual(['zero']);
expect(impl.lockMemory).not.toHaveBeenCalled();
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
test('useSecure: false leaves the buffer untouched', async () => {
const { impl, calls } = recordingMemory();
const buf = filledBuffer();
await contextWith(buf, impl, false).use(async () => undefined);
expect(calls).toEqual([]);
expect(new Uint8Array(buf).every(b => b === 0xab)).toBe(true);
});
test('locked reflects state across the lifecycle', async () => {
const { impl } = recordingMemory();
const ctx = contextWith(filledBuffer(), impl);
expect(ctx.locked).toBe(false);
await ctx.use(async () => {
expect(ctx.locked).toBe(true);
});
expect(ctx.locked).toBe(false);
});
test('honours the global disable/enable flag', async () => {
disableSecureMemory();
try {
const buf = filledBuffer();
// No explicit useSecure: falls back to the global flag
const ctx = new SecureByteContext(buf);
const { impl } = recordingMemory();
(ctx as unknown as { secureMemory: SecureMemory }).secureMemory = impl;
await ctx.use(async () => undefined);
expect(new Uint8Array(buf).every(b => b === 0xab)).toBe(true);
} finally {
enableSecureMemory();
}
});
});
describe('NodeSecureMemory', () => {
const memory = new NodeSecureMemory();
test('lock/unlock round-trips or degrades cleanly', () => {
const buf = filledBuffer(0x11, 4096);
const locked = memory.lockMemory(buf);
// Whatever the platform allows, the result must agree with what we report
expect(locked).toBe(getMemoryProtectionInfo().canLock);
if (locked) {
expect(memory.unlockMemory(buf)).toBe(true);
}
});
test('zeroMemory clears the buffer', () => {
const buf = filledBuffer(0xff, 64);
memory.zeroMemory(buf);
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
test('reported protection info is self-consistent', () => {
const info = memory.getProtectionInfo();
// canLock is probed, so it must never disagree with the method claimed
expect(info.method).toBe(info.canLock ? 'mlock' : 'zero-only');
expect(info.isPlatformSecure).toBe(info.canLock);
expect(info.platform).toBe(process.platform);
if (info.canLock) {
expect(info.hasSecureZeroing).toBe(true);
expect(info.pageSize).toBeGreaterThan(0);
}
});
test('claims mlock only when locking genuinely works', () => {
const info = memory.getProtectionInfo();
const buf = filledBuffer(0x22, 4096);
const locked = memory.lockMemory(buf);
if (locked) memory.unlockMemory(buf);
expect(info.method === 'mlock').toBe(locked);
});
});
describe('BrowserSecureMemory', () => {
const memory = new BrowserSecureMemory();
test('never claims to lock', () => {
expect(memory.lockMemory(filledBuffer())).toBe(false);
expect(memory.unlockMemory(filledBuffer())).toBe(false);
const info = memory.getProtectionInfo();
expect(info.canLock).toBe(false);
expect(info.method).toBe('zero-only');
expect(info.platform).toBe('browser');
});
test('still zeroes', () => {
const buf = filledBuffer(0x7f);
memory.zeroMemory(buf);
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
});
describe('createSecureMemory', () => {
test('returns an implementation satisfying the full interface', () => {
const memory = createSecureMemory();
expect(typeof memory.zeroMemory).toBe('function');
expect(typeof memory.lockMemory).toBe('function');
expect(typeof memory.unlockMemory).toBe('function');
expect(typeof memory.getProtectionInfo).toBe('function');
});
});

View file

@ -9,11 +9,15 @@
"declaration": true,
"declarationDir": "./dist/types",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"types": [
"node"
],
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,