@prismnetwork/agent-sdk 0.6.0 → 0.7.0

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/e2ee.d.mts ADDED
@@ -0,0 +1,89 @@
1
+ import type { KeyObject } from "node:crypto";
2
+
3
+ export declare const E2EE_VERSION: string;
4
+ export declare const X25519_SUITE: string;
5
+
6
+ export declare class E2eeError extends Error {}
7
+
8
+ export interface KeysetKey {
9
+ key_id: string;
10
+ algo: string;
11
+ public_key: string;
12
+ }
13
+
14
+ export interface AttestedKeyset {
15
+ e2ee_public_keys?: KeysetKey[];
16
+ [key: string]: unknown;
17
+ }
18
+
19
+ /// The client half of one encrypted exchange: the static key the response is
20
+ /// encrypted to, and the request context every field's associated data binds.
21
+ export interface ClientKey {
22
+ privateKey: KeyObject;
23
+ publicKey: string;
24
+ algo: string;
25
+ keyId: string;
26
+ model: string;
27
+ nonce: string;
28
+ ts: number;
29
+ }
30
+
31
+ export interface SealedRequest {
32
+ /// The body to send, with every message content replaced by its envelope.
33
+ bytes: Buffer;
34
+ /// The compact plaintext body the workload restores and hashes into the
35
+ /// receipt (§8 of the E2EE v2 protocol).
36
+ restored: Buffer;
37
+ /// All five headers a v2 request must carry; without any one of them the
38
+ /// service rejects the request.
39
+ headers: Record<string, string>;
40
+ clientKey: ClientKey;
41
+ }
42
+
43
+ export declare function selectE2eeKey(keyset: AttestedKeyset): KeysetKey;
44
+
45
+ /// §5 restoration: a decrypted whole-content plaintext that parses as a JSON
46
+ /// array comes back as structured content, and anything else stays a string.
47
+ export declare function restoreContent(plaintext: string): string | unknown[];
48
+
49
+ export declare function encryptChatRequest(
50
+ body: Record<string, unknown>,
51
+ keyset: AttestedKeyset,
52
+ options?: { now?: number; rand?: (length: number) => Uint8Array },
53
+ ): SealedRequest;
54
+
55
+ export declare function decryptResponse(
56
+ bodyBytes: Uint8Array | string,
57
+ clientKey: ClientKey,
58
+ options?: { model?: string },
59
+ ): Record<string, unknown>;
60
+
61
+ export declare function requestAad(input: {
62
+ algo: string;
63
+ model: string;
64
+ field: string;
65
+ nonce: string;
66
+ ts: number;
67
+ }): Uint8Array;
68
+
69
+ export declare function responseAad(input: {
70
+ algo: string;
71
+ model: string;
72
+ id: string;
73
+ field: string;
74
+ nonce: string;
75
+ ts: number;
76
+ }): Uint8Array;
77
+
78
+ export declare function sealField(
79
+ recipientRaw: Uint8Array,
80
+ plaintext: string,
81
+ aad: Uint8Array,
82
+ rand?: (length: number) => Uint8Array,
83
+ ): string;
84
+
85
+ export declare function openField(privateKey: KeyObject, envelopeHex: string, aad: Uint8Array): string;
86
+
87
+ export declare function publicKeyFromRaw(raw: Uint8Array): KeyObject;
88
+ export declare function privateKeyFromSeed(seed: Uint8Array): KeyObject;
89
+ export declare function rawPublicKey(key: KeyObject): Uint8Array;
package/e2ee.mjs ADDED
@@ -0,0 +1,215 @@
1
+ // E2EE v2 client for attested confidential inference: the field-level
2
+ // encryption an agent puts around a chat request so the relay that carries it
3
+ // holds ciphertext only, and the enclave holding the attested key is the one
4
+ // thing that can read the prompt.
5
+ //
6
+ // The wire contract is the X25519 suite of the ACI E2EE v2 protocol. Each
7
+ // protected field value is hex(ephemeral_public_key(32) || gcm_nonce(12) ||
8
+ // ciphertext || tag(16)); the AES-256-GCM key is HKDF-SHA256 over the X25519
9
+ // shared secret with an empty salt and the suite's info string; the AEAD's
10
+ // associated data is the JCS form of a purpose-tagged object that pins the
11
+ // field path, the model, the request nonce and the timestamp, so a ciphertext
12
+ // cannot be moved to another field, request or model.
13
+ import {
14
+ createCipheriv,
15
+ createDecipheriv,
16
+ createPrivateKey,
17
+ createPublicKey,
18
+ diffieHellman,
19
+ hkdfSync,
20
+ randomBytes,
21
+ } from "node:crypto";
22
+ import { jcsBytes } from "./vendor/aci-verifier/index.mjs";
23
+
24
+ export const E2EE_VERSION = "2";
25
+ export const X25519_SUITE = "x25519-aes-256-gcm-hkdf-sha256";
26
+ const HKDF_INFO = "aci.e2ee.v2.x25519";
27
+ const REQUEST_PURPOSE = "aci.e2ee.request.v2";
28
+ const RESPONSE_PURPOSE = "aci.e2ee.response.v2";
29
+
30
+ // Node's X25519 keys travel as DER. Raw 32-byte keys are the wire form on both
31
+ // sides, so wrap them in the one fixed SPKI/PKCS8 prefix each rather than
32
+ // pulling in a curve library to do it.
33
+ const SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex");
34
+ const PKCS8_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
35
+
36
+ const RESPONSE_FIELDS = ["content", "reasoning", "reasoning_content"];
37
+
38
+ export class E2eeError extends Error {
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = "E2eeError";
42
+ }
43
+ }
44
+
45
+ /// The x25519 entry of a quote-bound key set. The service publishes a secp256k1
46
+ /// suite too; this client speaks the x25519 one, which is the suite the spec
47
+ /// recommends and the only one it implements.
48
+ export function selectE2eeKey(keyset) {
49
+ const keys = Array.isArray(keyset?.e2ee_public_keys) ? keyset.e2ee_public_keys : [];
50
+ const entry = keys.find((k) => k.algo === X25519_SUITE);
51
+ if (!entry) throw new E2eeError(`the attested key set publishes no ${X25519_SUITE} key`);
52
+ return entry;
53
+ }
54
+
55
+ export function publicKeyFromRaw(raw) {
56
+ return createPublicKey({
57
+ key: Buffer.concat([SPKI_PREFIX, Buffer.from(raw)]),
58
+ format: "der",
59
+ type: "spki",
60
+ });
61
+ }
62
+
63
+ export function privateKeyFromSeed(seed) {
64
+ if (seed.length !== 32) throw new E2eeError("an x25519 private key is 32 bytes");
65
+ return createPrivateKey({
66
+ key: Buffer.concat([PKCS8_PREFIX, Buffer.from(seed)]),
67
+ format: "der",
68
+ type: "pkcs8",
69
+ });
70
+ }
71
+
72
+ export function rawPublicKey(key) {
73
+ return key.export({ type: "spki", format: "der" }).subarray(SPKI_PREFIX.length);
74
+ }
75
+
76
+ /// The associated data for one request field (§6). Byte-exact: the test vectors
77
+ /// pin this string.
78
+ export function requestAad({ algo, model, field, nonce, ts }) {
79
+ return jcsBytes({ purpose: REQUEST_PURPOSE, algo, model, field, nonce, ts });
80
+ }
81
+
82
+ /// The response variant, which additionally binds the response id.
83
+ export function responseAad({ algo, model, id, field, nonce, ts }) {
84
+ return jcsBytes({ purpose: RESPONSE_PURPOSE, algo, model, id, field, nonce, ts });
85
+ }
86
+
87
+ /// One field envelope, encrypted to `recipientRaw` under a fresh ephemeral key.
88
+ export function sealField(recipientRaw, plaintext, aad, rand = randomBytes) {
89
+ const ephemeral = privateKeyFromSeed(Buffer.from(rand(32)));
90
+ const shared = diffieHellman({ privateKey: ephemeral, publicKey: publicKeyFromRaw(recipientRaw) });
91
+ const key = Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0), Buffer.from(HKDF_INFO), 32));
92
+ const nonce = Buffer.from(rand(12));
93
+ const cipher = createCipheriv("aes-256-gcm", key, nonce);
94
+ cipher.setAAD(aad);
95
+ const body = Buffer.concat([cipher.update(Buffer.from(plaintext, "utf8")), cipher.final()]);
96
+ return Buffer.concat([rawPublicKey(createPublicKey(ephemeral)), nonce, body, cipher.getAuthTag()]).toString("hex");
97
+ }
98
+
99
+ /// The inverse. A tampered envelope, AAD or key fails the AEAD tag and throws.
100
+ export function openField(privateKey, envelopeHex, aad) {
101
+ const blob = Buffer.from(envelopeHex.startsWith("0x") ? envelopeHex.slice(2) : envelopeHex, "hex");
102
+ if (blob.length < 32 + 12 + 16) throw new E2eeError("e2ee envelope is too short to hold a field");
103
+ const shared = diffieHellman({ privateKey, publicKey: publicKeyFromRaw(blob.subarray(0, 32)) });
104
+ const key = Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0), Buffer.from(HKDF_INFO), 32));
105
+ const decipher = createDecipheriv("aes-256-gcm", key, blob.subarray(32, 44));
106
+ decipher.setAAD(aad);
107
+ decipher.setAuthTag(blob.subarray(blob.length - 16));
108
+ try {
109
+ return Buffer.concat([decipher.update(blob.subarray(44, blob.length - 16)), decipher.final()]).toString("utf8");
110
+ } catch {
111
+ throw new E2eeError("e2ee field did not authenticate: wrong key, wrong context, or altered ciphertext");
112
+ }
113
+ }
114
+
115
+ /// §5 restoration: a decrypted whole-content plaintext that parses as a JSON
116
+ /// array is restored as structured content, and anything else stays the string
117
+ /// it was. The receipt covers what the workload restored, so a client that
118
+ /// reproduces the hash has to apply the same rule to its own copy.
119
+ export function restoreContent(plaintext) {
120
+ if (typeof plaintext !== "string" || plaintext.trimStart()[0] !== "[") return plaintext;
121
+ try {
122
+ const value = JSON.parse(plaintext);
123
+ return Array.isArray(value) ? value : plaintext;
124
+ } catch {
125
+ return plaintext;
126
+ }
127
+ }
128
+
129
+ /// Encrypt every message content of a chat-completions body to the attested
130
+ /// service key. Returns the bytes to send, the five headers that must travel
131
+ /// with them, the client key the response is encrypted to, and the compact
132
+ /// restored-plaintext bytes the receipt's `request.received` hash covers.
133
+ ///
134
+ /// `now` and `rand` are injectable so a test can pin the whole envelope; both
135
+ /// default to the real clock and the system CSPRNG.
136
+ export function encryptChatRequest(body, keyset, { now = Math.floor(Date.now() / 1000), rand = randomBytes } = {}) {
137
+ if (typeof body?.model !== "string") throw new E2eeError("an e2ee request needs a top-level model string");
138
+ if (!Array.isArray(body.messages) || body.messages.length === 0) {
139
+ throw new E2eeError("an e2ee chat request needs a messages array");
140
+ }
141
+ const serviceKey = selectE2eeKey(keyset);
142
+ const serviceRaw = Buffer.from(serviceKey.public_key.replace(/^0x/, ""), "hex");
143
+ if (serviceRaw.length !== 32) throw new E2eeError("the attested x25519 key is not 32 bytes");
144
+
145
+ const clientPrivate = privateKeyFromSeed(Buffer.from(rand(32)));
146
+ const clientPublic = rawPublicKey(createPublicKey(clientPrivate));
147
+ const nonce = Buffer.from(rand(32)).toString("hex");
148
+ const ts = now;
149
+
150
+ // The plaintext copy is what the workload hashes into the receipt after it
151
+ // restores the fields (§8 of the v2 protocol), so it is built alongside the
152
+ // encrypted one from the same object and in the same member order.
153
+ const restored = { ...body, messages: [] };
154
+ const sealed = { ...body, messages: [] };
155
+ body.messages.forEach((message, index) => {
156
+ if (typeof message?.content !== "string") {
157
+ // Any plaintext string at a protected path fails the request upstream, so
158
+ // a body this client cannot fully protect is refused here instead.
159
+ throw new E2eeError(`messages.${index}.content must be a string to be encrypted`);
160
+ }
161
+ const field = `messages.${index}.content`;
162
+ const aad = requestAad({ algo: serviceKey.algo, model: body.model, field, nonce, ts });
163
+ restored.messages.push({ ...message, content: restoreContent(message.content) });
164
+ sealed.messages.push({ ...message, content: sealField(serviceRaw, message.content, aad, rand) });
165
+ });
166
+
167
+ return {
168
+ bytes: Buffer.from(JSON.stringify(sealed), "utf8"),
169
+ restored: Buffer.from(JSON.stringify(restored), "utf8"),
170
+ headers: {
171
+ "X-E2EE-Version": E2EE_VERSION,
172
+ "X-Client-Pub-Key": Buffer.from(clientPublic).toString("hex"),
173
+ "X-Model-Pub-Key": serviceKey.public_key,
174
+ "X-E2EE-Nonce": nonce,
175
+ "X-E2EE-Timestamp": String(ts),
176
+ },
177
+ clientKey: {
178
+ privateKey: clientPrivate,
179
+ publicKey: Buffer.from(clientPublic).toString("hex"),
180
+ algo: serviceKey.algo,
181
+ keyId: serviceKey.key_id,
182
+ model: body.model,
183
+ nonce,
184
+ ts,
185
+ },
186
+ };
187
+ }
188
+
189
+ /// Decrypt a buffered chat-completions response in place. Every generated
190
+ /// content field the service encrypted is authenticated against the AAD that
191
+ /// names its position, so a field lifted from another response does not open.
192
+ export function decryptResponse(bodyBytes, clientKey, { model = clientKey.model } = {}) {
193
+ const text = typeof bodyBytes === "string" ? bodyBytes : Buffer.from(bodyBytes).toString("utf8");
194
+ const body = JSON.parse(text);
195
+ const id = typeof body.id === "string" ? body.id : "";
196
+ const choices = Array.isArray(body.choices) ? body.choices : [];
197
+ choices.forEach((choice, position) => {
198
+ const index = Number.isInteger(choice?.index) ? choice.index : position;
199
+ const message = choice?.message;
200
+ if (message === null || typeof message !== "object") return;
201
+ for (const name of RESPONSE_FIELDS) {
202
+ if (typeof message[name] !== "string" || message[name] === "") continue;
203
+ const aad = responseAad({
204
+ algo: clientKey.algo,
205
+ model,
206
+ id,
207
+ field: `choices.${index}.message.${name}`,
208
+ nonce: clientKey.nonce,
209
+ ts: clientKey.ts,
210
+ });
211
+ message[name] = openField(clientKey.privateKey, message[name], aad);
212
+ }
213
+ });
214
+ return body;
215
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prismnetwork/agent-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Headless GPU leasing and renter-encrypted storage on Prism Network for wallet-holding agents.",
5
5
  "type": "module",
6
6
  "main": "prism.mjs",
@@ -9,6 +9,14 @@
9
9
  "types": "./prism.d.mts",
10
10
  "default": "./prism.mjs"
11
11
  },
