@uuaid/pillar 0.2.0-alpha.9 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/HOSTING-LIMITS.md +432 -0
  2. package/INTERACTION-CONVENTION.md +222 -1
  3. package/README.md +358 -17
  4. package/bin/carrier.mjs +7 -1
  5. package/bin/pillar-ask.mjs +1 -0
  6. package/bin/pillar-blob.mjs +139 -0
  7. package/bin/pillar-directory.mjs +232 -0
  8. package/bin/pillar-escrow.mjs +675 -0
  9. package/bin/pillar-responder.mjs +49 -3
  10. package/bin/pillar-seed.mjs +113 -13
  11. package/bin/pillar.mjs +299 -7
  12. package/package.json +30 -7
  13. package/src/blob/blob.mjs +510 -0
  14. package/src/blob/broker.mjs +129 -0
  15. package/src/blob/http.mjs +334 -0
  16. package/src/blob/store.mjs +55 -0
  17. package/src/escrow/box.mjs +43 -0
  18. package/src/escrow/escrow-service.mjs +538 -0
  19. package/src/escrow/rails/evm-rail.mjs +427 -0
  20. package/src/escrow/rails/index.mjs +41 -0
  21. package/src/escrow/rails/zilligon-rail.mjs +73 -0
  22. package/src/escrow/terms.mjs +368 -0
  23. package/src/escrow/token-registry.mjs +231 -0
  24. package/src/escrow/verify.mjs +119 -0
  25. package/src/escrow/zilligon.mjs +266 -0
  26. package/src/identity/keychain.mjs +27 -10
  27. package/src/identity/tier.mjs +130 -0
  28. package/src/identity/trust.mjs +63 -0
  29. package/src/index.mjs +28 -2
  30. package/src/mailbox/mailbox.mjs +33 -2
  31. package/src/net/carrier-client.mjs +93 -14
  32. package/src/net/carrier-server.mjs +212 -15
  33. package/src/net/delegation.mjs +209 -0
  34. package/src/net/envelope.mjs +81 -0
  35. package/src/net/link-offer.mjs +107 -0
  36. package/src/net/refusals.mjs +126 -0
  37. package/src/net/seed-registry.mjs +467 -0
  38. package/src/net/seed-tier.mjs +185 -0
  39. package/src/node-check.mjs +32 -0
  40. package/src/observability.mjs +141 -0
  41. package/src/pillar.mjs +180 -17
  42. package/src/vda/core.mjs +290 -0
  43. package/src/vda/directory.mjs +87 -0
  44. package/src/vda/instruments.mjs +246 -0
  45. package/src/vda/shims.mjs +77 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Blob challenge broker — single-use redemption tickets for blob transport.
