@nanogpt/private-mode 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,8 @@ const response = await client.chat.completions.create({
32
32
 
33
33
  The local proxy verifies TEE attestation, encrypts request bodies with EHBP, sends ciphertext through NanoGPT, decrypts encrypted responses locally, and returns normal OpenAI JSON to the calling app.
34
34
 
35
+ After a long idle period (five minutes by default), the proxy re-verifies attestation and creates a fresh encrypted transport before the next request. This makes laptop sleep/resume safe without requiring a proxy restart. Set `NANOGPT_PRIVATE_CLIENT_IDLE_RESET_MS` to a positive millisecond value to adjust the idle threshold.
36
+
35
37
  NanoGPT can see account identity, selected private model, selected TEE target metadata, timing, sizes, status, and usage metadata. NanoGPT cannot read the prompt or completion body for supported private models.
36
38
 
37
39
  NanoGPT's web app can also use these models without running this local proxy. Select an eligible Private Mode model and use the Private Mode control in the model picker. This package is for API clients, CLIs, agents, and other OpenAI-compatible tools.
@@ -0,0 +1,131 @@
1
+ const DEFAULT_SECURE_CLIENT_IDLE_RESET_MS = 5 * 60 * 1000;
2
+
3
+ function readSecureClientIdleResetMs() {
4
+ const parsed = Number(process.env.NANOGPT_PRIVATE_CLIENT_IDLE_RESET_MS || '');
5
+ if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
6
+ return DEFAULT_SECURE_CLIENT_IDLE_RESET_MS;
7
+ }
8
+
9
+ export function createSecureState(apiBase, options = {}) {
10
+ const idleResetMs = Number.isFinite(options.idleResetMs) && options.idleResetMs > 0
11
+ ? Math.floor(options.idleResetMs)
12
+ : readSecureClientIdleResetMs();
13
+ const loadTinfoil = options.loadTinfoil || (() => import('tinfoil'));
14
+ const now = options.now || Date.now;
15
+ let clientState = null;
16
+ let verificationDocument = null;
17
+ let verificationError = null;
18
+
19
+ async function getClient(userCacheSecret = clientState?.userCacheSecret) {
20
+ if (!userCacheSecret) {
21
+ throw new Error('NanoGPT preflight must establish cache isolation before attestation.');
22
+ }
23
+ const nowMs = now();
24
+ const idleMs = clientState ? nowMs - clientState.lastUsedAtMs : 0;
25
+ if (
26
+ clientState?.userCacheSecret === userCacheSecret
27
+ && idleMs >= 0
28
+ && idleMs < idleResetMs
29
+ ) {
30
+ clientState.lastUsedAtMs = nowMs;
31
+ return clientState.promise;
32
+ }
33
+
34
+ // Re-attest after a long idle period or a wall-clock discontinuity. This
35
+ // covers laptops resuming after sleep without mutating a client that may
36
+ // still be finishing an older in-flight request.
37
+ verificationDocument = null;
38
+ verificationError = null;
39
+ const nextState = {
40
+ client: null,
41
+ lastUsedAtMs: nowMs,
42
+ promise: null,
43
+ userCacheSecret,
44
+ };
45
+ const promise = Promise.resolve(loadTinfoil()).then(async ({ SecureClient }) => {
46
+ const baseURL = `${apiBase}/api/v1/private/tinfoil/`;
47
+ const client = new SecureClient({
48
+ baseURL,
49
+ attestationBundleURL: `${apiBase}/api/v1/private/tinfoil`,
50
+ transport: 'ehbp',
51
+ userCacheSecret,
52
+ });
53
+ await client.ready();
54
+ nextState.client = client;
55
+ if (clientState === nextState) {
56
+ verificationDocument = client.getVerificationDocument();
57
+ verificationError = null;
58
+ }
59
+ return client;
60
+ });
61
+ nextState.promise = promise;
62
+ clientState = nextState;
63
+
64
+ try {
65
+ return await promise;
66
+ } catch (error) {
67
+ if (clientState === nextState) {
68
+ clientState = null;
69
+ verificationError = error;
70
+ }
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ return {
76
+ getClient,
77
+ invalidateClient(client) {
78
+ if (!clientState) return false;
79
+ if (client && clientState.client !== client) return false;
80
+ clientState = null;
81
+ verificationDocument = null;
82
+ verificationError = null;
83
+ return true;
84
+ },
85
+ getVerificationState() {
86
+ return {
87
+ verified: verificationDocument?.securityVerified === true,
88
+ verificationDocument,
89
+ error: verificationError ? String(verificationError.message || verificationError) : null,
90
+ };
91
+ },
92
+ };
93
+ }
94
+
95
+ export async function isMissingEncryptedBodyHeaderResponse(response) {
96
+ if (response?.status !== 400) return false;
97
+ try {
98
+ const data = await response.clone().json();
99
+ return data?.error?.code === 'missing_encrypted_body_header';
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ export async function fetchWithSecureClientRecovery({
106
+ fetchWithClient,
107
+ secureState,
108
+ shouldInvalidateError = () => true,
109
+ userCacheSecret,
110
+ }) {
111
+ let client;
112
+ try {
113
+ for (let attempt = 0; attempt < 2; attempt += 1) {
114
+ client = await secureState.getClient(userCacheSecret);
115
+ const response = await fetchWithClient(client);
116
+ if (attempt === 0 && await isMissingEncryptedBodyHeaderResponse(response)) {
117
+ await response.body?.cancel().catch(() => undefined);
118
+ secureState.invalidateClient(client);
119
+ continue;
120
+ }
121
+ return response;
122
+ }
123
+ } catch (error) {
124
+ if (client && shouldInvalidateError(error)) {
125
+ secureState.invalidateClient(client);
126
+ }
127
+ throw error;
128
+ }
129
+
130
+ throw new Error('Private Mode secure-client recovery exhausted unexpectedly.');
131
+ }
package/lib/server.js CHANGED
@@ -11,6 +11,10 @@ import {
11
11
  normalizePrivateModeUpstreamErrorMessage,
12
12
  readErrorMessage,
13
13
  } from './serverErrorNormalization.js';
14
+ import {
15
+ createSecureState,
16
+ fetchWithSecureClientRecovery,
17
+ } from './secureClientLifecycle.js';
14
18
  import { buildPrivateModeStatusContract } from './statusContract.js';
15
19
 
16
20
  const MODELS = JSON.parse(
@@ -150,56 +154,6 @@ function copyLocalHeaders(req) {
150
154
  return headers;
151
155
  }
152
156
 
153
- function createSecureState(apiBase, apiKey) {
154
- let clientState = null;
155
- let verificationDocument = null;
156
- let verificationError = null;
157
-
158
- async function getClient(userCacheSecret = clientState?.userCacheSecret) {
159
- if (!userCacheSecret) {
160
- throw new Error('NanoGPT preflight must establish cache isolation before attestation.');
161
- }
162
- if (clientState?.userCacheSecret === userCacheSecret) {
163
- return clientState.promise;
164
- }
165
-
166
- const promise = import('tinfoil').then(async ({ SecureClient }) => {
167
- const baseURL = `${apiBase}/api/v1/private/tinfoil/`;
168
- const client = new SecureClient({
169
- baseURL,
170
- attestationBundleURL: `${apiBase}/api/v1/private/tinfoil`,
171
- transport: 'ehbp',
172
- userCacheSecret,
173
- });
174
- await client.ready();
175
- verificationDocument = client.getVerificationDocument();
176
- verificationError = null;
177
- return client;
178
- });
179
- const nextState = { userCacheSecret, promise };
180
- clientState = nextState;
181
-
182
- try {
183
- return await promise;
184
- } catch (error) {
185
- if (clientState === nextState) clientState = null;
186
- verificationError = error;
187
- throw error;
188
- }
189
- }
190
-
191
- return {
192
- getClient,
193
- getVerificationState() {
194
- return {
195
- verified: verificationDocument?.securityVerified === true,
196
- verificationDocument,
197
- error: verificationError ? String(verificationError.message || verificationError) : null,
198
- };
199
- },
200
- };
201
- }
202
-
203
157
  async function runPreflight({ apiBase, apiKey, model, req, requestBodyBytes }) {
204
158
  const preflightBody = { model: model.id };
205
159
  if (Number.isSafeInteger(requestBodyBytes) && requestBodyBytes >= 0) {
@@ -334,21 +288,25 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
334
288
  });
335
289
 
336
290
  try {
337
- const client = await secureState.getClient(preflight.cacheScope);
338
- response = await client.fetch(`${apiBase}/api/v1/private/tinfoil/v1/chat/completions`, {
339
- method: 'POST',
340
- headers: {
341
- authorization: `Bearer ${apiKey}`,
342
- 'content-type': 'application/json',
343
- accept: privateStreamRequested ? 'text/event-stream' : 'application/json',
344
- 'x-nanogpt-private-model': model.id,
345
- 'x-nanogpt-private-stream': privateStreamRequested ? 'true' : 'false',
346
- 'x-nanogpt-private-cache-scope': buildPrivateModeCacheScopeProof(preflight.cacheScope),
347
- 'x-query-source': 'api',
348
- ...copyLocalHeaders(req),
349
- },
350
- body: privateRequestBody,
351
- signal: upstreamAbortController.signal,
291
+ response = await fetchWithSecureClientRecovery({
292
+ secureState,
293
+ userCacheSecret: preflight.cacheScope,
294
+ shouldInvalidateError: () => !upstreamAbortController.signal.aborted,
295
+ fetchWithClient: (client) => client.fetch(`${apiBase}/api/v1/private/tinfoil/v1/chat/completions`, {
296
+ method: 'POST',
297
+ headers: {
298
+ authorization: `Bearer ${apiKey}`,
299
+ 'content-type': 'application/json',
300
+ accept: privateStreamRequested ? 'text/event-stream' : 'application/json',
301
+ 'x-nanogpt-private-model': model.id,
302
+ 'x-nanogpt-private-stream': privateStreamRequested ? 'true' : 'false',
303
+ 'x-nanogpt-private-cache-scope': buildPrivateModeCacheScopeProof(preflight.cacheScope),
304
+ 'x-query-source': 'api',
305
+ ...copyLocalHeaders(req),
306
+ },
307
+ body: privateRequestBody,
308
+ signal: upstreamAbortController.signal,
309
+ }),
352
310
  });
353
311
  } catch (error) {
354
312
  responseComplete = true;
@@ -448,7 +406,7 @@ function handleOptions(req, res, corsHeaders) {
448
406
 
449
407
  export async function startPrivateModeProxy(options) {
450
408
  const apiBase = normalizeApiBase(options.apiBase);
451
- const secureState = createSecureState(apiBase, options.apiKey);
409
+ const secureState = createSecureState(apiBase);
452
410
  const localBase = `http://${options.host}:${options.port}/v1`;
453
411
  const originPolicy = buildPrivateModeOriginPolicy(options);
454
412
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanogpt/private-mode",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "OpenAI-compatible localhost proxy for NanoGPT Private Mode.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -15,6 +15,7 @@
15
15
  "lib/cacheScope.js",
16
16
  "lib/originPolicy.js",
17
17
  "lib/requestTransforms.js",
18
+ "lib/secureClientLifecycle.js",
18
19
  "lib/server.js",
19
20
  "lib/serverErrorNormalization.js",
20
21
  "lib/statusContract.js",
@@ -22,9 +23,6 @@
22
23
  "models",
23
24
  "README.md"
24
25
  ],
25
- "scripts": {
26
- "start": "node ./bin/nanogpt-private-mode.js"
27
- },
28
26
  "dependencies": {
29
27
  "ai": "6.0.220",
30
28
  "tinfoil": "1.1.12"
@@ -32,5 +30,8 @@
32
30
  "engines": {
33
31
  "node": ">=22"
34
32
  },
35
- "license": "MIT"
36
- }
33
+ "license": "MIT",
34
+ "scripts": {
35
+ "start": "node ./bin/nanogpt-private-mode.js"
36
+ }
37
+ }