@uuaid/pillar 0.2.0-alpha.16 → 0.2.0-alpha.18

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/bin/pillar.mjs CHANGED
@@ -16,6 +16,7 @@
16
16
  * All commands respect --home to point at an alternate pillar directory,
17
17
  * useful for running multiple pillars on one machine (Zestro dogfood etc.).
18
18
  */
19
+ import "../src/node-check.mjs"; // must stay the first import: refuses Node < 22 before any dependency loads
19
20
  import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
20
21
  import { join, dirname } from "node:path";
21
22
  import { homedir } from "node:os";
@@ -24,6 +25,9 @@ import { Keychain } from "../src/identity/keychain.mjs";
24
25
  import { Pillar } from "../src/pillar.mjs";
25
26
  import { loadFleetAddressBook } from "../src/address-book.mjs";
26
27
  import { loadTrustPolicy, POLICIES } from "../src/identity/trust.mjs";
28
+ import { parseLinkOffer, isLinkOfferLive, LINK_GRANT_KINDS } from "../src/net/link-offer.mjs";
29
+ import { mintDelegationDoc } from "../src/net/delegation.mjs";
30
+ import { refusalLine } from "../src/net/refusals.mjs";
27
31
 
28
32
  function parseArgs(argv) {
29
33
  const args = { _: [] };
@@ -265,6 +269,121 @@ async function cmdSend(args) {
265
269
  }
266
270
  }
267
271
 