3
+ *
4
+ * Flow: reserve (sender announces an upload) -> finalize (upload landed,
5
+ * challenge is armed) -> redeem (recipient burns it exactly once, and only
6
+ * after the registry-active sender key check passed). A challenge that was
7
+ * never finalized, was already redeemed, or whose holder key is no longer
8
+ * active is refused — the redeem counter is the anti-replay guard.
9
+ *
10
+ * DEV/TEST ONLY. Production uses PillarBlobHttpClient with the UUAID broker;
11
+ * this in-memory implementation exists for deterministic local tests.
12
+ */
13
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
14
+ import { randomBytes } from "node:crypto";
15
+ import { BlobBrokerError } from "./blob.mjs";
16
+
17
+ export class InMemoryChallengeBroker {
18
+ constructor({ reserveTtlMs = 5 * 60 * 1000, now = () => Date.now() } = {}) {
19
+ this.reserveTtlMs = reserveTtlMs;
20
+ this.now = now;
21
+ this.challenges = new Map(); // challengeId -> record
22
+ }
23
+
24
+ /** Step 1: sender announces an upload; the challenge starts un-finalized. */
25
+ async reserve({ sender, recipient, size }) {
26
+ if (!sender) throw new BlobBrokerError("blob-broker-bad-request", "reserve: sender required");
27
+ if (!Number.isInteger(size) || size <= 0) throw new BlobBrokerError("blob-broker-bad-request", "reserve: size required");
28
+ const challengeId = `blob-ch-${this.now().toString(36)}-${randomBytes(8).toString("hex")}`;
29
+ this.challenges.set(challengeId, {
30
+ sender, recipient: recipient ?? null, size,
31
+ reservedAt: this.now(), expiresAt: this.now() + this.reserveTtlMs,
32
+ state: "reserved", object: null, hash: null, redeemedAt: null,
33
+ });
34
+ return { challengeId, expiresAt: this.now() + this.reserveTtlMs };
35
+ }
36
+
37
+ /** Step 2: the upload landed — arm the challenge for redemption. */
38
+ async finalize({ challengeId, object, hash, size }) {
39
+ const rec = this.challenges.get(challengeId);
40
+ if (!rec) throw new BlobBrokerError("blob-broker-not-found", `finalize: unknown challenge ${challengeId}`, 404);
41
+ if (rec.state !== "reserved") throw new BlobBrokerError("blob-broker-bad-state", `finalize: challenge is ${rec.state}`);
42
+ if (typeof object !== "string" || !object) throw new BlobBrokerError("blob-broker-bad-request", "finalize: object required");
43
+ if (typeof hash !== "string" || !/^[0-9a-f]{64}$/i.test(hash)) throw new BlobBrokerError("blob-broker-bad-request", "finalize: hash required");
44
+ if (!Number.isInteger(size) || size <= 0 || size !== rec.size) {
45
+ throw new BlobBrokerError("blob-broker-bad-request", `finalize: size ${size} != reserved ${rec.size}`);
46
+ }
47
+ rec.object = object; rec.hash = hash.toLowerCase(); rec.state = "finalized"; rec.finalizedAt = this.now();
48
+ return { challengeId, state: rec.state };
49
+ }
50
+
51
+ /** Step 3: single-use redemption. Throws on replay, expiry, bad state. */
52
+ async redeem({ challengeId, object = null, hash = null, sender = null, now = null }) {
53
+ const rec = this.challenges.get(challengeId);
54
+ if (!rec) throw new BlobBrokerError("blob-broker-not-found", `redeem: unknown challenge ${challengeId}`, 404);
55
+ if (rec.state === "redeemed") {
56
+ throw new BlobBrokerError("blob-broker-already-redeemed", `challenge ${challengeId} already redeemed at ${rec.redeemedAt}`, 409);
57
+ }
58
+ if (rec.state !== "finalized") {
59
+ throw new BlobBrokerError("blob-broker-not-finalized", `challenge ${challengeId} never finalized (state ${rec.state})`, 409);
60
+ }
61
+ const t = now ?? this.now();
62
+ if (rec.expiresAt < t) {
63
+ throw new BlobBrokerError("blob-broker-expired", `challenge ${challengeId} expired at ${new Date(rec.expiresAt).toISOString()}`, 410);
64
+ }
65
+ if (object && object !== rec.object) throw new BlobBrokerError("blob-broker-mismatch", "redeem: object mismatch", 400);
66
+ if (hash && hash.toLowerCase() !== rec.hash) throw new BlobBrokerError("blob-broker-mismatch", "redeem: hash mismatch", 400);
67
+ if (sender && sender !== rec.sender) throw new BlobBrokerError("blob-broker-mismatch", "redeem: sender mismatch", 400);
68
+ rec.state = "redeemed"; rec.redeemedAt = t;
69
+ return { challengeId, redeemedAt: t, object: rec.object, sender: rec.sender };
70
+ }
71
+
72
+ /** Inspection only — never a substitute for redeem(). */
73
+ state(challengeId) { return this.challenges.get(challengeId) ?? null; }
74
+ }
75
+
76
+ /**
77
+ * Append-only JSONL journal on top of the in-memory broker. The journal IS
78
+ * the persistence: construction replays it, so a CLI `put` in one process and
79
+ * `get` (redemption) in another share the same challenge ledger.
80
+ */
81
+ export class JournalingBlobBroker extends InMemoryChallengeBroker {
82
+ constructor({ journalPath = null, ...rest } = {}) {
83
+ super(rest);
84
+ this.journalPath = journalPath;
85
+ this.journal = [];
86
+ if (journalPath && existsSync(journalPath)) this._replay(journalPath);
87
+ }
88
+ _replay(path) {
89
+ for (const line of readFileSync(path, "utf-8").split("\n")) {
90
+ if (!line.trim()) continue;
91
+ let e;
92
+ try { e = JSON.parse(line); } catch (_err) { continue; }
93
+ this.journal.push(e);
94
+ const cid = e.challengeId;
95
+ if (e.op === "reserve") {
96
+ this.challenges.set(cid, {
97
+ sender: e.sender, recipient: e.recipient ?? null, size: e.size,
98
+ reservedAt: e.at, expiresAt: e.expiresAt,
99
+ state: "reserved", object: null, hash: null, redeemedAt: null,
100
+ });
101
+ } else if (e.op === "finalize") {
102
+ const rec = this.challenges.get(cid);
103
+ if (rec) { rec.object = e.object; rec.hash = e.hash; rec.state = "finalized"; rec.finalizedAt = e.at; }
104
+ } else if (e.op === "redeem") {
105
+ const rec = this.challenges.get(cid);
106
+ if (rec) { rec.state = "redeemed"; rec.redeemedAt = e.at; }
107
+ }
108
+ }
109
+ }
110
+ async reserve(o) {
111
+ const r = await super.reserve(o);
112
+ this._log({ op: "reserve", challengeId: r.challengeId, sender: o.sender, recipient: o.recipient ?? null, size: o.size, expiresAt: r.expiresAt, at: this.now() });
113
+ return r;
114
+ }
115
+ async finalize(o) {
116
+ const r = await super.finalize(o);
117
+ this._log({ op: "finalize", challengeId: o.challengeId, object: o.object, hash: o.hash, at: this.now() });
118
+ return r;
119
+ }
120
+ async redeem(o) {
121
+ const r = await super.redeem(o);
122
+ this._log({ op: "redeem", challengeId: o.challengeId, sender: r.sender, at: r.redeemedAt });
123
+ return r;
124
+ }
125
+ _log(entry) {
126
+ this.journal.push(entry);
127
+ if (this.journalPath) appendFileSync(this.journalPath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
128
+ }
129
+ }
@@ -0,0 +1,334 @@
1
+ import { createHash } from "node:crypto";
2
+ import { jcs } from "../identity/jcs.mjs";
3
+ import { Keychain } from "../identity/keychain.mjs";
4
+ import {
5
+ BlobBrokerError,
6
+ BlobExpiryError,
7
+ BlobFetchError,
8
+ BlobIntegrityError,
9
+ BlobNotFoundError,
10
+ BlobSenderKeyError,
11
+ BlobUploadError,
12
+ SELF_CERTIFIED_KEY_ID,
13
+ isSelfCertifiedUuaid,
14
+ senderSelfCertified,
15
+ } from "./blob.mjs";
16
+ import { REFUSAL_FIXES } from "../net/refusals.mjs";
17
+
18
+ export const PILLAR_BLOB_AUDIENCE = "uuaid:pillar-blob:v1";
19
+ export { SELF_CERTIFIED_KEY_ID };
20
+ const KEY_ID_TTL_MS = 300_000;
21
+
22
+ function payloadHash(payload) {
23
+ return `0x${createHash("sha256").update(jcs(payload)).digest("hex")}`;
24
+ }
25
+
26
+ function signedRequest(keychain, keyId, challenge, request) {
27
+ if (!keyId) throw new BlobBrokerError("blob-broker-key-id-required", "a presentation key id is required (registry badge key or self-certified:ed25519)");
28
+ const payload = { v: 1, audience: PILLAR_BLOB_AUDIENCE, challenge, request };
29
+ return {
30
+ payload,
31
+ payloadHash: payloadHash(payload),
32
+ signatures: [{
33
+ alg: "ed25519",
34
+ keyId,
35
+ publicKey: keychain._identity.publicKeyHex,
36
+ signature: keychain.sign(Buffer.from(jcs(payload), "utf-8")).toString("hex"),
37
+ created: new Date().toISOString(),
38
+ }],
39
+ };
40
+ }
41
+
42
+ /** `{ code, fix, body }` from a refusal body; empty for a non-JSON one (e.g. S3 XML). */
43
+ async function refusalOf(response) {
44
+ const body = await response.json().catch(() => null);
45
+ if (!body || typeof body !== "object" || Array.isArray(body)) return {};
46
+ return {
47
+ code: typeof body.code === "string" && body.code ? body.code : null,
48
+ fix: typeof body.fix === "string" && body.fix ? body.fix : null,
49
+ body,
50
+ };
51
+ }
52
+
53
+ /** Put the broker's code/fix on a typed error; its own code moves to legacyCode. */
54
+ function withRefusal(error, { code = null, fix = null, body = null } = {}) {
55
+ if (code && code !== error.code) { error.legacyCode = error.code; error.code = code; }
56
+ if (fix) error.fix = fix;
57
+ if (body) error.body = body;
58
+ return error;
59
+ }
60
+
61
+ function safeBase(url) {
62
+ const parsed = new URL(url);
63
+ if (parsed.protocol !== "https:" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") {
64
+ throw new BlobBrokerError("blob-broker-insecure-url", "blob broker must use HTTPS");
65
+ }
66
+ return parsed.href.replace(/\/+$/, "");
67
+ }
68
+
69
+ /**
70
+ * HTTP client for the UUAID broker. URLs are returned only to the live caller.
71
+ *
72
+ * `keyId` is optional. Given, it is presented as is. Omitted, the client
73
+ * resolves one per identity (resolveKeyId): the registry badge's key when a
74
+ * badge names this exact key, else "self-certified:ed25519" when the UUAID is
75
+ * exactly the key's uuaid:foundation:agent UUAID, else a `blob-key-unproven`
76
+ * refusal.
77
+ */
78
+ /**
79
+ * The brokers this package talks to without being told, same list and order as
80
+ * @uuaid/pillar-client (hosts.ts BLOB_BROKERS). pillar-sfo runs broker 1.1.0,
81
+ * which also serves self-certified identities; api.uuaid.org serves registry
82
+ * badges only, so it is no longer the default (pass --broker to use it).
83
+ */
84
+ export const BLOB_BROKERS = Object.freeze(["https://pillar-sfo.uuaid.org/blob", "https://pillar-nyc.uuaid.org/blob"]);
85
+ export const DEFAULT_BLOB_BROKER = BLOB_BROKERS[0];
86
+
87
+ export class PillarBlobHttpClient {
88
+ constructor({ baseUrl = DEFAULT_BLOB_BROKER, keychain, keyId = null, fetchImpl = globalThis.fetch, timeoutMs = 15_000 } = {}) {
89
+ if (!keychain?._identity) throw new BlobBrokerError("blob-broker-no-identity", "loaded keychain required");
90
+ if (typeof fetchImpl !== "function") throw new BlobBrokerError("blob-broker-no-fetch", "fetch implementation required");
91
+ this.baseUrl = safeBase(baseUrl);
92
+ this.keychain = keychain;
93
+ this.keyId = keyId || null;
94
+ this.fetch = fetchImpl;
95
+ this.timeoutMs = timeoutMs;
96
+ this._trust = null;
97
+ this._resolvedKeyId = null;
98
+ }
99
+
100
+ async _json(path, { method = "POST", body } = {}) {
101
+ let response;
102
+ try {
103
+ response = await this.fetch(`${this.baseUrl}${path}`, {
104
+ method,
105
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
106
+ body: body === undefined ? undefined : JSON.stringify(body),
107
+ signal: AbortSignal.timeout(this.timeoutMs),
108
+ });
109
+ } catch (error) {
110
+ throw new BlobBrokerError("blob-broker-unreachable", `blob broker request failed: ${error.message}`);
111
+ }
112
+ const parsed = await response.json().catch(() => ({}));
113
+ if (!response.ok) {
114
+ const legacyCode = response.status === 404 ? "blob-broker-not-found"
115
+ : response.status === 410 ? "blob-broker-expired"
116
+ : response.status === 409 ? "blob-broker-replay"
117
+ : "blob-broker-rejected";
118
+ // The broker's own code + fix win; an older broker sends neither.
119
+ const brokerCode = typeof parsed?.code === "string" && parsed.code ? parsed.code : null;
120
+ const fix = typeof parsed?.fix === "string" && parsed.fix ? parsed.fix : null;
121
+ throw new BlobBrokerError(brokerCode ?? legacyCode, parsed?.error ?? `blob broker returned ${response.status}`, response.status, {
122
+ fix,
123
+ legacyCode,
124
+ body: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null,
125
+ });
126
+ }
127
+ return parsed;
128
+ }
129
+
130
+ /**
131
+ * The keyId this identity presents, strongest proof first:
132
+ * 1. the constructor's explicit `keyId`;
133
+ * 2. the registry badge's presentation keyId, when a fresh registry-signed
134
+ * badge for this UUAID names this exact public key (badge limits);
135
+ * 3. "self-certified:ed25519", when this UUAID is exactly
136
+ * `uuaid:foundation:agent:` + the key's local id (self-certified
137
+ * limits; the full string, as the broker compares it);
138
+ * else throws `blob-key-unproven`. A badge that names another key, a
139
+ * missing badge and an unreachable registry all fall through to 3.
140
+ * Cached for 5 minutes.
141
+ */
142
+ async resolveKeyId() {
143
+ if (this.keyId) return this.keyId;
144
+ if (this._resolvedKeyId && this._resolvedKeyId.until > Date.now()) return this._resolvedKeyId.value;
145
+ const { uuaid, publicKeyHex } = this.keychain._identity;
146
+ let value = null;
147
+ let badgeProblem = "no registry badge names this key";
148
+ try {
149
+ const presentation = await this._verifiedPresentationKey(uuaid);
150
+ if (presentation && presentation.publicKey.toLowerCase() === publicKeyHex.toLowerCase()
151
+ && typeof presentation.keyId === "string" && presentation.keyId) {
152
+ value = presentation.keyId;
153
+ }
154
+ } catch (error) {
155
+ badgeProblem = `registry badge lookup failed (${error.code ?? error.message})`;
156
+ }
157
+ if (!value && isSelfCertifiedUuaid(uuaid, publicKeyHex)) value = SELF_CERTIFIED_KEY_ID;
158
+ if (!value) {
159
+ throw new BlobBrokerError(
160
+ "blob-key-unproven",
161
+ `${uuaid} cannot sign broker requests: ${badgeProblem}, and it is not the uuaid:foundation:agent UUAID derived from its key`,
162
+ null,
163
+ { fix: REFUSAL_FIXES["blob-key-unproven"] },
164
+ );
165
+ }
166
+ this._resolvedKeyId = { value, until: Date.now() + KEY_ID_TTL_MS };
167
+ return value;
168
+ }
169
+
170
+ challenge(action, objectId = null) {
171
+ return this._json("/pillar/blobs/challenge", {
172
+ body: {
173
+ action,
174
+ agent_uuaid: this.keychain._identity.uuaid,
175
+ ...(objectId ? { object_id: objectId } : {}),
176
+ },
177
+ }).then((body) => body.challenge);
178
+ }
179
+
180
+ async reserve(challenge, { recipient, ciphertextSha256, ciphertextBytes, expiresAt }) {
181
+ const keyId = await this.resolveKeyId();
182
+ return this._json("/pillar/blobs/reserve", {
183
+ body: signedRequest(this.keychain, keyId, challenge, {
184
+ action: "reserve",
185
+ agent_uuaid: this.keychain._identity.uuaid,
186
+ recipient_uuaid: recipient,
187
+ ciphertext_sha256: ciphertextSha256,
188
+ ciphertext_bytes: ciphertextBytes,
189
+ expires_at: expiresAt,
190
+ }),
191
+ });
192
+ }
193
+
194
+ async finalize(objectId) {
195
+ const keyId = await this.resolveKeyId();
196
+ const challenge = await this.challenge("finalize", objectId);
197
+ return this._json("/pillar/blobs/finalize", {
198
+ body: signedRequest(this.keychain, keyId, challenge, {
199
+ action: "finalize",
200
+ agent_uuaid: this.keychain._identity.uuaid,
201
+ }),
202
+ });
203
+ }
204
+
205
+ async redeem(objectId) {
206
+ const keyId = await this.resolveKeyId();
207
+ const challenge = await this.challenge("redeem", objectId);
208
+ return this._json("/pillar/blobs/redeem", {
209
+ body: signedRequest(this.keychain, keyId, challenge, {
210
+ action: "redeem",
211
+ agent_uuaid: this.keychain._identity.uuaid,
212
+ }),
213
+ });
214
+ }
215
+
216
+ async upload(ticket, ciphertext) {
217
+ let response;
218
+ try {
219
+ response = await this.fetch(ticket.url, {
220
+ method: "PUT",
221
+ headers: ticket.headers,
222
+ body: ciphertext,
223
+ signal: AbortSignal.timeout(this.timeoutMs),
224
+ });
225
+ } catch (error) {
226
+ throw new BlobUploadError(`temporary blob upload failed: ${error.message}`, error);
227
+ }
228
+ if (!response.ok) {
229
+ const cause = new Error(`upload returned ${response.status}`);
230
+ throw withRefusal(new BlobUploadError(`temporary blob upload failed: ${cause.message}`, cause), await refusalOf(response));
231
+ }
232
+ }
233
+
234
+ async download(ticket) {
235
+ let response;
236
+ try {
237
+ response = await this.fetch(ticket.url, { signal: AbortSignal.timeout(this.timeoutMs) });
238
+ } catch (error) {
239
+ throw new BlobFetchError(`temporary blob download failed: ${error.message}`, error);
240
+ }
241
+ if (!response.ok) {
242
+ const error = response.status === 404 ? new BlobNotFoundError("temporary-object")
243
+ : response.status === 403 ? new BlobExpiryError("temporary blob download capability expired")
244
+ : new BlobFetchError(`temporary blob download returned ${response.status}`);
245
+ throw withRefusal(error, await refusalOf(response));
246
+ }
247
+ return Buffer.from(await response.arrayBuffer());
248
+ }
249
+
250
+ async _registryTrust() {
251
+ if (this._trust && this._trust.until > Date.now()) return this._trust.value;
252
+ const value = await this._json("/.well-known/uuaid-registry.json", { method: "GET" });
253
+ if (value.provisioned !== true || !Array.isArray(value.keys)) {
254
+ throw new BlobSenderKeyError("registry trust root is not provisioned", "unknown", "unprovisioned");
255
+ }
256
+ this._trust = { value, until: Date.now() + 300_000 };
257
+ return value;
258
+ }
259
+
260
+ /**
261
+ * The presentation key ({ alg, keyId, publicKey }) of a fresh badge for
262
+ * `uuaid` whose payload hash matches and whose ed25519 signature pins to a
263
+ * live key of the registry trust root; null when there is no badge (404)
264
+ * or it fails any of those checks. Other broker errors propagate.
265
+ */
266
+ async _verifiedPresentationKey(uuaid) {
267
+ let badge;
268
+ try {
269
+ badge = await this._json(`/iaaso/v1/badge/${encodeURIComponent(uuaid)}`, { method: "GET" });
270
+ } catch (error) {
271
+ if (error?.status === 404) return null;
272
+ throw error;
273
+ }
274
+ const trust = await this._registryTrust();
275
+ const presentation = badge?.payload?.subject?.presentationKey;
276
+ const subjectMatches = badge?.payload?.subject?.uuaid === uuaid
277
+ && presentation?.alg === "ed25519"
278
+ && typeof presentation?.publicKey === "string";
279
+ const fresh = Number.isFinite(Date.parse(badge?.payload?.freshUntil)) && Date.parse(badge.payload.freshUntil) > Date.now();
280
+ let hashMatches = false;
281
+ try { hashMatches = badge?.payloadHash === payloadHash(badge?.payload); } catch (_error) { hashMatches = false; }
282
+ const registrySignature = Array.isArray(badge?.signatures)
283
+ ? badge.signatures.find((signature) => {
284
+ if (signature.alg !== "ed25519") return false;
285
+ const pinned = trust.keys.find((key) => key.keyId === signature.keyId && key.alg === "ed25519" && key.retired !== true);
286
+ if (!pinned || pinned.publicKey.toLowerCase() !== signature.publicKey?.toLowerCase()) return false;
287
+ try {
288
+ return Keychain.verifyDetached(signature.publicKey, Buffer.from(jcs(badge.payload), "utf-8"), Buffer.from(signature.signature, "hex"));
289
+ } catch (_error) { return false; }
290
+ })
291
+ : null;
292
+ return subjectMatches && fresh && hashMatches && registrySignature ? presentation : null;
293
+ }
294
+
295
+ /**
296
+ * Fail closed unless a fresh registry-signed badge names this exact active
297
+ * key. The receive flows call this only for a sender whose UUAID does not
298
+ * derive from its signing key; see assertSenderKey.
299
+ */
300
+ async assertRegistryActiveSender(sender, expectedPublicKey) {
301
+ const presentation = await this._verifiedPresentationKey(sender);
302
+ const keyMatches = typeof expectedPublicKey === "string"
303
+ && presentation?.publicKey.toLowerCase() === expectedPublicKey.toLowerCase();
304
+ if (!keyMatches) {
305
+ throw new BlobSenderKeyError("sender presentation key is not registry-active", sender, "inactive");
306
+ }
307
+ return "active";
308
+ }
309
+
310
+ /**
311
+ * Recipient sender gate for a received blob-ref. "self-certified" when the
312
+ * ref's signature verifies and its sender is exactly that signing key's
313
+ * uuaid:foundation:agent UUAID (proven key holder, unverified identity);
314
+ * otherwise "active" when a registry badge names that key; otherwise
315
+ * throws BlobSenderKeyError (code sender-key-not-active, with a fix).
316
+ */
317
+ async assertSenderKey(ref) {
318
+ if (senderSelfCertified(ref)) return "self-certified";
319
+ return this.assertRegistryActiveSender(ref?.sender, ref?.sig?.publicKey);
320
+ }
321
+
322
+ crossCheckRedemption(ref, brokerRecord) {
323
+ if (
324
+ brokerRecord?.object_id !== ref.object ||
325
+ brokerRecord?.sender_uuaid !== ref.sender ||
326
+ brokerRecord?.recipient_uuaid !== ref.recipient ||
327
+ brokerRecord?.ciphertext_sha256 !== ref.hash ||
328
+ brokerRecord?.ciphertext_bytes !== ref.size ||
329
+ brokerRecord?.expires_at !== ref.expiry
330
+ ) {
331
+ throw new BlobIntegrityError("broker metadata does not match the signed blob-ref");
332
+ }
333
+ }
334
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Blob stores — where encrypted blob objects (ciphertext only) live while
3
+ * they wait to be redeemed. Stores hold NO plaintext and NO key material:
4
+ * the DEK travels only inside the sealed envelope's blob-ref.
5
+ *
6
+ * Temporary-bucket semantics: objects are ephemeral; the blob-ref carries
7
+ * the expiry and the receive path re-checks it. Stores SHOULD garbage-
8
+ * collect expired objects, but an object living past its expiry is inert —
9
+ * the ref is refused before any fetch result is trusted.
10
+ */
11
+ import { mkdirSync } from "node:fs";
12
+ import { readFile, writeFile, rm, access } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { BlobUploadError } from "./blob.mjs";
15
+
16
+ export class MemoryBlobStore {
17
+ constructor() { this._map = new Map(); }
18
+ async put(object, buf) { this._map.set(object, Buffer.from(buf)); return { object, bytes: buf.length }; }
19
+ async get(object) { return this._map.get(object) ?? null; }
20
+ async has(object) { return this._map.has(object); }
21
+ async delete(object) { return this._map.delete(object); }
22
+ get size() { return this._map.size; }
23
+ }
24
+
25
+ export class FsBlobStore {
26
+ constructor({ dir }) {
27
+ if (!dir) throw new Error("FsBlobStore: dir required");
28
+ this.dir = dir;
29
+ this._ready = mkdirSync(dir, { recursive: true });
30
+ }
31
+ _path(object) {
32
+ // Object names are opaque 256-bit hex ids; encode defensively anyway.
33
+ return join(this.dir, encodeURIComponent(object));
34
+ }
35
+ async put(object, buf) {
36
+ try {
37
+ await writeFile(this._path(object), buf);
38
+ return { object, bytes: buf.length };
39
+ } catch (e) {
40
+ throw new BlobUploadError(`FsBlobStore write failed for ${object}: ${e.message}`, e);
41
+ }
42
+ }
43
+ async get(object) {
44
+ try { return await readFile(this._path(object)); }
45
+ catch (e) { if (e?.code === "ENOENT") return null; throw e; }
46
+ }
47
+ async has(object) {
48
+ try { await access(this._path(object)); return true; }
49
+ catch (_e) { return false; }
50
+ }
51
+ async delete(object) {
52
+ try { await rm(this._path(object), { force: true }); return true; }
53
+ catch (_e) { return false; }
54
+ }
55
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Content box — symmetric AES-256-GCM under the escrowed 32-byte content
3
+ * key K. The seller seals the deliverable with K and sends the box directly
4
+ * to the buyer; the escrow holds only K (and the sha256 of the plaintext, in
5
+ * the signed terms) — it never sees the payload. The buyer can only open
6
+ * the box after K is released, and the GCM tag makes any tamper or wrong key
7
+ * fail loudly.
8
+ */
9
+ import { randomBytes, createCipheriv, createDecipheriv, createHash } from "node:crypto";
10
+
11
+ export const BOX_ALG = "aes-256-gcm";
12
+
13
+ export function sealBox(key32, plaintext) {
14
+ if (!Buffer.isBuffer(key32) || key32.length !== 32) throw new Error("sealBox: key must be 32 bytes");
15
+ const iv = randomBytes(12);
16
+ const c = createCipheriv(BOX_ALG, key32, iv);
17
+ const ct = Buffer.concat([c.update(plaintext), c.final()]);
18
+ return {
19
+ alg: BOX_ALG,
20
+ iv: iv.toString("hex"),
21
+ ct: Buffer.concat([ct, c.getAuthTag()]).toString("hex"), // tag appended to ct
22
+ };
23
+ }
24
+
25
+ /** Throws on wrong key, tamper, or malformed box — never returns garbage. */
26
+ export function openBox(key32, box) {
27
+ if (!Buffer.isBuffer(key32) || key32.length !== 32) throw new Error("openBox: key must be 32 bytes");
28
+ if (!box || box.alg !== BOX_ALG || typeof box.iv !== "string" || typeof box.ct !== "string") {
29
+ throw new Error("openBox: bad box");
30
+ }
31
+ const iv = Buffer.from(box.iv, "hex");
32
+ const buf = Buffer.from(box.ct, "hex");
33
+ if (iv.length !== 12 || buf.length < 16) throw new Error("openBox: bad lengths");
34
+ const ct = buf.subarray(0, buf.length - 16);
35
+ const tag = buf.subarray(buf.length - 16);
36
+ const d = createDecipheriv(BOX_ALG, key32, iv);
37
+ d.setAuthTag(tag);
38
+ return Buffer.concat([d.update(ct), d.final()]);
39
+ }
40
+
41
+ export function sha256Hex(buf) {
42
+ return createHash("sha256").update(buf).digest("hex");
43
+ }