12
+ "./attest": {
13
+ "types": "./attest.d.mts",
14
+ "default": "./attest.mjs"
15
+ },
16
+ "./e2ee": {
17
+ "types": "./e2ee.d.mts",
18
+ "default": "./e2ee.mjs"
19
+ },
12
20
  "./toolset": {
13
21
  "types": "./toolset.d.mts",
14
22
  "default": "./toolset.mjs"
@@ -24,15 +32,22 @@
24
32
  },
25
33
  "files": [
26
34
  "prism.mjs",
27
- "relay.mjs",
28
- "toolset.d.mts",
29
35
  "prism.d.mts",
36
+ "attest.mjs",
37
+ "attest.d.mts",
38
+ "e2ee.mjs",
39
+ "e2ee.d.mts",
40
+ "relay.mjs",
30
41
  "toolset.mjs",
42
+ "toolset.d.mts",
31
43
  "vault.mjs",
32
44
  "vault.d.ts",
33
45
  "workspace.mjs",
34
46
  "workspace.d.ts",
35
- "README.md"
47
+ "vendor/aci-verifier/*.mjs",
48
+ "README.md",
49
+ "LICENSE",
50
+ "NOTICE"
36
51
  ],
37
52
  "engines": {
38
53
  "node": ">=20"
@@ -47,7 +62,9 @@
47
62
  "web3",
48
63
  "usdg",
49
64
  "compute",
50
- "llm"
65
+ "llm",
66
+ "tee",
67
+ "attestation"
51
68
  ],