272
+
273
+ // ── delegate ──────────────────────────────────────────────────────────────────
274
+ // The desktop-link ceiling enforced by chat-web (browser-chat-session.ts,
275
+ // LINK_TTL_MS). A grant with a longer window verifies fine and is then DROPPED
276
+ // by the browser without a word, so this side refuses it loudly instead.
277
+ const LINK_TTL_DAYS_MAX = 7;
278
+
279
+ /**
280
+ * pillar delegate --to-browser '<uuaid://link/...>'
281
+ *
282
+ * Pair a browser tab to THIS pillar identity. The tab publishes an offer
283
+ * carrying its own freshly minted device key; this mints a delegation naming
284
+ * that key as delegate and this identity as principal, and sends it as a
285
+ * `_link` grant to the tab's inbox.
286
+ *
287
+ * The persona seed never leaves this machine — only a signed, time-boxed
288
+ * document does. Identical in every respect to what a phone sends: the
289
+ * handshake authenticates the PRINCIPAL (the envelope sender must equal the
290
+ * delegation principal), and never the kind of device the principal runs on.
291
+ * That is what lets a headless agent hold a desktop.
292
+ */
293
+ async function cmdDelegate(args) {
294
+ const offerRaw = typeof args["to-browser"] === "string" ? args["to-browser"] : args._[1];
295
+ if (!offerRaw || args["to-browser"] === true) {
296
+ console.error("usage: pillar delegate --to-browser '<uuaid://link/...>' [--ttl-days N] [--name NAME]");
297
+ console.error(" Paste the offer string the browser shows under its QR code.");
298
+ exit(2);
299
+ }
300
+
301
+ const offer = parseLinkOffer(offerRaw);
302
+ if (!offer) {
303
+ console.error("not a valid link offer. Expected: uuaid://link/<delegate-uuaid>?pk=<64hex>&nonce=<hex>&exp=<iso>");
304
+ exit(2);
305
+ }
306
+ if (!isLinkOfferLive(offer)) {
307
+ console.error(`offer expired at ${offer.exp} — reload the browser tab for a fresh one`);
308
+ exit(1);
309
+ }
310
+
311
+ const ttlDays = args["ttl-days"] !== undefined ? Number(args["ttl-days"]) : LINK_TTL_DAYS_MAX;
312
+ if (!Number.isFinite(ttlDays) || ttlDays <= 0) {
313
+ console.error("--ttl-days must be a positive number");
314
+ exit(2);
315
+ }
316
+ // Refuse rather than mint a grant the far side discards in silence.
317
+ if (ttlDays > LINK_TTL_DAYS_MAX) {
318
+ console.error(`--ttl-days ${ttlDays} exceeds the ${LINK_TTL_DAYS_MAX}-day desktop-link ceiling.`);
319
+ console.error("A longer grant is a VALID document that the browser drops without reporting why.");
320
+ exit(2);
321
+ }
322
+
323
+ const p = loadPillar(args);
324
+ await p.start();
325
+ try {
326
+ const me = p.keychain._identity;
327
+ const delegation = mintDelegationDoc(p.keychain, {
328
+ delegate: offer.delegate,
329
+ delegatePublicKey: offer.pk,
330
+ kinds: [...LINK_GRANT_KINDS], // never "*": that would let the desktop read this identity's inbox
331
+ ttlDays,
332
+ });
333
+
334
+ // The snapshot's persona MUST equal the delegation principal exactly — the
335
+ // browser rejects the whole grant on any mismatch, treating a disagreeing
336
+ // snapshot as evidence that neither half can be trusted.
337
+ // fleet.json is { fleet: { "<role>": { uuaid, publicKey, ... } } } — keyed by
338
+ // ROLE, which is also the human-readable name the desktop shows. A row
339
+ // without a publicKey cannot be sealed to, so it is not a contact.
340
+ const contacts = [];
341
+ const fp = fleetPath(args);
342
+ if (existsSync(fp)) {
343
+ let fleet = {};
344
+ try {
345
+ fleet = JSON.parse(readFileSync(fp, "utf-8")).fleet ?? {};
346
+ } catch {
347
+ console.error(`warning: ${fp} is not readable JSON — sending 0 contacts`);
348
+ }
349
+ for (const [role, entry] of Object.entries(fleet)) {
350
+ if (typeof entry?.uuaid !== "string" || typeof entry?.publicKey !== "string") continue;
351
+ contacts.push({ uuaid: entry.uuaid, publicKey: entry.publicKey, name: String(role) });
352
+ }
353
+ }
354
+
355
+ const payload = {
356
+ _link: { v: 1, op: "grant", nonce: offer.nonce },
357
+ delegation,
358
+ snapshot: {
359
+ persona: { uuaid: me.uuaid, publicKey: me.publicKeyHex },
360
+ displayName: typeof args.name === "string" ? args.name : null,
361
+ contacts,
362
+ },
363
+ };
364
+
365
+ const { envelope } = await p.send({
366
+ recipient: offer.delegate,
367
+ kind: "message",
368
+ payload,
369
+ recipientPublicKey: offer.pk,
370
+ resolveTimeoutMs: 5000,
371
+ });
372
+
373
+ console.log(`linked ${offer.delegate}`);
374
+ console.log(` principal: ${me.uuaid}`);
375
+ console.log(` window: ${delegation.notBefore} -> ${delegation.notAfter} (${ttlDays}d)`);
376
+ console.log(` contacts: ${contacts.length}`);
377
+ console.log(` envelope: ${envelope.id}`);
378
+ console.log("");
379
+ console.log("The tab pairs when it polls. If it does not, the grant was refused in");
380
+ console.log("silence — check the offer is unexpired and unclaimed, and that the tab");
381
+ console.log("still shows the same QR (a reload rotates the nonce and voids this grant).");
382
+ } finally {
383
+ await p.stop();
384
+ }
385
+ }
386
+
268
387
  async function cmdSendGroup(args) {
269
388
  const [groupName, kind, jsonStr] = args._.slice(1);
270
389
  if (!groupName || !kind) {
@@ -286,7 +405,7 @@ async function cmdSendGroup(args) {
286
405
  let failed = 0;
287
406
  for (const r of results) {
288
407
  if (r.ok) console.log(` ✓ ${r.member} via ${r.via} ${r.envelopeId}`);
289
- else { failed++; console.error(` ✗ ${r.member} ${r.error}`); }
408
+ else { failed++; console.error(` ✗ ${r.member} ${refusalLine(r) ?? r.error}`); }
290
409
  }
291
410
  console.log(`group "${groupName}": ${results.length - failed}/${results.length} delivered`);
292
411
  if (failed) exit(1);
@@ -636,10 +755,13 @@ async function main() {
636
755
  case "trust": return cmdTrust(args);
637
756
  case "tier": return cmdTier(args);
638
757
  case "peers": return cmdPeers(args);
758
+ case "delegate": return cmdDelegate(args);
639
759
  default:
640
- console.error(`usage: pillar <init|id|send|send-group|groups|recv|inbox|trust|tier|peers> [--home DIR]`);
760
+ console.error(`usage: pillar <init|id|send|send-group|groups|recv|inbox|trust|tier|peers|delegate> [--home DIR]`);
641
761
  exit(2);
642
762
  }
643
763
  }
644
764
 
645
- main().catch(e => { console.error(e); exit(1); });
765
+ // A carrier or broker refusal prints one line, `<code>: <fix>` (src/net/refusals.mjs);
766
+ // anything else keeps the full error.
767
+ main().catch(e => { console.error(refusalLine(e) ?? e); exit(1); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uuaid/pillar",
3
- "version": "0.2.0-alpha.16",
3
+ "version": "0.2.0-alpha.18",
4
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",
@@ -30,6 +30,7 @@
30
30
  "./blob/http": "./src/blob/http.mjs",
31
31
  "./blob/store": "./src/blob/store.mjs",
32
32
  "./blob/broker": "./src/blob/broker.mjs",
33
+ "./refusals": "./src/net/refusals.mjs",
33
34
  "./package.json": "./package.json"
34
35
  },
35
36
  "files": [
@@ -37,16 +38,20 @@
37
38
  "bin",
38
39
  "README.md",
39
40
  "INTERACTION-CONVENTION.md",
41
+ "HOSTING-LIMITS.md",
40
42
  "LICENSE"
41
43
  ],
42
44
  "scripts": {
43
45
  "test": "node --test --test-reporter spec test/unit/*.test.mjs",
44
46
  "test:int": "node --test --test-reporter spec test/integration/*.test.mjs",
45
47
  "test:chaos": "node --test --test-reporter spec test/chaos/*.test.mjs",
46
- "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"
47
52
  },
48
53
  "engines": {
49
- "node": ">=20"
54
+ "node": ">=22"
50
55
  },
51
56
  "dependencies": {
52
57
  "@chainsafe/libp2p-noise": "^17.0.0",
@@ -66,7 +71,7 @@
66
71
  "@libp2p/websockets": "^10.1.19",
67
72
  "@multiformats/multiaddr": "^13.0.3",
68
73
  "@noble/curves": "^2.3.0",
69
- "better-sqlite3": "^11.10.0",
74
+ "better-sqlite3": "^12.11.1",
70
75
  "it-pipe": "^3.0.1",
71
76
  "it-pushable": "^3.2.4",
72
77
  "libp2p": "^3.3.8",
package/src/blob/blob.mjs CHANGED
@@ -20,19 +20,24 @@
20
20
  * carrier-side log/telemetry may ever see) carries none of those.
21
21
  * The serialized ref must stay <= 4 KiB.
22
22
  * 4. REDEEM on receipt: recipient checks the ref signature + expiry +
23
- * recipient binding, verifies the sender's registry key is ACTIVE,
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),
24
25
  * fetches and hash-checks the object, redeems the broker challenge
25
26
  * (single-use), then decrypts.
26
27
  *
27
28
  * Error contract: every failure throws a typed BlobError subclass with a
28
29
  * stable `.code` — blob-too-large, blob-expired, blob-not-found,
29
30
  * blob-upload-failed, blob-fetch-failed, blob-integrity-failed,
30
- * blob-ref-invalid, blob-broker-*, sender-key-not-active.
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`.
31
35
  */
32
36
  import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
33
37
  import { jcs } from "../identity/jcs.mjs";
34
- import { Keychain } from "../identity/keychain.mjs";
38
+ import { Keychain, localIdFromKey } from "../identity/keychain.mjs";
35
39
  import { ABSOLUTE_MAX_BODY, TIER_DEFAULTS } from "../identity/tier.mjs";
40
+ import { REFUSAL_FIXES } from "../net/refusals.mjs";
36
41
 
37
42
  export const BLOB_REF_V = 1;
38
43
  export const BLOB_KIND = "blob-ref";
@@ -44,10 +49,18 @@ export const DEFAULT_BLOB_TTL_MS = 24 * 3600 * 1000;
44
49
  export const DEFAULT_DIRECT_THRESHOLD_BYTES = 256_000;
45
50
  export const NONCE_BYTES = 12;
46
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";
47
57
 
48
58
  // ---------------------------------------------------------------- errors ---
49
59
  export class BlobError extends Error {
50
- constructor(code, message) { super(message ?? code); this.name = "BlobError"; this.code = code; }
60
+ constructor(code, message, { fix = null } = {}) {
61
+ super(message ?? code); this.name = "BlobError"; this.code = code;
62
+ if (fix) this.fix = fix;
63
+ }
51
64
  }
52
65
  export class BlobSizeError extends BlobError {
53
66
  constructor(message, { size, max } = {}) { super("blob-too-large", message); this.size = size; this.max = max; }
@@ -70,11 +83,24 @@ export class BlobIntegrityError extends BlobError {
70
83
  export class BlobRefError extends BlobError {
71
84
  constructor(message) { super("blob-ref-invalid", message); }
72
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
+ */
73
91
  export class BlobBrokerError extends BlobError {
74
- constructor(code, message, status = null) { super(code, message); this.status = status; }
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
+ }
75
98
  }
76
99
  export class BlobSenderKeyError extends BlobError {
77
- constructor(message, sender, status) { super("sender-key-not-active", message); this.sender = sender; this.status = status; }
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
+ }
78
104
  }
79
105
 
80
106
  // ------------------------------------------------------------- crypto -----
@@ -142,6 +168,41 @@ export function mkObjectName() {
142
168
  return randomBytes(32).toString("hex");
143
169
  }
144
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
+
145
206
  // ------------------------------------------------------------------ refs ---
146
207
  function refSignable(ref) {
147
208
  const { sig, ...rest } = ref;
@@ -329,17 +390,23 @@ export async function prepareBlobSend(keychain, { data, envelopeId, recipient, t
329
390
 
330
391
  /**
331
392
  * RECEIVE side. Gate order: ref validation (sig/expiry/recipient) ->
332
- * registry-active sender key check -> fetch -> content-hash check ->
333
- * broker redemption (single-use, only after every prior gate passed) ->
334
- * decrypt -> size check.
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.
335
400
  */
336
401
  export async function fetchAndDecryptBlob({ ref, store, envelopeId, now = Date.now(), broker = null, senderKeyCheck = null }) {
337
402
  validateBlobRef(ref, { recipient: null, now });
338
403
  if (typeof ref.envelopeId === "string" && envelopeId && ref.envelopeId !== envelopeId) {
339
404
  throw new BlobRefError(`blob-ref bound to envelope ${ref.envelopeId}, received in ${envelopeId}`);
340
405
  }
341
- // Registry-active sender key gate — BEFORE any redemption burns.
342
- if (senderKeyCheck) {
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) {
343
410
  const status = await senderKeyCheck(ref.sender, ref.sig?.publicKey);
344
411
  if (status !== "active") {
345
412
  throw new BlobSenderKeyError(`sender key for ${ref.sender} is ${status ?? "unknown"}, not active — refusing redemption`, ref.sender, status);
@@ -364,7 +431,10 @@ export async function fetchAndDecryptBlob({ ref, store, envelopeId, now = Date.n
364
431
  if (data.length !== ref.plaintextSize) {
365
432
  throw new BlobIntegrityError(`blob size mismatch: decrypted ${data.length}B, ref says ${ref.plaintextSize}B`);
366
433
  }
367
- return { data, object: ref.object, hash: ref.hash, size: ref.size };
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
+ };
368
438
  }
369
439
 
370
440
  /** Production send path through the recipient-authenticated UUAID HTTP broker. */
@@ -399,13 +469,24 @@ export async function prepareBrokerBlobSend(keychain, { data, envelopeId, recipi
399
469
  return { ...prepared, size: buf.length, hash: prepared.ref.hash, object: prepared.ref.object };
400
470
  }
401
471
 
402
- /** Production receive path; registry verification precedes broker redemption. */
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
+ */
403
483
  export async function fetchBrokerBlob({ ref, envelopeId, recipient, now = Date.now(), client }) {
404
484
  if (!client) throw new BlobError("no-broker-client", "fetchBrokerBlob: broker client required");
405
485
  validateBlobRef(ref, { recipient, now });
406
486
  if (ref.envelopeId !== envelopeId) throw new BlobRefError("blob-ref envelope binding mismatch");
407
487
  if (ref.broker !== client.baseUrl) throw new BlobRefError("blob-ref broker binding mismatch");
408
- await client.assertRegistryActiveSender(ref.sender, ref.sig?.publicKey);
488
+ const selfCertified = senderSelfCertified(ref);
489
+ if (!selfCertified) await client.assertRegistryActiveSender(ref.sender, ref.sig?.publicKey);
409
490
  const redeemed = await client.redeem(ref.object);
410
491
  client.crossCheckRedemption(ref, redeemed.blob);
411
492
  const stored = await client.download(redeemed.download);
@@ -422,5 +503,8 @@ export async function fetchBrokerBlob({ ref, envelopeId, recipient, now = Date.n
422
503
  if (data.length !== ref.plaintextSize) {
423
504
  throw new BlobIntegrityError(`blob plaintext size mismatch: ${data.length}B != ${ref.plaintextSize}B`);
424
505
  }
425
- return { data, object: ref.object, hash: ref.hash, size: ref.plaintextSize };
506
+ return {
507
+ data, object: ref.object, hash: ref.hash, size: ref.plaintextSize,
508
+ sender: ref.sender, senderProof: selfCertified ? "self-certified" : "registry-badge",
509
+ };
426
510
  }