privateer-agent 0.7.0 → 0.8.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/src/harbor/ipc.ts CHANGED
@@ -33,44 +33,82 @@ export interface IpcResponse {
33
33
 
34
34
  export type IpcHandler = (req: IpcRequest) => Promise<IpcResponse> | IpcResponse;
35
35
 
36
- // Start the harbor-side socket server. Returns the Server so the caller can close it.
37
- export function startIpcServer(handler: IpcHandler): Server {
36
+ // Probe whether a LIVE process is listening on the socket at `path`. Used as the
37
+ // single-instance test: a successful connect (or a slow-to-answer one) means a real
38
+ // harbor holds the lock; ECONNREFUSED/ENOENT means the socket file is stale (no
39
+ // listener behind it) and is safe to reclaim. Conservative — any ambiguous error
40
+ // resolves `true` so we never steal a path that might still be owned.
41
+ function probeExistingListener(path: string, timeoutMs = 1000): Promise<boolean> {
42
+ return new Promise((resolve) => {
43
+ const sock = createConnection(path);
44
+ const done = (live: boolean) => {
45
+ clearTimeout(timer);
46
+ try { sock.destroy(); } catch { /* already gone */ }
47
+ resolve(live);
48
+ };
49
+ const timer = setTimeout(() => done(true), timeoutMs); // slow to answer ⇒ assume live
50
+ sock.on("connect", () => done(true));
51
+ sock.on("error", (err: NodeJS.ErrnoException) => {
52
+ done(!(err.code === "ECONNREFUSED" || err.code === "ENOENT"));
53
+ });
54
+ });
55
+ }
56
+
57
+ // Start the harbor-side socket server. Resolves with the Server (so the caller can
58
+ // close it), or REJECTS with HarborAlreadyRunningError if a live harbor already owns
59
+ // the socket — the bind is the machine's single-instance lock. Two harbors under one
60
+ // ~/.privateer share a single routineRelayId(), so a second instance would collide on
61
+ // the relay and double-fire routines; refusing to start is the fix. A stale socket
62
+ // file (crash with no live listener) is detected and reclaimed, so recovery still works.
63
+ export function startIpcServer(handler: IpcHandler): Promise<Server> {
38
64
  const path = harborSocketPath();
39
- // A stale socket file from a previous crash would block bind; remove it first.
40
- if (existsSync(path)) {
41
- try {
42
- unlinkSync(path);
43
- } catch {
44
- /* ignore bind will surface a clearer error */
45
- }
46
- }
47
- const server = createServer((sock: Socket) => {
48
- let buf = "";
49
- sock.on("data", (chunk) => {
50
- buf += chunk.toString("utf8");
51
- const nl = buf.indexOf("\n");
52
- if (nl < 0) return; // wait for the full line
53
- const line = buf.slice(0, nl);
54
- void (async () => {
55
- let res: IpcResponse;
65
+ const build = (): Server =>
66
+ createServer((sock: Socket) => {
67
+ let buf = "";
68
+ sock.on("data", (chunk) => {
69
+ buf += chunk.toString("utf8");
70
+ const nl = buf.indexOf("\n");
71
+ if (nl < 0) return; // wait for the full line
72
+ const line = buf.slice(0, nl);
73
+ void (async () => {
74
+ let res: IpcResponse;
75
+ try {
76
+ res = await handler(JSON.parse(line) as IpcRequest);
77
+ } catch (err) {
78
+ res = { ok: false, message: err instanceof Error ? err.message : String(err) };
79
+ }
80
+ sock.end(JSON.stringify(res) + "\n");
81
+ })();
82
+ });
83
+ sock.on("error", () => sock.destroy());
84
+ });
85
+
86
+ return new Promise<Server>((resolve, reject) => {
87
+ // `reclaimed` guards a single stale-socket reclaim so a persistent bind failure
88
+ // can't loop. On EADDRINUSE we probe for a live listener rather than unlinking
89
+ // blindly (the old behavior, which let a second harbor silently steal the path).
90
+ const attempt = (reclaimed: boolean) => {
91
+ const server = build();
92
+ server.once("error", (err: NodeJS.ErrnoException) => {
93
+ if (err.code !== "EADDRINUSE") { reject(err); return; }
94
+ void probeExistingListener(path).then((live) => {
95
+ if (live) { reject(new HarborAlreadyRunningError()); return; }
96
+ if (reclaimed) { reject(err); return; } // already reclaimed once — give up
97
+ try { unlinkSync(path); } catch { /* ignore — retry surfaces a clearer error */ }
98
+ attempt(true);
99
+ });
100
+ });
101
+ server.listen(path, () => {
56
102
  try {
57
- res = await handler(JSON.parse(line) as IpcRequest);
58
- } catch (err) {
59
- res = { ok: false, message: err instanceof Error ? err.message : String(err) };
103
+ chmodSync(path, 0o600); // owner-only IPC endpoint
104
+ } catch {
105
+ /* non-POSIX best effort */
60
106
  }
61
- sock.end(JSON.stringify(res) + "\n");
62
- })();
63
- });
64
- sock.on("error", () => sock.destroy());
65
- });
66
- server.listen(path, () => {
67
- try {
68
- chmodSync(path, 0o600); // owner-only IPC endpoint
69
- } catch {
70
- /* non-POSIX — best effort */
71
- }
107
+ resolve(server);
108
+ });
109
+ };
110
+ attempt(false);
72
111
  });
73
- return server;
74
112
  }
75
113
 
76
114
  // Client side: send one request, resolve with the response. Rejects if the harbor
@@ -116,6 +154,16 @@ export class HarborNotRunningError extends Error {
116
154
  }
117
155
  }
118
156
 
157
+ // Thrown by startIpcServer when a live harbor already holds this machine's socket —
158
+ // i.e. a second instance is trying to start under the same ~/.privateer. The caller
159
+ // (runHarbor) treats this as a clean no-op exit, not a crash.
160
+ export class HarborAlreadyRunningError extends Error {
161
+ constructor() {
162
+ super("A Harbor is already running on this machine.");
163
+ this.name = "HarborAlreadyRunningError";
164
+ }
165
+ }
166
+
119
167
  // Convenience: is the harbor reachable right now?
120
168
  export async function harborIsRunning(): Promise<boolean> {
121
169
  try {
@@ -130,6 +130,168 @@ export const MCP_CATALOG: CatalogEntry[] = [
130
130
  args: ["-y", "@modelcontextprotocol/server-memory"],
131
131
  needs: "none",
132
132
  },
133
+
134
+ // ── Remote, browser-authorized (OAuth) ──────────────────────────────────────
135
+ {
136
+ id: "sentry",
137
+ name: "sentry",
138
+ label: "Sentry",
139
+ blurb: "Errors, issues, and releases. Sign in via browser.",
140
+ transport: "http",
141
+ url: "https://mcp.sentry.dev/mcp",
142
+ oauth: true,
143
+ needs: "oauth",
144
+ },
145
+ {
146
+ id: "atlassian",
147
+ name: "atlassian",
148
+ label: "Jira & Confluence",
149
+ blurb: "Atlassian issues and pages. Sign in via browser.",
150
+ transport: "http",
151
+ url: "https://mcp.atlassian.com/v1/sse",
152
+ oauth: true,
153
+ needs: "oauth",
154
+ },
155
+ {
156
+ id: "stripe",
157
+ name: "stripe",
158
+ label: "Stripe",
159
+ blurb: "Payments, customers, and invoices. Sign in via browser.",
160
+ transport: "http",
161
+ url: "https://mcp.stripe.com",
162
+ oauth: true,
163
+ needs: "oauth",
164
+ },
165
+ {
166
+ id: "asana",
167
+ name: "asana",
168
+ label: "Asana",
169
+ blurb: "Tasks and projects. Sign in via browser.",
170
+ transport: "http",
171
+ url: "https://mcp.asana.com/sse",
172
+ oauth: true,
173
+ needs: "oauth",
174
+ },
175
+
176
+ // ── Local, token-authorized ─────────────────────────────────────────────────
177
+ {
178
+ id: "brave-search",
179
+ name: "brave-search",
180
+ label: "Brave Search",
181
+ blurb: "Web and local search results.",
182
+ transport: "stdio",
183
+ command: "npx",
184
+ args: ["-y", "@modelcontextprotocol/server-brave-search"],
185
+ env: { BRAVE_API_KEY: "" },
186
+ needs: "token",
187
+ fill: "BRAVE_API_KEY",
188
+ credUrl: "https://brave.com/search/api/",
189
+ },
190
+ {
191
+ id: "google-maps",
192
+ name: "google-maps",
193
+ label: "Google Maps",
194
+ blurb: "Places, directions, and geocoding.",
195
+ transport: "stdio",
196
+ command: "npx",
197
+ args: ["-y", "@modelcontextprotocol/server-google-maps"],
198
+ env: { GOOGLE_MAPS_API_KEY: "" },
199
+ needs: "token",
200
+ fill: "GOOGLE_MAPS_API_KEY",
201
+ credUrl: "https://console.cloud.google.com/google/maps-apis/credentials",
202
+ },
203
+ {
204
+ id: "supabase",
205
+ name: "supabase",
206
+ label: "Supabase",
207
+ blurb: "Query and manage your Supabase project.",
208
+ transport: "stdio",
209
+ command: "npx",
210
+ args: ["-y", "@supabase/mcp-server-supabase@latest"],
211
+ env: { SUPABASE_ACCESS_TOKEN: "" },
212
+ needs: "token",
213
+ fill: "SUPABASE_ACCESS_TOKEN",
214
+ credUrl: "https://supabase.com/dashboard/account/tokens",
215
+ },
216
+ {
217
+ id: "figma",
218
+ name: "figma",
219
+ label: "Figma",
220
+ blurb: "Read designs, frames, and components.",
221
+ transport: "stdio",
222
+ command: "npx",
223
+ args: ["-y", "figma-developer-mcp", "--stdio"],
224
+ env: { FIGMA_API_KEY: "" },
225
+ needs: "token",
226
+ fill: "FIGMA_API_KEY",
227
+ credUrl: "https://www.figma.com/developers/api#access-tokens",
228
+ },
229
+ // ── Google Workspace (needs a Google Cloud OAuth client, then browser sign-in) ─
230
+ // One server (workspace-mcp) scoped per service via --tools. Runs on uv (uvx),
231
+ // not npx. Create an OAuth client in Google Cloud once and paste its ID + secret;
232
+ // the first request opens Google's consent screen on THIS machine.
233
+ {
234
+ id: "gmail",
235
+ name: "gmail",
236
+ label: "Gmail",
237
+ blurb: "Read, search, and send email. Google sign-in.",
238
+ transport: "stdio",
239
+ command: "uvx",
240
+ args: ["workspace-mcp", "--tools", "gmail"],
241
+ env: { GOOGLE_OAUTH_CLIENT_ID: "", GOOGLE_OAUTH_CLIENT_SECRET: "" },
242
+ needs: "token",
243
+ fill: "GOOGLE_OAUTH_CLIENT_ID",
244
+ credUrl: "https://console.cloud.google.com/apis/credentials",
245
+ },
246
+ {
247
+ id: "google-calendar",
248
+ name: "google-calendar",
249
+ label: "Google Calendar",
250
+ blurb: "Events and scheduling. Google sign-in.",
251
+ transport: "stdio",
252
+ command: "uvx",
253
+ args: ["workspace-mcp", "--tools", "calendar"],
254
+ env: { GOOGLE_OAUTH_CLIENT_ID: "", GOOGLE_OAUTH_CLIENT_SECRET: "" },
255
+ needs: "token",
256
+ fill: "GOOGLE_OAUTH_CLIENT_ID",
257
+ credUrl: "https://console.cloud.google.com/apis/credentials",
258
+ },
259
+ {
260
+ id: "google-drive",
261
+ name: "google-drive",
262
+ label: "Google Drive",
263
+ blurb: "Files and folders in your Drive. Google sign-in.",
264
+ transport: "stdio",
265
+ command: "uvx",
266
+ args: ["workspace-mcp", "--tools", "drive"],
267
+ env: { GOOGLE_OAUTH_CLIENT_ID: "", GOOGLE_OAUTH_CLIENT_SECRET: "" },
268
+ needs: "token",
269
+ fill: "GOOGLE_OAUTH_CLIENT_ID",
270
+ credUrl: "https://console.cloud.google.com/apis/credentials",
271
+ },
272
+ {
273
+ id: "google-docs",
274
+ name: "google-docs",
275
+ label: "Google Docs",
276
+ blurb: "Read and edit your documents. Google sign-in.",
277
+ transport: "stdio",
278
+ command: "uvx",
279
+ args: ["workspace-mcp", "--tools", "docs"],
280
+ env: { GOOGLE_OAUTH_CLIENT_ID: "", GOOGLE_OAUTH_CLIENT_SECRET: "" },
281
+ needs: "token",
282
+ fill: "GOOGLE_OAUTH_CLIENT_ID",
283
+ credUrl: "https://console.cloud.google.com/apis/credentials",
284
+ },
285
+ {
286
+ id: "sequential-thinking",
287
+ name: "sequential-thinking",
288
+ label: "Sequential Thinking",
289
+ blurb: "A step-by-step reasoning scratchpad.",
290
+ transport: "stdio",
291
+ command: "npx",
292
+ args: ["-y", "@modelcontextprotocol/server-sequential-thinking"],
293
+ needs: "none",
294
+ },
133
295
  ];
134
296
 
135
297
  export function catalogEntry(id: string): CatalogEntry | undefined {
@@ -0,0 +1,42 @@
1
+ // No-quarter: the moat fully lowered for a session — every action auto-approves
2
+ // with no prompt (dangerous shell, destructive tools, out-of-cwd, protected files).
3
+ // This is the single source of truth for that state; the gate reads it through
4
+ // ModeGate.getSkipAllPermissions, which sits above every other policy check.
5
+ //
6
+ // Two ways in, one state:
7
+ // 1. `privateer --no-quarter` at launch → PRIVATEER_NO_QUARTER=1 (see
8
+ // bin/privateer-launch.mjs), which seeds `active` below.
9
+ // 2. shift+tab in a live session → toggleNoQuarter(). This is the "step away from
10
+ // the keyboard" switch: flip it on and the agent runs to completion instead of
11
+ // stopping on the next approval prompt.
12
+ //
13
+ // Toggling MIRRORS the env var, because that's how the state reaches subagents: a
14
+ // pi-subagents child is a `pi` subprocess that inherits this process's env and reads
15
+ // PRIVATEER_NO_QUARTER in its own gate. Children spawned after a toggle therefore
16
+ // match the parent; ones already running keep the posture they started with.
17
+ //
18
+ // IMPORT-SAFETY: no Pi imports, no node builtins — safe to load from anywhere,
19
+ // including boot-ordered entrypoints (see boot.ts's ORDERING CONTRACT).
20
+
21
+ const ENV = "PRIVATEER_NO_QUARTER";
22
+
23
+ // Seeded from the launch flag so `--no-quarter` and the toggle share one state.
24
+ let active = process.env[ENV] === "1";
25
+
26
+ /** True while the gate is fully lowered for this session. */
27
+ export function noQuarterActive(): boolean {
28
+ return active;
29
+ }
30
+
31
+ /** Set the state (and mirror it to the env for future subagent children). Returns the new state. */
32
+ export function setNoQuarter(on: boolean): boolean {
33
+ active = on;
34
+ if (on) process.env[ENV] = "1";
35
+ else delete process.env[ENV];
36
+ return active;
37
+ }
38
+
39
+ /** Flip the state. Returns the new state. */
40
+ export function toggleNoQuarter(): boolean {
41
+ return setNoQuarter(!active);
42
+ }
@@ -22,6 +22,13 @@ import {
22
22
  } from "../auth/privateer.ts";
23
23
  import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
24
24
  import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
25
+ import {
26
+ sealedEnabled,
27
+ sealedProviderFor,
28
+ sealedShimBase,
29
+ ensureSealedShim,
30
+ attestSealed,
31
+ } from "./sealedShim.ts";
25
32
 
26
33
  // Seed/fallback catalog: registered synchronously so the account provider has real
27
34
  // models the instant it loads (before the live /api/models fetch resolves) — in
@@ -33,8 +40,9 @@ import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel }
33
40
  const DEFAULT_MODELS = [
34
41
  ACCOUNT_DEFAULT_MODEL_ID,
35
42
  ACCOUNT_NEAR_MODEL_ID,
36
- "anthropic/claude-sonnet-4.6",
37
- "openai/gpt-5.5",
43
+ "anthropic/claude-opus-5",
44
+ "anthropic/claude-sonnet-5",
45
+ "openai/gpt-5.6-sol",
38
46
  "deepseek/deepseek-v4-flash",
39
47
  ];
40
48
 
@@ -73,7 +81,9 @@ const VALID_TIERS = new Set<PrivacyTier>([
73
81
  // upgrades it to tee-verified live via attestation (accountPosture). Everything
74
82
  // else with no server signal is "standard": we don't assert ZDR we can't back.
75
83
  function tierFromPrefix(modelId: string): PrivacyTier {
76
- return modelId.startsWith("near/") || modelId.startsWith("tinfoil/") ? "tee-unverified" : "standard";
84
+ return modelId.startsWith("near/") || modelId.startsWith("tinfoil/") || modelId.startsWith("phala/")
85
+ ? "tee-unverified"
86
+ : "standard";
77
87
  }
78
88
 
79
89
  function normalizeTier(tier: string | undefined, modelId: string): PrivacyTier {
@@ -248,13 +258,26 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
248
258
  if (privateerChannel(modelId) === "zdr") {
249
259
  return { tier: "zdr-policy" };
250
260
  }
251
- // Honest labelling for the non-NEAR enclaves. Tinfoil and Phala publish real
252
- // attestations, but the server proxies the inference, so from here we cannot bind a
253
- // quote to the connection actually carrying our tokens only the account's word
254
- // that it did. That's `tee-unverified` (yellow "confidential compute, unconfirmed"),
255
- // never the green tee-verified we reserve for a quote we checked ourselves. A user
256
- // who wants the verified shield sets TINFOIL_API_KEY and runs `tinfoil/*` direct,
257
- // where pi-privacy attests the enclave client-side.
261
+ // Sealed (EHBP) path. When sealed mode is on and the model has a Node sealed
262
+ // client (tinfoil/*), inference goes through the blind relay with the body
263
+ // HPKE-sealed to the enclave, and we attest that enclave client-side with the SAME
264
+ // SecureClient that carries the tokens. A green ready() is a quote WE checked,
265
+ // bound to the HPKE key we seal to so it earns tee-verified. A failure stays
266
+ // tee-unverified with the reason surfaced (never a silent green). See
267
+ // docs/tee-privateer-tinfoil-ehbp.md.
268
+ const sealedProvider = sealedEnabled() ? sealedProviderFor(modelId) : null;
269
+ if (sealedProvider) {
270
+ const att = await attestSealed(sealedProvider);
271
+ return att.ok ? { tier: "tee-verified" } : { tier: "tee-unverified", error: att.error };
272
+ }
273
+ // Honest labelling for the non-NEAR enclaves without sealed mode. Tinfoil and Phala
274
+ // publish real attestations, but the server proxies the inference in cleartext, so
275
+ // from here we cannot bind a quote to the connection actually carrying our tokens —
276
+ // only the account's word that it did. That's `tee-unverified` (yellow "confidential
277
+ // compute, unconfirmed"), never the green tee-verified we reserve for a quote we
278
+ // checked ourselves. Turn on sealed mode (PRIVATEER_SEALED=1) for the verified
279
+ // shield, or set TINFOIL_API_KEY and run `tinfoil/*` direct (pi-privacy attests
280
+ // client-side over the TLS binding).
258
281
  if (!modelId.startsWith("near/")) {
259
282
  return { tier: "tee-unverified" };
260
283
  }
@@ -299,15 +322,40 @@ export function makeAccountProvider() {
299
322
  on?: (event: string, handler: (e: unknown, ctx: unknown) => void) => void;
300
323
  }): void => {
301
324
  if (typeof pi.registerProvider !== "function") return;
302
- const register = (ids: string[]): void =>
325
+ // A model entry, with a per-model baseUrl override for sealed models once the
326
+ // EHBP shim is listening: `tinfoil/*` then route through the loopback shim (which
327
+ // seals to the blind relay) instead of the cleartext `/api/agent/v1` proxy.
328
+ // Everything else keeps the provider baseUrl below. Until the shim is up (or when
329
+ // sealed mode is off) sealed models fall back to the cleartext path — and the
330
+ // badge stays honestly `tee-unverified` (see accountPosture).
331
+ const modelEntry = (id: string) => {
332
+ const base = seedModel(id);
333
+ const provider = sealedEnabled() ? sealedProviderFor(id) : null;
334
+ const shim = sealedShimBase();
335
+ return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
336
+ };
337
+ let lastIds: string[] = DEFAULT_MODELS;
338
+ const register = (ids: string[]): void => {
339
+ lastIds = ids;
303
340
  pi.registerProvider!("privateer", {
304
341
  name: "Privateer account",
305
342
  baseUrl: `${serverBaseUrl()}/api/agent/v1`,
306
343
  api: "openai-completions",
307
344
  oauth: privateerOAuthProvider,
308
- models: ids.map(seedModel),
345
+ models: ids.map(modelEntry),
309
346
  });
347
+ };
310
348
  register(DEFAULT_MODELS); // immediate: provider exists this tick
349
+ // Bring up the sealed shim, then re-register so sealed models pick up their shim
350
+ // baseUrl. Registration re-runs anyway after the catalog fetch; this just makes
351
+ // sure the switch lands even if the fetch is slow or fails.
352
+ if (sealedEnabled()) {
353
+ void ensureSealedShim()
354
+ .then(() => register(lastIds))
355
+ .catch(() => {
356
+ /* shim failed to start → sealed models stay on the cleartext path */
357
+ });
358
+ }
311
359
  // Refine to the live catalog. fetchAccountCatalog also populates accountTierMap
312
360
  // as a side effect, so the /models picker can shield each row without re-fetching.
313
361
  void fetchAccountCatalog()
@@ -2,9 +2,9 @@
2
2
  // becomes reachable under Pi. Written after verifying (2026-07-07) that:
3
3
  // - pi-ai ships STATIC model catalogs for its 14 built-in providers, so they're
4
4
  // selectable once a key is present — privateer emits NO models.json entry for them;
5
- // - the `pi-privacy` extension registers the 6 privacy providers (tinfoil, nearai,
6
- // venice, ollama, custom, privateer-api) at load — privateer emits NO entry for
7
- // them either;
5
+ // - the `pi-privacy` extension registers the privacy providers (tinfoil, nearai,
6
+ // venice, ollama, and — since pi-privacy 0.7`privateer`, its posture-aware
7
+ // public dev-key surface) at load — privateer emits NO entry for them either;
8
8
  // - so the ONLY provider this generator must emit is `qwen` (config-only,
9
9
  // non-privacy, no built-in catalog), and `privateer` (the account OAuth channel)
10
10
  // is handled in code, not config (Phase 4).
@@ -66,9 +66,11 @@ export const PROVIDERS: ProviderEntry[] = [
66
66
  { id: "tinfoil", source: "pi-privacy" },
67
67
  { id: "venice", source: "pi-privacy" },
68
68
  { id: "custom", source: "pi-privacy" },
69
- // Public developer-API surface (sk-priv-… key). Registered by the pi-privacy
70
- // extension like the others; distinct from the in-app `privateer` account channel.
71
- { id: "privateer-api", source: "pi-privacy" },
69
+ // The in-app account OAuth channel. NOTE: since pi-privacy 0.7 the extension ALSO
70
+ // registers a `privateer` provider (its posture-aware public sk-priv- dev-key surface,
71
+ // floored to zdr-policy) but makeAccountProvider runs AFTER pi-privacy in the
72
+ // extension list and re-registers the same id, so the account channel wins. There is
73
+ // no longer a separate `privateer-api` id (renamed to `privateer` upstream).
72
74
  { id: "privateer", source: "account" },
73
75
  ];
74
76
 
@@ -0,0 +1,23 @@
1
+ # Vendored: `@dstack/aci-verifier`
2
+
3
+ Faithful copy of the zero-dependency TypeScript ACI verifier from
4
+ [Dstack-TEE/private-ai-gateway](https://github.com/Dstack-TEE/private-ai-gateway)
5
+ (`clients/verifier-ts/src`), Apache-2.0. It is `private: true` upstream (not on
6
+ npm), so it is vendored here rather than installed.
7
+
8
+ Provides the pieces `PhalaProvider` needs:
9
+ - **`verifyReportBinding`** (`report.ts`) — §10.1 checks 2–6 (crypto binding of the
10
+ attestation report to the attested keyset for a supplied nonce). NOT the hardware
11
+ TDX quote (check 1) — that is layered on with `@phala/dcap-qvl` in the provider.
12
+ - **`openE2eeChannel`** (`e2ee-channel.ts`) — the ACI E2EE channel:
13
+ `x25519-aes-256-gcm-hkdf-sha256`, per-field seal/open, `X-E2EE-*` headers.
14
+
15
+ ## Local adaptation (the only change from upstream)
16
+ - Relative import specifiers had their `.js` extension stripped (`'./jcs.js'` →
17
+ `'./jcs'`) so Metro + TS (`moduleResolution: bundler`) resolve to the `.ts` files.
18
+
19
+ Everything else is byte-for-byte upstream. The crypto runs on `globalThis.crypto`
20
+ (Web Crypto: X25519, HKDF, AES-GCM, Ed25519, `getRandomValues`). In privateer-agent
21
+ (Node ≥ 22) these are all native — **no polyfills needed** (unlike the treeview RN app,
22
+ which bridges them via `react-native-quick-crypto`). Re-pull from upstream to update;
23
+ re-apply only the `.js`-extension strip.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Cryptographic primitives, all via the Web Crypto API (`globalThis.crypto`) so
3
+ * the same code runs in browsers and in Node 20+ with no third-party deps.
4
+ * Only SHA-256 and Ed25519 verification are needed for Level 1.
5
+ */
6
+
7
+ import { AciFormatError, UnsupportedAlgorithmError } from './errors';
8
+
9
+ const subtle = globalThis.crypto.subtle;
10
+
11
+ /** Lowercase-hex encode bytes. */
12
+ export function toHex(bytes: Uint8Array): string {
13
+ let out = '';
14
+ for (const b of bytes) out += b.toString(16).padStart(2, '0');
15
+ return out;
16
+ }
17
+
18
+ /** Decode hex (optionally `0x`-prefixed) to bytes. */
19
+ export function fromHex(hex: string): Uint8Array {
20
+ const h = hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex;
21
+ if (h.length % 2 !== 0) {
22
+ throw new AciFormatError(`hex string has odd length: ${hex.length} chars`);
23
+ }
24
+ const out = new Uint8Array(h.length / 2);
25
+ for (let i = 0; i < out.length; i++) {
26
+ const byte = Number.parseInt(h.substr(i * 2, 2), 16);
27
+ if (Number.isNaN(byte)) {
28
+ throw new AciFormatError(`invalid hex at offset ${i * 2}: "${h.substr(i * 2, 2)}"`);
29
+ }
30
+ out[i] = byte;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /** SHA-256 of the given bytes. */
36
+ export async function sha256(bytes: Uint8Array): Promise<Uint8Array> {
37
+ return new Uint8Array(await subtle.digest('SHA-256', bytes as BufferSource));
38
+ }
39
+
40
+ /** Lowercase-hex SHA-256 of the given bytes. */
41
+ export async function sha256Hex(bytes: Uint8Array): Promise<string> {
42
+ return toHex(await sha256(bytes));
43
+ }
44
+
45
+ /**
46
+ * `sha256:<lowercase-hex>` digest string of the given bytes — the ACI digest
47
+ * form (§3) used for `workload_id`, keyset digests, and body hashes.
48
+ */
49
+ export async function sha256Prefixed(bytes: Uint8Array): Promise<string> {
50
+ return 'sha256:' + (await sha256Hex(bytes));
51
+ }
52
+
53
+ /**
54
+ * Verify an Ed25519 signature (RFC 8032, §4.3/§8.5) over `message`.
55
+ * `publicKeyRaw` is the 32-byte raw key; `signature` the 64-byte value.
56
+ * Returns false on a bad signature or malformed key — never throws for those.
57
+ */
58
+ export async function verifyEd25519(
59
+ publicKeyRaw: Uint8Array,
60
+ signature: Uint8Array,
61
+ message: Uint8Array,
62
+ ): Promise<boolean> {
63
+ let key: CryptoKey;
64
+ try {
65
+ key = await subtle.importKey('raw', publicKeyRaw as BufferSource, { name: 'Ed25519' }, false, [
66
+ 'verify',
67
+ ]);
68
+ } catch {
69
+ // A key that will not import cannot verify anything.
70
+ return false;
71
+ }
72
+ try {
73
+ return await subtle.verify({ name: 'Ed25519' }, key, signature as BufferSource, message as BufferSource);
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Verify a signature by ACI signature `algo`, dispatching on the algorithm the
81
+ * attested keyset entry declares. Only `ed25519` is verifiable here; every other
82
+ * algorithm (including `ecdsa-secp256k1`) raises {@link UnsupportedAlgorithmError}.
83
+ */
84
+ export async function verifySignature(
85
+ algo: string,
86
+ publicKeyRaw: Uint8Array,
87
+ signature: Uint8Array,
88
+ message: Uint8Array,
89
+ context: string,
90
+ ): Promise<boolean> {
91
+ if (algo === 'ed25519') {
92
+ return verifyEd25519(publicKeyRaw, signature, message);
93
+ }
94
+ throw new UnsupportedAlgorithmError(algo, context);
95
+ }