52
69
  "homepage": "https://prismnetwork.tech",
53
70
  "repository": {
@@ -62,5 +79,9 @@
62
79
  "publishConfig": {
63
80
  "access": "public"
64
81
  },
65
- "types": "prism.d.mts"
82
+ "types": "prism.d.mts",
83
+ "dependencies": {
84
+ "@phala/dcap-qvl": "^0.6.1",
85
+ "jose": "^6.0.0"
86
+ }
66
87
  }
package/prism.d.mts CHANGED
@@ -1,8 +1,13 @@
1
+ import type { AttestationResult, VerifyConfidentialOptions, WorkloadPin } from "./attest.d.mts";
2
+
1
3
  export declare const robinhoodChain: unknown;
2
4
  export declare const USDG: string;
3
5
  export declare const DEFAULT_IMAGE: string;
4
6
  export declare const TRUST_CLASSES: readonly ["open", "isolated", "attested", "confidential"];
5
7
 
8
+ export { DEFAULT_CONFIDENTIAL_BASE, EXPECTED_WORKLOAD, renderChecks, verifyConfidential } from "./attest.d.mts";
9
+ export type { AttestationCheck, AttestationResult, WorkloadPin } from "./attest.d.mts";
10
+
6
11
  /// `mode` says which of the two shapes arrived. Brokered capacity fills in
