@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 +39 -3
- package/package.json +36 -9
- package/prism.mjs +102 -17
- package/vault.d.ts +75 -0
- package/vault.mjs +308 -0
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 /
|
|
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.
|
|
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": {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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": {
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/prismnetwork-tech/prism/issues"
|
|
43
|
+
},
|
|
19
44
|
"license": "Apache-2.0",
|
|
20
|
-
"publishConfig": {
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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"],
|
|
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({
|
|
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({
|
|
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
|
|
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
|
+
}
|