@uuaid/pillar 0.2.0-alpha.9 → 2.0.1

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 +47 -1
  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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@uuaid/pillar",
3
- "version": "0.2.0-alpha.9",
4
- "description": "IAASO-3050 reference implementation. Every agent runs its own PDP pillar, discovers peers via a Kademlia DHT, and exchanges cryptographically signed + Noise-encrypted messages over libp2p. One command to install and join the network.",
3
+ "version": "2.0.1",
4
+ "description": "Pillar (Protocol for Inter-agent Linked Ledger And Relay) \u2014 the secure delivery layer of UACP, the Universal Agent Communication Protocol. UACP = UUAID (identity) + Envelope (signed, encrypted container) + Pillar (relay, mailbox, routing, store-and-forward). Reference implementation of the UACP delivery layer; includes VDA-1 (IAASO-3901, in ballot) delivery attestation and a blind escrow for the Zilligon ZGN / EVM rails.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "main": "src/index.mjs",
@@ -10,29 +10,52 @@
10
10
  "pillar-carrier": "bin/carrier.mjs",
11
11
  "pillar-ask": "bin/pillar-ask.mjs",
12
12
  "pillar-responder": "bin/pillar-responder.mjs",
13
- "pillar-seed": "bin/pillar-seed.mjs"
13
+ "pillar-seed": "bin/pillar-seed.mjs",
14
+ "pillar-directory": "bin/pillar-directory.mjs",
15
+ "pillar-escrow": "bin/pillar-escrow.mjs",
16
+ "pillar-blob": "bin/pillar-blob.mjs"
14
17
  },
15
18
  "exports": {
16
- ".": "./src/index.mjs"
19
+ ".": "./src/index.mjs",
20
+ "./keychain": "./src/identity/keychain.mjs",
21
+ "./jcs": "./src/identity/jcs.mjs",
22
+ "./tier": "./src/identity/tier.mjs",
23
+ "./envelope": "./src/net/envelope.mjs",
24
+ "./delegation": "./src/net/delegation.mjs",
25
+ "./e2e": "./src/crypto/e2e.mjs",
26
+ "./carrier-client": "./src/net/carrier-client.mjs",
27
+ "./seed-tier": "./src/net/seed-tier.mjs",
28
+ "./seed-registry": "./src/net/seed-registry.mjs",
29
+ "./blob": "./src/blob/blob.mjs",
30
+ "./blob/http": "./src/blob/http.mjs",
31
+ "./blob/store": "./src/blob/store.mjs",
32
+ "./blob/broker": "./src/blob/broker.mjs",
33
+ "./refusals": "./src/net/refusals.mjs",
34
+ "./package.json": "./package.json"
17
35
  },
18
36
  "files": [
19
37
  "src",
20
38
  "bin",
21
39
  "README.md",
22
40
  "INTERACTION-CONVENTION.md",
41
+ "HOSTING-LIMITS.md",
23
42
  "LICENSE"
24
43
  ],
25
44
  "scripts": {
26
45
  "test": "node --test --test-reporter spec test/unit/*.test.mjs",
27
46
  "test:int": "node --test --test-reporter spec test/integration/*.test.mjs",
28
47
  "test:chaos": "node --test --test-reporter spec test/chaos/*.test.mjs",
29
- "test:all": "node --test --test-reporter spec test/unit/*.test.mjs test/integration/*.test.mjs test/chaos/*.test.mjs"
48
+ "test:all": "node --test --test-reporter spec test/unit/*.test.mjs test/integration/*.test.mjs test/chaos/*.test.mjs",
49
+ "preflight": "node scripts/preflight-publish.mjs",
50
+ "prepublishOnly": "node scripts/preflight-publish.mjs",
51
+ "postpublish": "node scripts/verify-publish.mjs"
30
52
  },
31
53
  "engines": {
32
- "node": ">=20"
54
+ "node": ">=22"
33
55
  },
