@prismnetwork/agent-sdk 0.2.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 +36 -0
- package/package.json +10 -4
- package/prism.mjs +63 -2
- package/vault.d.ts +75 -0
- package/vault.mjs +308 -0
package/README.md
CHANGED
|
@@ -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,27 @@
|
|
|
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
7
|
"exports": {
|
|
8
|
-
".": "./prism.mjs"
|
|
8
|
+
".": "./prism.mjs",
|
|
9
|
+
"./vault": {
|
|
10
|
+
"types": "./vault.d.ts",
|
|
11
|
+
"default": "./vault.mjs"
|
|
12
|
+
}
|
|
9
13
|
},
|
|
10
14
|
"files": [
|
|
11
15
|
"prism.mjs",
|
|
16
|
+
"vault.mjs",
|
|
17
|
+
"vault.d.ts",
|
|
12
18
|
"README.md"
|
|
13
19
|
],
|
|
14
20
|
"engines": {
|
|
15
21
|
"node": ">=20"
|
|
16
22
|
},
|
|
17
23
|
"peerDependencies": {
|
|
18
|
-
"viem": "^2"
|
|
24
|
+
"viem": "^2.55.11"
|
|
19
25
|
},
|
|
20
26
|
"keywords": [
|
|
21
27
|
"prism",
|
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,
|
|
@@ -46,6 +49,20 @@ const escrowAbi = parseAbi([
|
|
|
46
49
|
"function createLease(bytes32 nodeId, uint32 duration, bytes32 clientReference) returns (uint256)",
|
|
47
50
|
]);
|
|
48
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
|
+
|
|
49
66
|
function assertTrustClass(value) {
|
|
50
67
|
if (!TRUST_CLASSES.includes(value)) {
|
|
51
68
|
throw new PrismError(400, "invalid_trust_class", { expected: TRUST_CLASSES });
|
|
@@ -90,12 +107,23 @@ export class PrismAgent {
|
|
|
90
107
|
this.publicClient = createPublicClient({ chain: robinhoodChain, transport });
|
|
91
108
|
this.walletClient = createWalletClient({ account: this.account, chain: robinhoodChain, transport });
|
|
92
109
|
this.session = null;
|
|
110
|
+
this.vault = new PrismVault(this);
|
|
93
111
|
}
|
|
94
112
|
|
|
95
113
|
get address() {
|
|
96
114
|
return this.account.address;
|
|
97
115
|
}
|
|
98
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
|
+
|
|
99
127
|
async authenticate() {
|
|
100
128
|
const challenge = await this.#json(`/api/agent/challenge?address=${this.address}`);
|
|
101
129
|
const signature = await this.account.signMessage({ message: challenge.message });
|
|
@@ -136,7 +164,7 @@ export class PrismAgent {
|
|
|
136
164
|
}
|
|
137
165
|
}
|
|
138
166
|
|
|
139
|
-
async quote({ image, durationSeconds, minVramMib = 16000, preferredNodeId = null, minTrustClass = "open" } = {}) {
|
|
167
|
+
async quote({ image, durationSeconds, minVramMib = 16000, preferredNodeId = null, minTrustClass = "open", command = null } = {}) {
|
|
140
168
|
if (typeof image !== "string" || !/@sha256:[0-9a-f]{64}$/.test(image)) {
|
|
141
169
|
throw new PrismError(400, "image_must_be_digest_pinned", { hint: "use ollama@sha256:... or DEFAULT_IMAGE" });
|
|
142
170
|
}
|
|
@@ -150,6 +178,7 @@ export class PrismAgent {
|
|
|
150
178
|
min_vram_mib: minVramMib,
|
|
151
179
|
preferred_node_id: preferredNodeId,
|
|
152
180
|
min_trust_class: assertTrustClass(minTrustClass),
|
|
181
|
+
...(command === null ? {} : { command: assertCommand(command) }),
|
|
153
182
|
},
|
|
154
183
|
},
|
|
155
184
|
});
|
|
@@ -215,6 +244,22 @@ export class PrismAgent {
|
|
|
215
244
|
return this.#proxy("GET", ["leases", String(leaseId), "access"]);
|
|
216
245
|
}
|
|
217
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
|
+
|
|
218
263
|
async waitForAccess(leaseId, { timeoutMs = 600_000, intervalMs = 10_000 } = {}) {
|
|
219
264
|
const deadline = Date.now() + timeoutMs;
|
|
220
265
|
while (Date.now() < deadline) {
|
|
@@ -237,9 +282,17 @@ export class PrismAgent {
|
|
|
237
282
|
preferredNodeId = null,
|
|
238
283
|
maxDeposit = null,
|
|
239
284
|
minTrustClass = "open",
|
|
285
|
+
command = null,
|
|
240
286
|
} = {}) {
|
|
241
287
|
if (!this.session) await this.authenticate();
|
|
242
|
-
const quote = await this.quote({
|
|
288
|
+
const quote = await this.quote({
|
|
289
|
+
image,
|
|
290
|
+
durationSeconds,
|
|
291
|
+
minVramMib,
|
|
292
|
+
preferredNodeId,
|
|
293
|
+
minTrustClass,
|
|
294
|
+
command,
|
|
295
|
+
});
|
|
243
296
|
if (maxDeposit != null && parseBaseUnits(quote.maximum_escrow, "maximum_escrow") > BigInt(maxDeposit)) {
|
|
244
297
|
throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
|
|
245
298
|
}
|
|
@@ -252,6 +305,14 @@ export class PrismAgent {
|
|
|
252
305
|
sshAuthorizedKey: key.publicKey,
|
|
253
306
|
});
|
|
254
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
|
+
}
|
|
255
316
|
const access = await this.waitForAccess(record.lease_id);
|
|
256
317
|
return {
|
|
257
318
|
leaseId: record.lease_id,
|
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
|
+
}
|