@prismnetwork/agent-sdk 0.1.0 → 0.3.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/README.md CHANGED
@@ -4,12 +4,12 @@ Headless GPU leasing on [Prism Network](https://prismnetwork.tech) for autonomou
4
4
 
5
5
  ## Install
6
6
 
7
- Not yet published to npm. Until it is, install it from the repo alongside its `viem` peer dependency:
8
-
9
7
  ```
10
- npm install /path/to/prism-public/sdk viem
8
+ npm install @prismnetwork/agent-sdk viem
11
9
  ```
12
10
 
11
+ `viem` is a peer dependency.
12
+
13
13
  ## Use
14
14
 
15
15
  ```js
@@ -29,6 +29,42 @@ agent.endLease(lease);
29
29
 
30
30
  `image` must be an immutable digest-pinned reference (`repo@sha256:...`). `DEFAULT_IMAGE` is one; a plain tag is rejected.
31
31
 
32
+ ## Vault
33
+
34
+ Cards, identity documents, API credentials and recovery codes go in the vault
35
+ rather than on a leased box. Items are sealed here, on your machine, under a
36
+ key derived from a wallet signature that is never transmitted, so Prism stores
37
+ ciphertext and holds no way to read it.
38
+
39
+ ```js
40
+ await agent.vault.unlock();
41
+
42
+ const card = await agent.vault.put({ pan: "4111111111111111" }, { label: "billing" });
43
+ const value = await agent.vault.get(card.item_id, { json: true });
44
+ ```
45
+
46
+ `unlock()` derives the key from a signature over a fixed statement. Ethereum's
47
+ ECDSA is deterministic, so the same wallet reproduces the same vault on any
48
+ machine — no recovery copy is held anywhere. Pass `{ passphrase }` to require a
49
+ second factor beyond the wallet.
50
+
51
+ Every item carries the weakest workspace trust class it may ever be released
52
+ into. New items default to `confidential`, which is above anything the network
53
+ serves today, so `releaseInto` refuses rather than exposing a secret to a host
54
+ that can read it:
55
+
56
+ ```js
57
+ await agent.vault.releaseInto(lease, card.item_id, { json: true }); // throws on open capacity
58
+ ```
59
+
60
+ Lowering an item's floor is deliberate and reseals the item. Allowed releases
61
+ are recorded and readable with `agent.vault.releases()`.
62
+
63
+ The account, item slot, version and trust floor are authenticated into the
64
+ ciphertext, so a service that moved an item between accounts, replayed an older
65
+ version, or lowered its floor would produce a failed decrypt rather than a
66
+ plausible wrong answer. See [docs/VAULT.md](../docs/VAULT.md).
67
+
32
68
  ## Auth
33
69
 
34
70
  `authenticate()` fetches a challenge (`GET /api/agent/challenge`), signs the message with the wallet, and exchanges it for a session (`POST /api/agent/session`). The session is a bearer token used on every `/api/agent/proxy/*` call. No shared secret, no cookie. The wallet is the identity (`subject = wallet:0x...`).
package/package.json CHANGED
@@ -1,21 +1,48 @@
1
1
  {
2
2
  "name": "@prismnetwork/agent-sdk",
3
- "version": "0.1.0",
4
- "description": "Headless GPU leasing on Prism Network for wallet-holding agents.",
3
+ "version": "0.3.0",
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",
7
- "exports": { ".": "./prism.mjs" },
8
- "files": ["prism.mjs", "README.md"],
9
- "engines": { "node": ">=20" },
10
- "peerDependencies": { "viem": "^2" },
11
- "keywords": ["prism", "gpu", "agent", "web3", "usdg", "compute", "llm"],
7
+ "exports": {
8
+ ".": "./prism.mjs",
9
+ "./vault": {
10
+ "types": "./vault.d.ts",
11
+ "default": "./vault.mjs"
12
+ }
13
+ },
14
+ "files": [
15
+ "prism.mjs",
16
+ "vault.mjs",
17
+ "vault.d.ts",
18
+ "README.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "peerDependencies": {
24
+ "viem": "^2.55.11"
25
+ },
26
+ "keywords": [
27
+ "prism",
28
+ "gpu",
29
+ "agent",
30
+ "web3",
31
+ "usdg",
32
+ "compute",
33
+ "llm"
34
+ ],
12
35
  "homepage": "https://prismnetwork.tech",
13
36
  "repository": {
14
37
  "type": "git",
15
38
  "url": "git+https://github.com/prismnetwork-tech/prism.git",
16
39
  "directory": "sdk"
17
40
  },
18
- "bugs": { "url": "https://github.com/prismnetwork-tech/prism/issues" },
41
+ "bugs": {
42
+ "url": "https://github.com/prismnetwork-tech/prism/issues"
43
+ },
19
44
  "license": "Apache-2.0",
20
- "publishConfig": { "access": "public" }
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
21
48
  }
package/prism.mjs CHANGED
@@ -14,6 +14,9 @@ import {
14
14
  stringToBytes,
15
15
  } from "viem";
16
16
  import { privateKeyToAccount } from "viem/accounts";
17
+ import { PrismVault } from "./vault.mjs";
18
+
19
+ export { PrismVault, VaultError, DEFAULT_TRUST_FLOOR, VAULT_KEY_STATEMENT } from "./vault.mjs";
17
20
 
18
21
  export const robinhoodChain = defineChain({
19
22
  id: 4663,
@@ -29,6 +32,10 @@ export const USDG = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168";
29
32
  export const DEFAULT_IMAGE =
30
33
  "docker.io/ollama/ollama@sha256:a61a8fd395dbb931cc8cb1b5da7a2510746575c87113fdc45b647ee59ef7f808";
31
34
 
35
+ // Weakest to strongest. "open" means the supplier can read everything the
36
+ // workload touches, so an agent handling anything sensitive should raise this.
37
+ export const TRUST_CLASSES = ["open", "isolated", "attested", "confidential"];
38
+
32
39
  const CONFIRMATIONS = 12;
33
40
  const FETCH_TIMEOUT_MS = 30_000;
34
41
 
@@ -42,6 +49,27 @@ const escrowAbi = parseAbi([
42
49
  "function createLease(bytes32 nodeId, uint32 duration, bytes32 clientReference) returns (uint256)",
43
50
  ]);
44
51
 
52
+ // Matches the limit the control plane and the node both enforce, so a command
53
+ // that cannot run is rejected here rather than after an escrow is funded.
54
+ const MAX_COMMAND_BYTES = 8 * 1024;
55
+
56
+ function assertCommand(value) {
57
+ if (typeof value !== "string" || value.trim() === "") {
58
+ throw new PrismError(400, "invalid_command", { hint: "a batch command cannot be empty" });
59
+ }
60
+ if (Buffer.byteLength(value, "utf8") > MAX_COMMAND_BYTES) {
61
+ throw new PrismError(400, "invalid_command", { hint: "a batch command cannot exceed 8 KiB" });
62
+ }
63
+ return value;
64
+ }
65
+
66
+ function assertTrustClass(value) {
67
+ if (!TRUST_CLASSES.includes(value)) {
68
+ throw new PrismError(400, "invalid_trust_class", { expected: TRUST_CLASSES });
69
+ }
70
+ return value;
71
+ }
72
+
45
73
  function parseBaseUnits(value, field) {
46
74
  if (typeof value === "number" && Number.isInteger(value) && value >= 0) return BigInt(value);
47
75
  if (typeof value === "string" && /^[0-9]+$/.test(value)) return BigInt(value);
@@ -79,12 +107,23 @@ export class PrismAgent {
79
107
  this.publicClient = createPublicClient({ chain: robinhoodChain, transport });
80
108
  this.walletClient = createWalletClient({ account: this.account, chain: robinhoodChain, transport });
81
109
  this.session = null;
110
+ this.vault = new PrismVault(this);
82
111
  }
83
112
 
84
113
  get address() {
85
114
  return this.account.address;
86
115
  }
87
116
 
117
+ // The vault key is derived from this signature on the caller's machine. It is
118
+ // returned to the vault client and never sent anywhere.
119
+ async signVaultStatement(statement) {
120
+ return this.account.signMessage({ message: statement });
121
+ }
122
+
123
+ async vaultRequest(method, segments, { body = null } = {}) {
124
+ return this.#proxy(method, ["vault", ...segments], { body });
125
+ }
126
+
88
127
  async authenticate() {
89
128
  const challenge = await this.#json(`/api/agent/challenge?address=${this.address}`);
90
129
  const signature = await this.account.signMessage({ message: challenge.message });
@@ -96,8 +135,8 @@ export class PrismAgent {
96
135
  return session;
97
136
  }
98
137
 
99
- async offers() {
100
- return this.#proxy("GET", ["offers"]);
138
+ async offers({ minTrust = "open" } = {}) {
139
+ return this.#proxy("GET", ["offers"], { query: { min_trust: assertTrustClass(minTrust) } });
101
140
  }
102
141
 
103
142
  async balances() {
@@ -125,18 +164,22 @@ export class PrismAgent {
125
164
  }
126
165
  }
127
166
 
128
- async quote({ image, durationSeconds, minVramMib = 16000, preferredNodeId = null } = {}) {
167
+ async quote({ image, durationSeconds, minVramMib = 16000, preferredNodeId = null, minTrustClass = "open", command = null } = {}) {
129
168
  if (typeof image !== "string" || !/@sha256:[0-9a-f]{64}$/.test(image)) {
130
169
  throw new PrismError(400, "image_must_be_digest_pinned", { hint: "use ollama@sha256:... or DEFAULT_IMAGE" });
131
170
  }
132
171
  if (!Number.isInteger(durationSeconds) || durationSeconds <= 0) throw new PrismError(400, "invalid_duration");
133
172
  if (!Number.isInteger(minVramMib) || minVramMib <= 0) throw new PrismError(400, "invalid_min_vram_mib");
134
173
  return this.#proxy("POST", ["leases", "match"], {
135
- request: {
136
- image,
137
- duration_seconds: durationSeconds,
138
- min_vram_mib: minVramMib,
139
- preferred_node_id: preferredNodeId,
174
+ body: {
175
+ request: {
176
+ image,
177
+ duration_seconds: durationSeconds,
178
+ min_vram_mib: minVramMib,
179
+ preferred_node_id: preferredNodeId,
180
+ min_trust_class: assertTrustClass(minTrustClass),
181
+ ...(command === null ? {} : { command: assertCommand(command) }),
182
+ },
140
183
  },
141
184
  });
142
185
  }
@@ -185,9 +228,11 @@ export class PrismAgent {
185
228
 
186
229
  async confirm({ quoteId, transactionHash, sshAuthorizedKey }) {
187
230
  return this.#proxy("POST", ["leases", "confirm"], {
188
- quote_id: quoteId,
189
- transaction_hash: transactionHash,
190
- ssh_authorized_key: sshAuthorizedKey,
231
+ body: {
232
+ quote_id: quoteId,
233
+ transaction_hash: transactionHash,
234
+ ssh_authorized_key: sshAuthorizedKey,
235
+ },
191
236
  });
192
237
  }
193
238
 
@@ -199,10 +244,26 @@ export class PrismAgent {
199
244
  return this.#proxy("GET", ["leases", String(leaseId), "access"]);
200
245
  }
201
246
 
247
+ /// The output of a batch lease, once its node has reported.
248
+ async result(leaseId) {
249
+ return this.#proxy("GET", ["leases", String(leaseId), "result"]);
250
+ }
251
+
252
+ async waitForResult(leaseId, { timeoutMs = 900_000, intervalMs = 10_000 } = {}) {
253
+ const deadline = Date.now() + timeoutMs;
254
+ while (Date.now() < deadline) {
255
+ const res = await this.#proxy("GET", ["leases", String(leaseId), "result"], { raw: true });
256
+ if (res.status === 200) return res.body;
257
+ if (res.status !== 404) throw new PrismError(res.status, res.body?.code ?? "result_failed", res.body);
258
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
259
+ }
260
+ throw new PrismError(408, "result_timeout");
261
+ }
262
+
202
263
  async waitForAccess(leaseId, { timeoutMs = 600_000, intervalMs = 10_000 } = {}) {
203
264
  const deadline = Date.now() + timeoutMs;
204
265
  while (Date.now() < deadline) {
205
- const res = await this.#proxy("GET", ["leases", String(leaseId), "access"], null, true);
266
+ const res = await this.#proxy("GET", ["leases", String(leaseId), "access"], { raw: true });
206
267
  if (res.status === 200) {
207
268
  if (!res.body?.ssh_host && res.body?.mode !== "gateway") throw new PrismError(502, "malformed_access");
208
269
  return res.body;
@@ -214,9 +275,24 @@ export class PrismAgent {
214
275
  }
215
276
 
216
277
  // quote -> ssh keygen -> fund on-chain -> confirm -> wait for access.
217
- async lease({ image, durationSeconds, minVramMib, preferredNodeId = null, maxDeposit = null } = {}) {
278
+ async lease({
279
+ image,
280
+ durationSeconds,
281
+ minVramMib,
282
+ preferredNodeId = null,
283
+ maxDeposit = null,
284
+ minTrustClass = "open",
285
+ command = null,
286
+ } = {}) {
218
287
  if (!this.session) await this.authenticate();
219
- const quote = await this.quote({ image, durationSeconds, minVramMib, preferredNodeId });
288
+ const quote = await this.quote({
289
+ image,
290
+ durationSeconds,
291
+ minVramMib,
292
+ preferredNodeId,
293
+ minTrustClass,
294
+ command,
295
+ });
220
296
  if (maxDeposit != null && parseBaseUnits(quote.maximum_escrow, "maximum_escrow") > BigInt(maxDeposit)) {
221
297
  throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
222
298
  }
@@ -229,6 +305,14 @@ export class PrismAgent {
229
305
  sshAuthorizedKey: key.publicKey,
230
306
  });
231
307
  if (!Number.isInteger(record?.lease_id)) throw new PrismError(502, "malformed_lease_record");
308
+ // A batch lease never hands out access, so waiting for it would block
309
+ // until the timeout and then report a failure that never happened. Wait
310
+ // for what the command printed instead.
311
+ if (command !== null) {
312
+ const result = await this.waitForResult(record.lease_id);
313
+ rmSync(key.dir, { recursive: true, force: true });
314
+ return { leaseId: record.lease_id, result, fundingHash: funded.hash, quote };
315
+ }
232
316
  const access = await this.waitForAccess(record.lease_id);
233
317
  return {
234
318
  leaseId: record.lease_id,
@@ -325,9 +409,10 @@ export class PrismAgent {
325
409
  });
326
410
  }
327
411
 
328
- async #proxy(method, segments, body = null, raw = false, reauthed = false) {
412
+ async #proxy(method, segments, { body = null, raw = false, query = null, reauthed = false } = {}) {
329
413
  if (!this.session) await this.authenticate();
330
- const res = await this.#fetch(`/api/agent/proxy/${segments.join("/")}`, {
414
+ const search = query ? `?${new URLSearchParams(query)}` : "";
415
+ const res = await this.#fetch(`/api/agent/proxy/${segments.join("/")}${search}`, {
331
416
  method,
332
417
  body,
333
418
  headers: { authorization: `Bearer ${this.session}` },
@@ -336,7 +421,7 @@ export class PrismAgent {
336
421
  if (res.status === 401 && !reauthed) {
337
422
  this.session = null;
338
423
  await this.authenticate();
339
- return this.#proxy(method, segments, body, raw, true);
424
+ return this.#proxy(method, segments, { body, raw, query, reauthed: true });
340
425
  }
341
426
  if (raw) return { status: res.status, body: await res.json().catch(() => null) };
342
427
  return this.#unwrap(res);
package/vault.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ export type TrustFloor = "open" | "isolated" | "attested" | "confidential";
2
+
3
+ export type VaultItem = {
4
+ item_id: string;
5
+ version: number;
6
+ envelope: { wrapped_key: string; nonce: string; ciphertext: string };
7
+ min_trust_class: TrustFloor;
8
+ label: string;
9
+ created_at: string;
10
+ updated_at: string;
11
+ };
12
+
13
+ export type VaultRelease = {
14
+ item_id: string;
15
+ lease_id: number;
16
+ item_version: number;
17
+ lease_trust_class: TrustFloor;
18
+ released_at: string;
19
+ };
20
+
21
+ /// What the vault client needs from its caller: an address, a way to sign, and
22
+ /// a way to reach the control plane. The agent SDK and the browser each supply
23
+ /// their own, so the crypto below is written once.
24
+ export type VaultTransport = {
25
+ address: string;
26
+ session: unknown;
27
+ authenticate: () => Promise<unknown>;
28
+ signVaultStatement: (statement: string) => Promise<string>;
29
+ vaultRequest: (method: string, segments: string[], options?: { body?: unknown }) => Promise<any>;
30
+ };
31
+
32
+ export declare const VAULT_ENVELOPE_DOMAIN: string;
33
+ export declare const VAULT_KEY_STATEMENT: string;
34
+ export declare const DEFAULT_TRUST_FLOOR: TrustFloor;
35
+
36
+ export declare function vaultWallet(address: string): string;
37
+ export declare function associatedData(
38
+ wallet: string,
39
+ itemId: string,
40
+ version: number,
41
+ trustFloor: TrustFloor,
42
+ ): Uint8Array;
43
+
44
+ export declare class PrismVault {
45
+ constructor(transport: VaultTransport);
46
+ readonly unlocked: boolean;
47
+ readonly wallet: string | null;
48
+ unlock(options?: { passphrase?: string | null }): Promise<this>;
49
+ lock(): void;
50
+ list(): Promise<VaultItem[]>;
51
+ releases(): Promise<VaultRelease[]>;
52
+ put(
53
+ value: unknown,
54
+ options?: {
55
+ itemId?: string | null;
56
+ replaces?: VaultItem | null;
57
+ trustFloor?: TrustFloor;
58
+ label?: string;
59
+ },
60
+ ): Promise<VaultItem>;
61
+ get(itemId: string, options?: { json?: boolean }): Promise<any>;
62
+ open(item: VaultItem, options?: { expectVersion?: number | null }): Promise<string>;
63
+ remove(itemId: string): Promise<null>;
64
+ releaseInto(
65
+ lease: number | { leaseId?: number; lease_id?: number },
66
+ itemId: string,
67
+ options?: { json?: boolean },
68
+ ): Promise<any>;
69
+ static permits(trustFloor: TrustFloor, leaseTrustClass: TrustFloor): boolean;
70
+ }
71
+
72
+ export declare class VaultError extends Error {
73
+ readonly code: string;
74
+ readonly body?: unknown;
75
+ }
package/vault.mjs ADDED
@@ -0,0 +1,308 @@
1
+ // Renter-held storage. Everything in this file runs on the renter's machine:
2
+ // the vault key is derived here, items are sealed here, and only ciphertext is
3
+ // handed to Prism. There is no code path that sends a key anywhere, which is
4
+ // the entire reason an agent can keep a card or an identity document in a
5
+ // service it does not trust.
6
+ //
7
+ // Runs unchanged in Node and in the browser, so an agent and the person who
8
+ // owns it seal items identically. Two implementations would be two chances to
9
+ // disagree, and disagreeing here means a vault that will not open.
10
+ const { subtle } = globalThis.crypto;
11
+
12
+ export const VAULT_ENVELOPE_DOMAIN = "prism.vault.v1\0";
13
+
14
+ // A signature over this exact string is the vault key. Nothing else Prism ever
15
+ // asks a wallet to sign resembles it, and no other message derives the same
16
+ // key, so approving a lease cannot hand anyone the vault.
17
+ export const VAULT_KEY_STATEMENT = [
18
+ "Prism Network vault key",
19
+ "",
20
+ "Signing this derives the key that encrypts your Prism vault. It is computed on",
21
+ "this machine and never sent. Anyone who gets this signature can read every item",
22
+ "in the vault, so only sign it in software you trust.",
23
+ "",
24
+ "domain: prism.vault.kdf.v1",
25
+ ].join("\n");
26
+
27
+ // New items are sealed against every trust class the network can serve today.
28
+ // Storing and reading back is unaffected; only handing an item to a rented box
29
+ // is blocked, and that is the operation worth blocking by default.
30
+ export const DEFAULT_TRUST_FLOOR = "confidential";
31
+
32
+ const TRUST_ORDER = ["open", "isolated", "attested", "confidential"];
33
+
34
+ const encoder = new TextEncoder();
35
+ const decoder = new TextDecoder();
36
+
37
+ // Chunked because a 160 KiB item would otherwise spread into more arguments
38
+ // than an engine will accept in one call.
39
+ function b64url(bytes) {
40
+ let binary = "";
41
+ for (let index = 0; index < bytes.length; index += 0x8000) {
42
+ binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
43
+ }
44
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
45
+ }
46
+
47
+ function fromB64url(value) {
48
+ const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
49
+ const bytes = new Uint8Array(binary.length);
50
+ for (let index = 0; index < binary.length; index += 1) {
51
+ bytes[index] = binary.charCodeAt(index);
52
+ }
53
+ return bytes;
54
+ }
55
+
56
+ function fromHex(value) {
57
+ const digits = value.replace(/^0x/, "");
58
+ if (digits.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(digits)) {
59
+ throw new VaultError("invalid_signature_encoding");
60
+ }
61
+ const bytes = new Uint8Array(digits.length / 2);
62
+ for (let index = 0; index < bytes.length; index += 1) {
63
+ bytes[index] = Number.parseInt(digits.slice(index * 2, index * 2 + 2), 16);
64
+ }
65
+ return bytes;
66
+ }
67
+
68
+ /// The wallet address, lowercased. Casing varies by source — a checksummed
69
+ /// address from one wallet and a lowercase one from another must not derive
70
+ /// two different keys for the same vault.
71
+ export function vaultWallet(address) {
72
+ if (typeof address !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(address)) {
73
+ throw new VaultError("invalid_wallet_address", { address });
74
+ }
75
+ return address.toLowerCase();
76
+ }
77
+
78
+ /// Mirrors `vault_associated_data` in prism-protocol byte for byte. A shared
79
+ /// test vector pins both; if they drift, stored items stop opening.
80
+ export function associatedData(wallet, itemId, version, trustFloor) {
81
+ return encoder.encode(
82
+ `${VAULT_ENVELOPE_DOMAIN}${wallet}\0${itemId}\0${version}\0${trustFloor}\0`,
83
+ );
84
+ }
85
+
86
+ function assertTrustFloor(value) {
87
+ if (!TRUST_ORDER.includes(value)) {
88
+ throw new VaultError("invalid_trust_floor", { expected: TRUST_ORDER });
89
+ }
90
+ return value;
91
+ }
92
+
93
+ function meetsFloor(floor, leaseClass) {
94
+ return TRUST_ORDER.indexOf(leaseClass) >= TRUST_ORDER.indexOf(floor);
95
+ }
96
+
97
+ // HKDF over the wallet signature. Ethereum's ECDSA is deterministic (RFC 6979),
98
+ // so the same wallet and message reproduce the same key on any machine: the
99
+ // vault survives a lost laptop without Prism holding an escrow copy. A
100
+ // passphrase, when given, is mixed into the salt, so a leaked signature alone
101
+ // is not enough to open the vault.
102
+ async function deriveRootKey(signature, wallet, passphrase) {
103
+ const material = await subtle.importKey("raw", fromHex(signature), "HKDF", false, ["deriveKey"]);
104
+ const salt = await subtle.digest(
105
+ "SHA-256",
106
+ encoder.encode(`prism.vault.kdf.v1\0${wallet}\0${passphrase ?? ""}`),
107
+ );
108
+ return subtle.deriveKey(
109
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(salt), info: encoder.encode("root") },
110
+ material,
111
+ { name: "AES-KW", length: 256 },
112
+ false,
113
+ ["wrapKey", "unwrapKey"],
114
+ );
115
+ }
116
+
117
+ async function gcm(usages) {
118
+ return subtle.generateKey({ name: "AES-GCM", length: 256 }, true, usages);
119
+ }
120
+
121
+ // Per-item data key, wrapped under the root. The indirection is what makes
122
+ // re-keying cheap: changing the wallet or passphrase rewraps 40 bytes per item
123
+ // instead of re-encrypting every card and passport in the vault.
124
+ async function seal(rootKey, plaintext, aad) {
125
+ const dataKey = await gcm(["encrypt", "decrypt"]);
126
+ const nonce = globalThis.crypto.getRandomValues(new Uint8Array(12));
127
+ const [ciphertext, wrapped] = await Promise.all([
128
+ subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: aad }, dataKey, plaintext),
129
+ subtle.wrapKey("raw", dataKey, rootKey, "AES-KW"),
130
+ ]);
131
+ return {
132
+ nonce,
133
+ ciphertext: new Uint8Array(ciphertext),
134
+ wrappedKey: new Uint8Array(wrapped),
135
+ };
136
+ }
137
+
138
+ async function unseal(rootKey, wrappedKey, nonce, ciphertext, aad) {
139
+ let dataKey;
140
+ try {
141
+ dataKey = await subtle.unwrapKey(
142
+ "raw",
143
+ wrappedKey,
144
+ rootKey,
145
+ "AES-KW",
146
+ { name: "AES-GCM", length: 256 },
147
+ false,
148
+ ["decrypt"],
149
+ );
150
+ } catch {
151
+ throw new VaultError("vault_key_mismatch", {
152
+ hint: "this vault key does not open that item; check the wallet and passphrase used to unlock",
153
+ });
154
+ }
155
+ try {
156
+ return new Uint8Array(
157
+ await subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: aad }, dataKey, ciphertext),
158
+ );
159
+ } catch {
160
+ throw new VaultError("vault_authentication_failed", {
161
+ hint: "the stored item does not match the account, slot, version and trust floor it was sealed with",
162
+ });
163
+ }
164
+ }
165
+
166
+ export class PrismVault {
167
+ #agent;
168
+ #rootKey = null;
169
+ #wallet = null;
170
+
171
+ constructor(agent) {
172
+ this.#agent = agent;
173
+ }
174
+
175
+ get unlocked() {
176
+ return this.#rootKey !== null;
177
+ }
178
+
179
+ /// Derives the vault key from a wallet signature. Nothing leaves this
180
+ /// process; the signature itself is discarded once the key exists.
181
+ async unlock({ passphrase = null } = {}) {
182
+ if (!this.#agent.session) await this.#agent.authenticate();
183
+ const wallet = vaultWallet(this.#agent.address);
184
+ const signature = await this.#agent.signVaultStatement(VAULT_KEY_STATEMENT);
185
+ this.#rootKey = await deriveRootKey(signature, wallet, passphrase);
186
+ this.#wallet = wallet;
187
+ return this;
188
+ }
189
+
190
+ /// The wallet whose vault is open. One wallet, one vault, whether it is
191
+ /// reached from a browser or from an agent.
192
+ get wallet() {
193
+ return this.#wallet;
194
+ }
195
+
196
+ lock() {
197
+ this.#rootKey = null;
198
+ this.#wallet = null;
199
+ }
200
+
201
+ #require() {
202
+ if (!this.#rootKey) throw new VaultError("vault_locked", { hint: "call unlock() first" });
203
+ }
204
+
205
+ /// Item ids and trust floors only. Names live inside the ciphertext unless
206
+ /// the caller passed a label, so a listing does not disclose what is stored.
207
+ async list() {
208
+ return this.#agent.vaultRequest("GET", ["items"]);
209
+ }
210
+
211
+ async releases() {
212
+ return this.#agent.vaultRequest("GET", ["releases"]);
213
+ }
214
+
215
+ /// Seals `value` and stores it. Pass the item returned by a previous put or
216
+ /// get as `replaces` to update it; omitting that creates a new item and
217
+ /// fails rather than overwriting an existing one.
218
+ async put(value, { itemId = null, replaces = null, trustFloor = DEFAULT_TRUST_FLOOR, label = "" } = {}) {
219
+ this.#require();
220
+ assertTrustFloor(trustFloor);
221
+ const id = replaces?.item_id ?? itemId ?? globalThis.crypto.randomUUID();
222
+ const version = replaces ? replaces.version + 1 : 1;
223
+ const plaintext = encoder.encode(typeof value === "string" ? value : JSON.stringify(value));
224
+ const aad = associatedData(this.#wallet, id, version, trustFloor);
225
+ const { nonce, ciphertext, wrappedKey } = await seal(this.#rootKey, plaintext, aad);
226
+ return this.#agent.vaultRequest("PUT", ["items", id], {
227
+ body: {
228
+ envelope: {
229
+ wrapped_key: b64url(wrappedKey),
230
+ nonce: b64url(nonce),
231
+ ciphertext: b64url(ciphertext),
232
+ },
233
+ min_trust_class: trustFloor,
234
+ label,
235
+ ...(replaces ? { previous_version: replaces.version } : {}),
236
+ },
237
+ });
238
+ }
239
+
240
+ /// Fetches and opens an item. Throws if the service returned anything other
241
+ /// than what this account sealed into that slot at that version.
242
+ async get(itemId, { json = false } = {}) {
243
+ this.#require();
244
+ const item = await this.#agent.vaultRequest("GET", ["items", itemId]);
245
+ const value = await this.open(item);
246
+ return json ? JSON.parse(value) : value;
247
+ }
248
+
249
+ /// Opens an item you already hold. Separate from `get` so a caller can pin a
250
+ /// version they recorded earlier and detect being served an older copy.
251
+ async open(item, { expectVersion = null } = {}) {
252
+ this.#require();
253
+ if (expectVersion !== null && item.version !== expectVersion) {
254
+ throw new VaultError("vault_version_rollback", {
255
+ expected: expectVersion,
256
+ served: item.version,
257
+ });
258
+ }
259
+ const aad = associatedData(
260
+ this.#wallet,
261
+ item.item_id,
262
+ item.version,
263
+ item.min_trust_class,
264
+ );
265
+ const plaintext = await unseal(
266
+ this.#rootKey,
267
+ fromB64url(item.envelope.wrapped_key),
268
+ fromB64url(item.envelope.nonce),
269
+ fromB64url(item.envelope.ciphertext),
270
+ aad,
271
+ );
272
+ return decoder.decode(plaintext);
273
+ }
274
+
275
+ async remove(itemId) {
276
+ return this.#agent.vaultRequest("DELETE", ["items", itemId]);
277
+ }
278
+
279
+ /// Authorizes an item into a running lease and returns its plaintext. The
280
+ /// control plane refuses when the lease's trust class is below the floor the
281
+ /// item was sealed with, so an agent cannot post its owner's card to a host
282
+ /// that can read it by getting a policy check wrong.
283
+ async releaseInto(lease, itemId, { json = false } = {}) {
284
+ this.#require();
285
+ const leaseId = lease?.leaseId ?? lease?.lease_id ?? lease;
286
+ if (!Number.isInteger(leaseId)) throw new VaultError("invalid_lease_handle");
287
+ const item = await this.#agent.vaultRequest("GET", ["items", itemId]);
288
+ await this.#agent.vaultRequest("POST", ["items", itemId, "release"], {
289
+ body: { lease_id: leaseId },
290
+ });
291
+ const value = await this.open(item);
292
+ return json ? JSON.parse(value) : value;
293
+ }
294
+
295
+ /// Whether `releaseInto` would be allowed, without recording a release.
296
+ /// Useful for an agent choosing capacity before it commits to a lease.
297
+ static permits(trustFloor, leaseTrustClass) {
298
+ return meetsFloor(assertTrustFloor(trustFloor), assertTrustFloor(leaseTrustClass));
299
+ }
300
+ }
301
+
302
+ export class VaultError extends Error {
303
+ constructor(code, body) {
304
+ super(`prism vault: ${code}`);
305
+ this.code = code;
306
+ this.body = body;
307
+ }
308
+ }