@uuaid/pillar 0.2.0-alpha.13 → 0.2.0-alpha.17

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/src/pillar.mjs CHANGED
@@ -19,10 +19,14 @@ import { CID } from "multiformats/cid";
19
19
  import * as raw from "multiformats/codecs/raw";
20
20
  import { sha256 } from "multiformats/hashes/sha2";
21
21
  import { createTransport, PILLAR_PROTOCOL, uuaidToDhtKey, peerIdFromEd25519Hex } from "./net/transport.mjs";
22
- import { seal, open, decrypt, envelopeSha, ENVELOPE_VERSION } from "./net/envelope.mjs";
22
+ import { seal, open, decrypt, envelopeSha, mkEnvelopeId, ENVELOPE_VERSION } from "./net/envelope.mjs";
23
23
  import { Mailbox } from "./mailbox/mailbox.mjs";
24
24
  import { CarrierClient } from "./net/carrier-client.mjs";
25
25
  import { Observability } from "./observability.mjs";
26
+ import {
27
+ BLOB_KIND, MAX_INLINE_BODY_BYTES, BlobError, BlobSizeError, envelopeBodyBytes, preflightBodyBytes,
28
+ prepareBlobSend, fetchAndDecryptBlob, prepareBrokerBlobSend, fetchBrokerBlob,
29
+ } from "./blob/blob.mjs";
26
30
 
27
31
  const VALUE_STORE_PREFIX = "/uuaid/1.0";
28
32
 
