@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/HOSTING-LIMITS.md +432 -0
- package/README.md +8 -4
- package/bin/carrier.mjs +1 -0
- package/bin/pillar-ask.mjs +1 -0
- package/bin/pillar-blob.mjs +38 -11
- package/bin/pillar-directory.mjs +1 -0
- package/bin/pillar-escrow.mjs +1 -0
- package/bin/pillar-responder.mjs +1 -0
- package/bin/pillar-seed.mjs +1 -0
- package/bin/pillar.mjs +125 -3
- package/package.json +9 -4
- package/src/blob/blob.mjs +99 -15
- package/src/blob/http.mjs +166 -28
- package/src/identity/tier.mjs +26 -2
- package/src/index.mjs +3 -0
- package/src/net/carrier-client.mjs +27 -4
- package/src/net/carrier-server.mjs +2 -1
- package/src/net/delegation.mjs +57 -0
- package/src/net/link-offer.mjs +107 -0
- package/src/net/refusals.mjs +126 -0
- package/src/node-check.mjs +32 -0
- package/src/pillar.mjs +5 -5
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
|
-
|
|
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.
|
|
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": ">=
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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) {
|
|
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
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
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
|
-
//
|
|
342
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|