7
12
  /// `ssh_host` and `ssh_port`; a node that accepts nothing inbound fills in the
8
13
  /// gateway fields instead and is reached through a relay.
@@ -54,6 +59,35 @@ export interface RunResult {
54
59
  timedOut: boolean;
55
60
  }
56
61
 
62
+ export interface PaidResponse {
63
+ status: number;
64
+ headers: Headers;
65
+ /// The response bytes exactly as they arrived, which is what a signed receipt
66
+ /// over the exchange commits to.
67
+ bytes: Buffer;
68
+ tx: string;
69
+ /// The request as the attempt that was served built it. Under E2EE each
70
+ /// attempt seals its own envelope, so this is the one the receipt covers.
71
+ sent: { bytes: Buffer; headers?: Record<string, string>; [key: string]: unknown };
72
+ }
73
+
74
+ /// One confidential generation, with everything needed to check it afterwards.
75
+ export interface ConfidentialRun {
76
+ model: string;
77
+ content: string | null;
78
+ usage: Record<string, unknown> | null;
79
+ receiptId: string | null;
80
+ receipt: Record<string, unknown> | null;
81
+ /// The key set the prompt was encrypted to, when e2ee was on.
82
+ keysetDigest: string | null;
83
+ e2ee: boolean;
84
+ priceMicros: string;
85
+ priceUsdg: string;
86
+ tx: string;
87
+ bytes: { request: Buffer; response: Buffer; restoredRequest?: Buffer };
88
+ verify(options?: Partial<VerifyConfidentialOptions>): Promise<AttestationResult>;
89
+ }
90
+
57
91
  export declare class PrismAgent {
58
92
  constructor(options: { privateKey: string; escrow: string; apiBase?: string; rpcUrl?: string });
59
93
  readonly address: string;
@@ -96,6 +130,35 @@ export declare class PrismAgent {
96
130
  /// not a one-shot command: scp, a notebook client, an interactive shell.
97
131
  forward(lease: LeaseHandle, options?: { service?: "ssh" | "jupyter" }): Promise<RelayForwarder>;
98
132
  endLease(lease: LeaseHandle): void;
133
+ /// Pay for one call to a metered endpoint, keeping the payment until the
134
+ /// endpoint serves. Bytes are sent verbatim. Pass `seal` instead of `body` for
135
+ /// a request that has to be rebuilt per attempt, with `fingerprint` so the
136
+ /// kept payment still recognises the two attempts as the same request.
137
+ payAndPost(options: {
138
+ base: string;
139
+ path: string;
140
+ price: bigint | number | string;
141
+ payTo: string;
142
+ body?: Uint8Array | string | Record<string, unknown> | null;
143
+ headers?: Record<string, string>;
144
+ seal?: (() => { bytes: Uint8Array; headers?: Record<string, string> }) | null;
145
+ fingerprint?: Uint8Array | string | null;
146
+ retryDelayMs?: number;
147
+ caller?: string;
148
+ }): Promise<PaidResponse>;
149
+ /// One generation from the confidential tier, end-to-end encrypted by default
150
+ /// to a key the serving enclave's attestation quote commits to, from an
151
+ /// enclave running the code `expectedWorkload` pins.
152
+ confidentialInfer(options: {
153
+ prompt?: string;
154
+ messages?: Array<{ role: string; content: string }>;
155
+ model?: string | null;
156
+ maxUsdg?: number;
157
+ maxTokens?: number;
158
+ e2ee?: boolean;
159
+ expectedWorkload?: WorkloadPin | null;
160
+ endpoint?: string;
161
+ }): Promise<ConfidentialRun>;
99
162
  }
100
163
 
101
164
  export declare class PrismError extends Error {