@@ -41,6 +45,10 @@ export class Pillar {
41
45
  trust = null, // { policy, allow, block } from loadTrustPolicy(); null = open
42
46
  tierGrant = null, // signed grant object (tier.mjs) sent with carrier delivers
43
47
  metrics = null, // Observability instance; a default is created internally
48
+ blobStore = null, // BlobStore (src/blob/store.mjs) for the blob transport
49
+ blobBroker = null, // challenge broker for blob reserve/finalize/redeem
50
+ blobClient = null, // PillarBlobHttpClient for the production S3 broker path
51
+ senderKeyCheck = null, // registry-active gate: (uuaid, pubkeyHex) => status
44
52
  }) {
45
53
  if (!keychain?._identity) throw new Error("Pillar: keychain must be loaded before construction");
46
54
  this.keychain = keychain;
@@ -58,6 +66,12 @@ export class Pillar {
58
66
  this.node = null;
59
67
  this.trust = trust;
60
68
  this.metrics = metrics ?? new Observability();
69
+ this.blobStore = blobStore;
70
+ this.blobBroker = blobBroker;
71
+ this.blobClient = blobClient;
72
+ this.blobTier = tierGrant?.tier ?? "community";
73
+ this.blobMaxBodyBytes = Number.isInteger(tierGrant?.maxBodyBytes) ? tierGrant.maxBodyBytes : null;
74
+ this.senderKeyCheck = senderKeyCheck;
61
75
  this._envelopeHandlers = new Set();
62
76
  this._openStreams = new Map(); // peerIdString -> pushable
63
77
  }
@@ -240,7 +254,7 @@ export class Pillar {
240
254
  * fleet deployment we bypass DHT via the address book which is populated
241
255
  * once at fleet-up time.
242
256
  */
243
- async send({ recipient, recipientPublicKey, kind = "message", payload = {}, resolveTimeoutMs = 5000 }) {
257
+ async send({ recipient, recipientPublicKey, kind = "message", payload = {}, envelopeId = null, resolveTimeoutMs = 5000 }) {
244
258
  if (!this.node && !this.carrierClient) {
245
259
  this.metrics.inc("send.error.not-started");
246
260
  throw new Error("pillar not started");
@@ -265,7 +279,12 @@ export class Pillar {
265
279
  throw new Error(`send: no public key known for ${recipient} — encryption is mandatory; register the peer (address book / fleet.json) or pass recipientPublicKey`);
266
280
  }
267
281
 
268
- const envelope = seal(this.keychain, { recipient, recipientPublicKey: pubkey, kind, payload });
282
+ const envelope = seal(this.keychain, { recipient, recipientPublicKey: pubkey, kind, payload, id: envelopeId ?? undefined });
283
+ const hardCap = preflightBodyBytes(envelopeBodyBytes(envelope), { maxBodyBytes: MAX_INLINE_BODY_BYTES });
284
+ if (!hardCap.ok) {
285
+ this.metrics.inc("send.error.direct-cap");
286
+ throw new BlobSizeError(`envelope ${hardCap.bytes}B exceeds the ${MAX_INLINE_BODY_BYTES}B hard cap`, { size: hardCap.bytes, max: MAX_INLINE_BODY_BYTES });
287
+ }
269
288
  const persisted = this.mailbox.appendOutbound(envelope);
270
289
 
271
290
  // 1. Address book path: direct libp2p dial (lowest latency, no third party).
@@ -343,6 +362,66 @@ export class Pillar {
343
362
  return { group, results };
344
363
  }
345
364
 
365
+ /**
366
+ * Blob send — the oversized-payload path. Explicit flow (blob.mjs header):
367
+ * broker reserve -> local AES-256-GCM encrypt (fresh DEK + nonce, AAD bound
368
+ * to this envelope) -> store upload -> broker finalize -> seal a kind
369
+ * "blob-ref" envelope carrying the signed ref (DEK inside the sealed
370
+ * payload, never in metadata) -> deliver.
371
+ *
372
+ * Preflight is exact: the placeholder-free body is measured against the
373
+ * tier inline limit and the 2 MiB direct hard cap BEFORE any store or
374
+ * broker call. The envelope id is generated first so the GCM AAD can bind
375
+ * the ciphertext to it.
376
+ */
377
+ async sendBlob({ recipient, recipientPublicKey, kind = BLOB_KIND, data, ttlMs, resolveTimeoutMs = 5000 }) {
378
+ if (!this.blobClient && !this.blobStore) throw new BlobError("no-store", "sendBlob: configure blobClient or blobStore on the Pillar");
379
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
380
+ // Reserve an envelope id so the blob ciphertext is AAD-bound to it.
381
+ const envelopeId = mkEnvelopeId();
382
+ const placeholder = { blobRef: { v: 1, envelopeId } };
383
+ // Seal a disposable probe before any broker/S3 side effect. Besides sizing,
384
+ // this enforces recipient UUAID ↔ public-key binding up front.
385
+ const probe = seal(this.keychain, { recipient, recipientPublicKey, kind, payload: placeholder, id: envelopeId });
386
+ const pf = preflightBodyBytes(envelopeBodyBytes(probe), { tier: this.blobTier, maxBodyBytes: this.blobMaxBodyBytes });
387
+ if (!pf.ok) {
388
+ this.metrics.inc(`sendBlob.preflight.${pf.source}`);
389
+ throw new BlobError("blob-too-large", `sendBlob: placeholder body ${pf.bytes}B exceeds ${pf.source} limit ${pf.limit}B`);
390
+ }
391
+ const prepared = this.blobClient
392
+ ? await prepareBrokerBlobSend(this.keychain, { data: buf, envelopeId, recipient, ttlMs, client: this.blobClient })
393
+ : await prepareBlobSend(this.keychain, {
394
+ data: buf, envelopeId, recipient, ttlMs,
395
+ store: this.blobStore, broker: this.blobBroker,
396
+ });
397
+ const r = await this.send({
398
+ recipient, recipientPublicKey, kind,
399
+ payload: { blobRef: prepared.ref }, envelopeId, resolveTimeoutMs,
400
+ });
401
+ this.metrics.inc("sendBlob.ok");
402
+ return { ...r, blob: { object: prepared.object, hash: prepared.hash, size: prepared.size, metadata: prepared.metadata } };
403
+ }
404
+
405
+ /**
406
+ * Redeem a blob-ref from an inbound payload — fetch + hash-check +
407
+ * single-use broker redemption + AES-256-GCM decrypt with the ref's DEK.
408
+ * The registry-active sender key gate runs BEFORE the broker redemption
409
+ * burns. Returns { data, object, hash, size }.
410
+ */
411
+ async receiveBlob(payload, { envelopeId = null, now = Date.now() } = {}) {
412
+ const ref = payload?.blobRef;
413
+ if (!ref) throw new BlobError("blob-ref-invalid", "receiveBlob: payload carries no blobRef");
414
+ if (!this.blobClient && !this.blobStore) throw new BlobError("no-store", "receiveBlob: configure blobClient or blobStore on the Pillar");
415
+ const r = this.blobClient
416
+ ? await fetchBrokerBlob({ ref, envelopeId, recipient: this.uuaid, now, client: this.blobClient })
417
+ : await fetchAndDecryptBlob({
418
+ ref, store: this.blobStore, envelopeId, now,
419
+ broker: this.blobBroker, senderKeyCheck: this.senderKeyCheck,
420
+ });
421
+ this.metrics.inc("receiveBlob.ok");
422
+ return r;
423
+ }
424
+
346
425
  /**
347
426
  * Start pulling our inbox from every configured carrier. Received envelopes
348
427
  * flow through the same verification + mailbox + handler pipeline as
@@ -400,6 +479,10 @@ export class Pillar {
400
479
  }
401
480
 
402
481
  async _deliverToPeer(envelope, peerIdStr, multiaddrStr) {
482
+ const bytes = envelopeBodyBytes(envelope);
483
+ if (bytes > MAX_INLINE_BODY_BYTES) {
484
+ throw new BlobSizeError(`direct envelope ${bytes}B exceeds the ${MAX_INLINE_BODY_BYTES}B hard cap`, { size: bytes, max: MAX_INLINE_BODY_BYTES });
485
+ }
403
486
  const { peerIdFromString } = await import("@libp2p/peer-id");
404
487
  const { multiaddr } = await import("@multiformats/multiaddr");
405
488
  const peerId = peerIdFromString(peerIdStr);
@@ -444,6 +527,10 @@ export class Pillar {
444
527
  const pubkey = recipientPublicKey ?? this.addressBook?.get(recipient)?.publicKey;
445
528
  if (!pubkey) throw new Error(`sendDirect: no public key for ${recipient} — encryption is mandatory`);
446
529
  const envelope = seal(this.keychain, { recipient, recipientPublicKey: pubkey, kind, payload });
530
+ const bytes = envelopeBodyBytes(envelope);
531
+ if (bytes > MAX_INLINE_BODY_BYTES) {
532
+ throw new BlobSizeError(`direct envelope ${bytes}B exceeds the ${MAX_INLINE_BODY_BYTES}B hard cap`, { size: bytes, max: MAX_INLINE_BODY_BYTES });
533
+ }
447
534
  this.mailbox.appendOutbound(envelope);
448
535
  const { peerIdFromString } = await import("@libp2p/peer-id");
449
536
  const { multiaddr } = await import("@multiformats/multiaddr");
@@ -465,8 +552,14 @@ export class Pillar {
465
552
  async _handleStream(stream, connection) {
466
553
  try {
467
554
  let buf = "";
555
+ let receivedBytes = 0;
468
556
  for await (const chunk of stream) {
469
557
  const bytes = chunk?.subarray ? chunk.subarray() : chunk;
558
+ receivedBytes += bytes.byteLength ?? bytes.length;
559
+ if (receivedBytes > MAX_INLINE_BODY_BYTES + 1) {
560
+ this.metrics.inc("received.rejected.direct-cap");
561
+ throw new BlobSizeError(`incoming direct envelope exceeds the ${MAX_INLINE_BODY_BYTES}B hard cap`, { size: receivedBytes, max: MAX_INLINE_BODY_BYTES });
562
+ }
470
563
  buf += u8ToString(bytes);
471
564
  const newlineIdx = buf.indexOf("\n");
472
565
  if (newlineIdx < 0) continue;