34
56
  "dependencies": {
35
57
  "@chainsafe/libp2p-noise": "^17.0.0",
58
+ "@noble/hashes": "^2.3.0",
36
59
  "@chainsafe/libp2p-yamux": "^8.0.1",
37
60
  "@libp2p/autonat": "^3.0.26",
38
61
  "@libp2p/bootstrap": "^12.0.29",
@@ -48,7 +71,7 @@
48
71
  "@libp2p/websockets": "^10.1.19",
49
72
  "@multiformats/multiaddr": "^13.0.3",
50
73
  "@noble/curves": "^2.3.0",
51
- "better-sqlite3": "^11.10.0",
74
+ "better-sqlite3": "^12.11.1",
52
75
  "it-pipe": "^3.0.1",
53
76
  "it-pushable": "^3.2.4",
54
77
  "libp2p": "^3.3.8",
@@ -0,0 +1,510 @@
1
+ /**
2
+ * Blob transport — the oversized-payload path for pillar envelopes.
3
+ *
4
+ * Inline envelopes are capped by the carrier tier ladder (community
5
+ * 512 KiB ... 2 MiB; identity/tier.mjs — those limits are NOT redefined
6
+ * here) and by ABSOLUTE_MAX_BODY as the direct hard cap on ANY send.
7
+ * Above the direct threshold the payload travels out-of-band instead:
8
+ *
9
+ * 1. ENCRYPT locally — a fresh random AES-256-GCM DEK per object and a
10
+ * fresh 12-byte nonce. The GCM AAD binds the ciphertext to the exact
11
+ * envelope that will carry its reference:
12
+ * jcs({ envelopeId, sender, recipient, object, v, expiry })
13
+ * so a ciphertext cannot be transplanted into another envelope, sender,
14
+ * recipient, object name, protocol version, or lifetime.
15
+ * 2. UPLOAD the ciphertext to a blob store (temporary-bucket semantics —
16
+ * objects are ephemeral; expiry travels in the ref and is re-checked
17
+ * on fetch).
18
+ * 3. SEND a signed blob-ref inside the normal sealed envelope. The ref
19
+ * carries DEK + content hash + size; its metadata view (what any
20
+ * carrier-side log/telemetry may ever see) carries none of those.
21
+ * The serialized ref must stay <= 4 KiB.
22
+ * 4. REDEEM on receipt: recipient checks the ref signature + expiry +
23
+ * recipient binding, proves the sender (its UUAID derives from the key
24
+ * that signed the ref, or else a registry badge names that key),
25
+ * fetches and hash-checks the object, redeems the broker challenge
26
+ * (single-use), then decrypts.
27
+ *
28
+ * Error contract: every failure throws a typed BlobError subclass with a
29
+ * stable `.code` — blob-too-large, blob-expired, blob-not-found,
30
+ * blob-upload-failed, blob-fetch-failed, blob-integrity-failed,
31
+ * blob-ref-invalid, blob-broker-*, blob-key-unproven, sender-key-not-active.
32
+ * A broker refusal carries the broker's own `.code` and `.fix` when the
33
+ * broker sends them (`.legacyCode` keeps the blob-broker-* value). A refusal
34
+ * that knows its next step has a one-line `.fix`.
35
+ */
36
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
37
+ import { jcs } from "../identity/jcs.mjs";
38
+ import { Keychain, localIdFromKey } from "../identity/keychain.mjs";
39
+ import { ABSOLUTE_MAX_BODY, TIER_DEFAULTS } from "../identity/tier.mjs";
40
+ import { REFUSAL_FIXES } from "../net/refusals.mjs";
41
+
42
+ export const BLOB_REF_V = 1;
43
+ export const BLOB_KIND = "blob-ref";
44
+ export const MAX_BLOB_REF_BYTES = 4096; // serialized ref ceiling
45
+ export const MAX_INLINE_BODY_BYTES = ABSOLUTE_MAX_BODY; // 2 MiB direct hard cap
46
+ export const MAX_BLOB_OBJECT_BYTES = 50 * 1024 * 1024; // broker ciphertext cap
47
+ export const MAX_BLOB_PLAINTEXT_BYTES = MAX_BLOB_OBJECT_BYTES - 16; // GCM tag rides with ciphertext
48
+ export const DEFAULT_BLOB_TTL_MS = 24 * 3600 * 1000;
49
+ export const DEFAULT_DIRECT_THRESHOLD_BYTES = 256_000;
50
+ export const NONCE_BYTES = 12;
51
+ export const DEK_BYTES = 32;
52
+ /** keyId a broker request presents when the identity proves itself by its own key. */
53
+ export const SELF_CERTIFIED_KEY_ID = "self-certified:ed25519";
54
+ /** The one UUAID namespace and object type the broker accepts on the self-certified path. */
55
+ export const SELF_CERTIFIED_NAMESPACE = "foundation";
56
+ export const SELF_CERTIFIED_OBJECT_TYPE = "agent";
57
+
58
+ // ---------------------------------------------------------------- errors ---
59
+ export class BlobError extends Error {
60
+ constructor(code, message, { fix = null } = {}) {
61
+ super(message ?? code); this.name = "BlobError"; this.code = code;
62
+ if (fix) this.fix = fix;
63
+ }
64
+ }
65
+ export class BlobSizeError extends BlobError {
66
+ constructor(message, { size, max } = {}) { super("blob-too-large", message); this.size = size; this.max = max; }
67
+ }
68
+ export class BlobExpiryError extends BlobError {
69
+ constructor(message, expiry) { super("blob-expired", message); this.expiry = expiry; }
70
+ }
71
+ export class BlobNotFoundError extends BlobError {
72
+ constructor(object) { super("blob-not-found", `blob object not found: ${object}`); this.object = object; }
73
+ }
74
+ export class BlobUploadError extends BlobError {
75
+ constructor(message, cause = null) { super("blob-upload-failed", message); this.cause = cause; }
76
+ }
77
+ export class BlobFetchError extends BlobError {
78
+ constructor(message, cause = null) { super("blob-fetch-failed", message); this.cause = cause; }
79
+ }
80
+ export class BlobIntegrityError extends BlobError {
81
+ constructor(message) { super("blob-integrity-failed", message); }
82
+ }
83
+ export class BlobRefError extends BlobError {
84
+ constructor(message) { super("blob-ref-invalid", message); }
85
+ }
86
+ /**
87
+ * `code` is the broker's own code when it sent one; `legacyCode` is the
88
+ * status-derived blob-broker-* code this client has always used (set on HTTP
89
+ * refusals); `body` is the refusal body, numbers included.
90
+ */
91
+ export class BlobBrokerError extends BlobError {
92
+ constructor(code, message, status = null, { fix = null, legacyCode = null, body = null } = {}) {
93
+ super(code, message, { fix });
94
+ this.status = status;
95
+ if (legacyCode) this.legacyCode = legacyCode;
96
+ if (body) this.body = body;
97
+ }
98
+ }
99
+ export class BlobSenderKeyError extends BlobError {
100
+ constructor(message, sender, status) {
101
+ super("sender-key-not-active", message, { fix: REFUSAL_FIXES["sender-key-not-active"] });
102
+ this.sender = sender; this.status = status;
103
+ }
104
+ }
105
+
106
+ // ------------------------------------------------------------- crypto -----
107
+ const HEX = { dek: 64, nonce: 24, hash: 64 }; // expected hex lengths
108
+
109
+ /**
110
+ * Deterministic AAD: binds one ciphertext to exactly one envelope context.
111
+ * Every field is mandatory — a missing binding is a refusal, not a default.
112
+ */
113
+ export function blobAad({ envelopeId, sender, recipient, object, v, expiry }) {
114
+ for (const [k, val] of Object.entries({ envelopeId, sender, recipient, object, expiry })) {
115
+ if (typeof val !== "string" || !val) throw new BlobRefError(`blob AAD: field "${k}" required`);
116
+ }
117
+ if (!Number.isInteger(v)) throw new BlobRefError("blob AAD: field \"v\" required");
118
+ return jcs({ envelopeId, sender, recipient, object, v, expiry });
119
+ }
120
+
121
+ /**
122
+ * Encrypt plaintext under a fresh per-object AES-256-GCM DEK.
123
+ * Returns Buffers { dek (32 B), nonce (12 B), ct, tag (16 B) }.
124
+ */
125
+ export function encryptBlob(plaintext, ctx, { nonce = null } = {}) {
126
+ const buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
127
+ const aad = blobAad(ctx);
128
+ const dek = randomBytes(DEK_BYTES);
129
+ const nv = nonce ?? randomBytes(NONCE_BYTES);
130
+ if (nv.length !== NONCE_BYTES) throw new BlobRefError(`blob nonce must be ${NONCE_BYTES} bytes`);
131
+ const cipher = createCipheriv("aes-256-gcm", dek, nv);
132
+ cipher.setAAD(Buffer.from(aad, "utf-8"));
133
+ const ct = Buffer.concat([cipher.update(buf), cipher.final()]);
134
+ return { dek, nonce: nv, ct, tag: cipher.getAuthTag() };
135
+ }
136
+
137
+ /**
138
+ * Decrypt; throws BlobIntegrityError on ANY AAD/key/tag mismatch.
139
+ * `secret` fields may be Buffers or hex strings.
140
+ */
141
+ export function decryptBlob({ dek, nonce, ct, tag = null }, ctx) {
142
+ const aad = blobAad(ctx);
143
+ try {
144
+ const stored = Buffer.from(ct, "hex");
145
+ const ciphertext = tag == null ? stored.subarray(0, stored.length - 16) : stored;
146
+ const authTag = tag == null ? stored.subarray(stored.length - 16) : Buffer.from(tag, "hex");
147
+ if (authTag.length !== 16) throw new Error("ciphertext is missing the GCM tag");
148
+ const d = createDecipheriv("aes-256-gcm", Buffer.from(dek, "hex"), Buffer.from(nonce, "hex"));
149
+ d.setAAD(Buffer.from(aad, "utf-8"));
150
+ d.setAuthTag(authTag);
151
+ return Buffer.concat([d.update(ciphertext), d.final()]);
152
+ } catch (e) {
153
+ throw new BlobIntegrityError(`blob integrity check failed: ${e.message}`);
154
+ }
155
+ }
156
+
157
+ export function contentHash(buf) {
158
+ return createHash("sha256").update(buf).digest("hex");
159
+ }
160
+
161
+ /**
162
+ * Unguessable upload token for a blob object. Deliberately NOT content-
163
+ * addressed: the AAD must bind the object name before the ciphertext exists
164
+ * (no derivation cycle), and a content digest in the store key would leak a
165
+ * guessable plaintext's fingerprint to the store operator.
166
+ */
167
+ export function mkObjectName() {
168
+ return randomBytes(32).toString("hex");
169
+ }
170
+
171
+ // ------------------------------------------------------ self-certified ---
172
+ /**
173
+ * The one UUAID a key self-certifies, rebuilt in full:
174
+ * `uuaid:foundation:agent:` + sha256(publicKey)[:16] as a UUID (localIdFromKey,
175
+ * the twin of pillar-client's localIdFromPublicKey). Null for a malformed key.
176
+ */
177
+ export function selfCertifiedUuaidFor(publicKeyHex) {
178
+ if (typeof publicKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return null;
179
+ const localId = localIdFromKey(Buffer.from(publicKeyHex, "hex"));
180
+ return `uuaid:${SELF_CERTIFIED_NAMESPACE}:${SELF_CERTIFIED_OBJECT_TYPE}:${localId}`;
181
+ }
182
+
183
+ /**
184
+ * True only when `uuaid` is EXACTLY the UUAID the key self-certifies (full
185
+ * string, namespace and object type included, the broker's rule); such an
186
+ * identity uses the broker without a registry badge. A device, a service,
187
+ * another namespace, or a matching local id under any other prefix is not
188
+ * self-certified: it needs a registry badge naming its key.
189
+ */
190
+ export function isSelfCertifiedUuaid(uuaid, publicKeyHex) {
191
+ const expected = selfCertifiedUuaidFor(publicKeyHex);
192
+ return expected !== null && uuaid === expected;
193
+ }
194
+
195
+ /**
196
+ * Recipient sender gate, self-certified half: the ref's signature verifies
197
+ * under `ref.sig.publicKey`, and `ref.sender` derives from that same key.
198
+ * The sender field is inside the signed bytes, so the key that proves the
199
+ * UUAID is the key whose signature was checked. Checked here rather than
200
+ * trusted from an earlier validateBlobRef, so the gate stands on its own.
201
+ */
202
+ export function senderSelfCertified(ref) {
203
+ return verifyBlobRefSig(ref) && isSelfCertifiedUuaid(ref?.sender, ref?.sig?.publicKey);
204
+ }
205
+
206
+ // ------------------------------------------------------------------ refs ---
207
+ function refSignable(ref) {
208
+ const { sig, ...rest } = ref;
209
+ return jcs(rest);
210
+ }
211
+
212
+ /** Sign a blob-ref body with the loaded keychain (sender identity). */
213
+ export function signBlobRef(keychain, ref) {
214
+ if (!keychain?._identity) throw new Error("signBlobRef: keychain not loaded");
215
+ ref.sig = {
216
+ alg: "ed25519",
217
+ publicKey: keychain._identity.publicKeyHex,
218
+ signature: keychain.sign(Buffer.from(refSignable(ref), "utf-8")).toString("hex"),
219
+ };
220
+ return ref;
221
+ }
222
+
223
+ export function verifyBlobRefSig(ref) {
224
+ const sig = ref?.sig;
225
+ if (!sig || sig.alg !== "ed25519") return false;
226
+ if (!/^[0-9a-f]{64}$/i.test(sig.publicKey ?? "") || !/^[0-9a-f]{128}$/i.test(sig.signature ?? "")) return false;
227
+ try {
228
+ return Keychain.verifyDetached(sig.publicKey, Buffer.from(refSignable(ref), "utf-8"), Buffer.from(sig.signature, "hex"));
229
+ } catch (_e) { return false; }
230
+ }
231
+
232
+ /**
233
+ * Metadata view of a ref — safe for carrier-side logs/telemetry.
234
+ * Deliberately drops DEK, nonce, object id, hash and signature. This view is
235
+ * safe for counters only; the opaque capability never enters logs/telemetry.
236
+ */
237
+ export function blobRefMetadata(ref) {
238
+ return {
239
+ v: ref.v,
240
+ kind: ref.kind,
241
+ size: ref.size,
242
+ plaintextSize: ref.plaintextSize,
243
+ expiry: ref.expiry,
244
+ createdAt: ref.createdAt,
245
+ };
246
+ }
247
+
248
+ /** Secret material of a ref — what only the sealed envelope may carry. */
249
+ export function blobRefSecret(ref) {
250
+ return { dek: ref.dek, nonce: ref.nonce };
251
+ }
252
+
253
+ export function refSerializedBytes(ref) {
254
+ return Buffer.byteLength(JSON.stringify(ref), "utf-8");
255
+ }
256
+
257
+ /**
258
+ * Build + encrypt + sign a blob-ref for one plaintext payload.
259
+ * Returns { ref, metadata, ct, ctx }. Ref is rejected if it would
260
+ * serialize above MAX_BLOB_REF_BYTES.
261
+ */
262
+ export function makeBlobRef(keychain, { envelopeId, recipient, plaintext, ttlMs = DEFAULT_BLOB_TTL_MS, now = Date.now(), challengeId = null, objectId = null, broker = null }) {
263
+ const sender = keychain._identity?.uuaid;
264
+ if (!sender) throw new Error("makeBlobRef: keychain not loaded");
265
+ const buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
266
+ const object = objectId ?? mkObjectName();
267
+ const expiry = new Date(now + ttlMs).toISOString();
268
+ const ctx = { envelopeId, sender, recipient, object, v: BLOB_REF_V, expiry };
269
+ const { dek, nonce, ct, tag } = encryptBlob(buf, ctx);
270
+ const stored = Buffer.concat([ct, tag]);
271
+ // Hash covers every STORED byte, including the GCM tag — checkable before decrypt.
272
+ const hash = contentHash(stored);
273
+ const ref = {
274
+ v: BLOB_REF_V,
275
+ kind: BLOB_KIND,
276
+ envelopeId,
277
+ sender,
278
+ recipient,
279
+ object,
280
+ hash,
281
+ size: stored.length,
282
+ plaintextSize: buf.length,
283
+ dek: dek.toString("hex"),
284
+ nonce: nonce.toString("hex"),
285
+ ...(broker ? { broker } : {}),
286
+ expiry,
287
+ createdAt: new Date(now).toISOString(),
288
+ ...(challengeId ? { challengeId } : {}),
289
+ };
290
+ signBlobRef(keychain, ref);
291
+ const bytes = refSerializedBytes(ref);
292
+ if (bytes > MAX_BLOB_REF_BYTES) {
293
+ throw new BlobRefError(`blob-ref serializes to ${bytes}B > ${MAX_BLOB_REF_BYTES}B ceiling`);
294
+ }
295
+ return { ref, metadata: blobRefMetadata(ref), ct: stored, ctx };
296
+ }
297
+
298
+ /**
299
+ * Structural + temporal validation of a received ref. Throws typed errors.
300
+ * `recipient` (when given) must match ref.recipient; `now` injectable for
301
+ * tests.
302
+ */
303
+ export function validateBlobRef(ref, { recipient = null, now = Date.now() } = {}) {
304
+ if (!ref || typeof ref !== "object") throw new BlobRefError("blob-ref: not an object");
305
+ if (ref.v !== BLOB_REF_V) throw new BlobRefError(`blob-ref: unsupported v ${ref.v}`);
306
+ if (ref.kind !== BLOB_KIND) throw new BlobRefError(`blob-ref: unexpected kind ${ref.kind}`);
307
+ for (const [k, len] of Object.entries(HEX)) {
308
+ if (typeof ref[k] !== "string" || !new RegExp(`^[0-9a-f]{${len}}$`, "i").test(ref[k])) {
309
+ throw new BlobRefError(`blob-ref: bad ${k}`);
310
+ }
311
+ }
312
+ for (const k of ["envelopeId", "sender", "recipient", "object"]) {
313
+ if (typeof ref[k] !== "string" || !ref[k]) throw new BlobRefError(`blob-ref: missing ${k}`);
314
+ }
315
+ if (!/^[0-9a-f]{64}$/.test(ref.object)) throw new BlobRefError("blob-ref: object must be a 256-bit opaque id");
316
+ if (ref.broker !== undefined) {
317
+ let broker;
318
+ try { broker = new URL(ref.broker); } catch (_error) { throw new BlobRefError("blob-ref: invalid broker URL"); }
319
+ if (broker.protocol !== "https:" && broker.hostname !== "127.0.0.1" && broker.hostname !== "localhost") {
320
+ throw new BlobRefError("blob-ref: broker must use HTTPS");
321
+ }
322
+ }
323
+ if (!Number.isInteger(ref.size) || ref.size <= 16 || ref.size > MAX_BLOB_OBJECT_BYTES) {
324
+ throw new BlobRefError(`blob-ref: bad size ${ref.size}`);
325
+ }
326
+ if (!Number.isInteger(ref.plaintextSize) || ref.plaintextSize <= 0 || ref.plaintextSize !== ref.size - 16) {
327
+ throw new BlobRefError(`blob-ref: bad plaintextSize ${ref.plaintextSize}`);
328
+ }
329
+ const exp = Date.parse(ref.expiry);
330
+ if (!Number.isFinite(exp)) throw new BlobRefError("blob-ref: bad expiry");
331
+ if (exp < now) throw new BlobExpiryError(`blob-ref expired at ${ref.expiry}`, ref.expiry);
332
+ if (!verifyBlobRefSig(ref)) throw new BlobRefError("blob-ref: signature invalid");
333
+ if (recipient && ref.recipient !== recipient) {
334
+ throw new BlobRefError(`blob-ref: addressed to ${ref.recipient}, not ${recipient}`);
335
+ }
336
+ return true;
337
+ }
338
+
339
+ // ------------------------------------------------------- size decisions ----
340
+ export function envelopeBodyBytes(envelope) {
341
+ return Buffer.byteLength(JSON.stringify(envelope), "utf-8");
342
+ }
343
+
344
+ /**
345
+ * Exact preflight against the carrier limits — tier inline limit first,
346
+ * then the 2 MiB direct hard cap, which no tier grant can raise.
347
+ * Returns { ok, bytes, limit, source: "ok"|"tier"|"direct-cap" }.
348
+ */
349
+ export function preflightBodyBytes(bodyBytes, { tier = "community", maxBodyBytes = null } = {}) {
350
+ const tierLimit = maxBodyBytes ?? (TIER_DEFAULTS[tier]?.maxBodyBytes ?? TIER_DEFAULTS.community.maxBodyBytes);
351
+ const bytes = Number.isInteger(bodyBytes) ? bodyBytes : -1;
352
+ if (bytes > MAX_INLINE_BODY_BYTES) return { ok: false, bytes, limit: MAX_INLINE_BODY_BYTES, source: "direct-cap" };
353
+ if (bytes > tierLimit) return { ok: false, bytes, limit: tierLimit, source: "tier" };
354
+ return { ok: true, bytes, limit: Math.min(tierLimit, MAX_INLINE_BODY_BYTES), source: "ok" };
355
+ }
356
+
357
+ /** Inline-vs-blob decision for a payload of `dataBytes` plaintext. */
358
+ export function chooseTransport(dataBytes, { directThresholdBytes = DEFAULT_DIRECT_THRESHOLD_BYTES } = {}) {
359
+ return dataBytes <= directThresholdBytes ? "inline" : "blob";
360
+ }
361
+
362
+ // ------------------------------------------------------------- flows -------
363
+ /**
364
+ * SEND side (client half). Order is deliberate:
365
+ * broker.reserve -> encrypt+sign ref -> store.put -> broker.finalize
366
+ * An upload failure surfaces as BlobUploadError BEFORE finalize, so a
367
+ * challenge is never finalized for an object that did not land.
368
+ */
369
+ export async function prepareBlobSend(keychain, { data, envelopeId, recipient, ttlMs = DEFAULT_BLOB_TTL_MS, now = Date.now(), store, broker = null }) {
370
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
371
+ if (buf.length > MAX_BLOB_PLAINTEXT_BYTES) {
372
+ throw new BlobSizeError(`blob payload ${buf.length}B exceeds the ${MAX_BLOB_PLAINTEXT_BYTES}B plaintext cap`, { size: buf.length, max: MAX_BLOB_PLAINTEXT_BYTES });
373
+ }
374
+ if (!store) throw new BlobError("no-store", "prepareBlobSend: a blob store is required");
375
+ let challengeId = null;
376
+ if (broker) {
377
+ const reserved = await broker.reserve({ sender: keychain._identity.uuaid, recipient, size: buf.length + 16 });
378
+ challengeId = reserved.challengeId;
379
+ }
380
+ const { ref, metadata, ct, ctx } = makeBlobRef(keychain, { envelopeId, recipient, plaintext: buf, ttlMs, now, challengeId });
381
+ try {
382
+ await store.put(ref.object, ct);
383
+ } catch (e) {
384
+ if (e instanceof BlobError) throw e;
385
+ throw new BlobUploadError(`blob upload failed for ${ref.object}: ${e.message}`, e);
386
+ }
387
+ if (broker) await broker.finalize({ challengeId, object: ref.object, hash: ref.hash, size: ref.size });
388
+ return { ref, metadata, ctx, size: buf.length, hash: ref.hash, object: ref.object };
389
+ }
390
+
391
+ /**
392
+ * RECEIVE side. Gate order: ref validation (sig/expiry/recipient) ->
393
+ * sender key gate -> fetch -> content-hash check -> broker redemption
394
+ * (single-use, only after every prior gate passed) -> decrypt -> size check.
395
+ *
396
+ * `senderKeyCheck(uuaid, pubkeyHex) => status` is the registry check for a
397
+ * sender whose UUAID does NOT derive from the key that signed the ref. A
398
+ * derived sender is proven by the verified ref signature alone (the same
399
+ * rule its envelope was accepted on), so the check is not consulted for it.
400
+ */
401
+ export async function fetchAndDecryptBlob({ ref, store, envelopeId, now = Date.now(), broker = null, senderKeyCheck = null }) {
402
+ validateBlobRef(ref, { recipient: null, now });
403
+ if (typeof ref.envelopeId === "string" && envelopeId && ref.envelopeId !== envelopeId) {
404
+ throw new BlobRefError(`blob-ref bound to envelope ${ref.envelopeId}, received in ${envelopeId}`);
405
+ }
406
+ // Sender key gate — BEFORE any redemption burns. Only a non-derived sender
407
+ // needs the registry to vouch for its key.
408
+ const selfCertified = senderSelfCertified(ref);
409
+ if (senderKeyCheck && !selfCertified) {
410
+ const status = await senderKeyCheck(ref.sender, ref.sig?.publicKey);
411
+ if (status !== "active") {
412
+ throw new BlobSenderKeyError(`sender key for ${ref.sender} is ${status ?? "unknown"}, not active — refusing redemption`, ref.sender, status);
413
+ }
414
+ }
415
+ let ct;
416
+ try {
417
+ ct = await store.get(ref.object);
418
+ } catch (e) {
419
+ if (e instanceof BlobError) throw e;
420
+ throw new BlobFetchError(`blob fetch failed for ${ref.object}: ${e.message}`, e);
421
+ }
422
+ if (ct == null) throw new BlobNotFoundError(ref.object);
423
+ if (contentHash(ct) !== ref.hash) {
424
+ throw new BlobIntegrityError(`blob content hash mismatch for ${ref.object}`);
425
+ }
426
+ if (broker && ref.challengeId) await broker.redeem({ challengeId: ref.challengeId });
427
+ const data = decryptBlob({ ...blobRefSecret(ref), ct: Buffer.from(ct).toString("hex") }, {
428
+ envelopeId: ref.envelopeId, sender: ref.sender, recipient: ref.recipient,
429
+ object: ref.object, v: ref.v, expiry: ref.expiry,
430
+ });
431
+ if (data.length !== ref.plaintextSize) {
432
+ throw new BlobIntegrityError(`blob size mismatch: decrypted ${data.length}B, ref says ${ref.plaintextSize}B`);
433
+ }
434
+ return {
435
+ data, object: ref.object, hash: ref.hash, size: ref.size,
436
+ sender: ref.sender, senderProof: selfCertified ? "self-certified" : senderKeyCheck ? "sender-key-check" : "unchecked",
437
+ };
438
+ }
439
+
440
+ /** Production send path through the recipient-authenticated UUAID HTTP broker. */
441
+ export async function prepareBrokerBlobSend(keychain, { data, envelopeId, recipient, ttlMs = DEFAULT_BLOB_TTL_MS, now = Date.now(), client }) {
442
+ if (!client) throw new BlobError("no-broker-client", "prepareBrokerBlobSend: broker client required");
443
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
444
+ if (buf.length > MAX_BLOB_PLAINTEXT_BYTES) {
445
+ throw new BlobSizeError(`blob payload ${buf.length}B exceeds the ${MAX_BLOB_PLAINTEXT_BYTES}B plaintext cap`, { size: buf.length, max: MAX_BLOB_PLAINTEXT_BYTES });
446
+ }
447
+ const challenge = await client.challenge("reserve");
448
+ const prepared = makeBlobRef(keychain, {
449
+ envelopeId,
450
+ recipient,
451
+ plaintext: buf,
452
+ ttlMs,
453
+ now,
454
+ objectId: challenge.object_id,
455
+ broker: client.baseUrl,
456
+ });
457
+ const reserved = await client.reserve(challenge, {
458
+ recipient,
459
+ ciphertextSha256: prepared.ref.hash,
460
+ ciphertextBytes: prepared.ref.size,
461
+ expiresAt: prepared.ref.expiry,
462
+ });
463
+ if (reserved.object_id !== prepared.ref.object || reserved.expires_at !== prepared.ref.expiry) {
464
+ throw new BlobIntegrityError("broker reservation does not match the prepared blob-ref");
465
+ }
466
+ await client.upload(reserved.upload, prepared.ct);
467
+ const finalized = await client.finalize(prepared.ref.object);
468
+ client.crossCheckRedemption(prepared.ref, finalized.blob_ref);
469
+ return { ...prepared, size: buf.length, hash: prepared.ref.hash, object: prepared.ref.object };
470
+ }
471
+
472
+ /**
473
+ * Production receive path; the sender key gate precedes broker redemption.
474
+ * A sender whose UUAID is exactly the uuaid:foundation:agent UUAID of the
475
+ * ref's verified signing key passes without a badge; any other sender needs
476
+ * a fresh registry badge naming that key (client.assertRegistryActiveSender).
477
+ *
478
+ * The result names the sender and how it was proven (`senderProof`):
479
+ * "self-certified" means the key holder is proven but the identity is
480
+ * UNVERIFIED (no registry name or badge applies; show the UUAID only);
481
+ * "registry-badge" means a fresh registry badge names the signing key.
482
+ */
483
+ export async function fetchBrokerBlob({ ref, envelopeId, recipient, now = Date.now(), client }) {
484
+ if (!client) throw new BlobError("no-broker-client", "fetchBrokerBlob: broker client required");
485
+ validateBlobRef(ref, { recipient, now });
486
+ if (ref.envelopeId !== envelopeId) throw new BlobRefError("blob-ref envelope binding mismatch");
487
+ if (ref.broker !== client.baseUrl) throw new BlobRefError("blob-ref broker binding mismatch");
488
+ const selfCertified = senderSelfCertified(ref);
489
+ if (!selfCertified) await client.assertRegistryActiveSender(ref.sender, ref.sig?.publicKey);
490
+ const redeemed = await client.redeem(ref.object);
491
+ client.crossCheckRedemption(ref, redeemed.blob);
492
+ const stored = await client.download(redeemed.download);
493
+ if (stored.length !== ref.size) throw new BlobIntegrityError(`blob ciphertext size mismatch: ${stored.length}B != ${ref.size}B`);
494
+ if (contentHash(stored) !== ref.hash) throw new BlobIntegrityError("blob ciphertext hash mismatch");
495
+ const data = decryptBlob({ ...blobRefSecret(ref), ct: stored.toString("hex") }, {
496
+ envelopeId: ref.envelopeId,
497
+ sender: ref.sender,
498
+ recipient: ref.recipient,
499
+ object: ref.object,
500
+ v: ref.v,
501
+ expiry: ref.expiry,
502
+ });
503
+ if (data.length !== ref.plaintextSize) {
504
+ throw new BlobIntegrityError(`blob plaintext size mismatch: ${data.length}B != ${ref.plaintextSize}B`);
505
+ }
506
+ return {
507
+ data, object: ref.object, hash: ref.hash, size: ref.plaintextSize,
508
+ sender: ref.sender, senderProof: selfCertified ? "self-certified" : "registry-badge",
509
+ };
510
+ }