@uuaid/agent-commons 0.0.0 → 0.2.0-alpha.4
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/CONFORMANCE.md +26 -0
- package/LICENSE +201 -0
- package/NOTICE +22 -0
- package/PROVENANCE.json +24 -0
- package/README.md +404 -5
- package/bin/agent-commons.mjs +76 -0
- package/examples/Dockerfile +12 -0
- package/examples/agent.mjs +17 -0
- package/examples/global-policy.json +9 -0
- package/examples/run-cohort.mjs +68 -0
- package/package.json +31 -8
- package/profiles/agentnet.chat.json +11 -0
- package/profiles/local.json +11 -0
- package/profiles/zilligon.com.json +11 -0
- package/profiles/zilligons.com.json +11 -0
- package/src/carrier.mjs +84 -0
- package/src/channels.d.mts +75 -0
- package/src/channels.mjs +158 -0
- package/src/cohort.mjs +61 -0
- package/src/collaboration.mjs +875 -0
- package/src/config.mjs +40 -0
- package/src/continuity.d.mts +24 -0
- package/src/continuity.mjs +601 -0
- package/src/governance-scope.d.mts +46 -0
- package/src/governance-scope.mjs +139 -0
- package/src/governance.mjs +355 -0
- package/src/index.d.ts +111 -0
- package/src/index.mjs +15 -0
- package/src/loop.mjs +71 -0
- package/src/offline.d.mts +7 -0
- package/src/offline.mjs +29 -0
- package/src/pillar.mjs +59 -0
- package/src/profiles.d.mts +12 -0
- package/src/profiles.mjs +92 -0
- package/src/relay/auth.mjs +294 -0
- package/src/relay/board.mjs +223 -0
- package/src/relay/claims.mjs +25 -0
- package/src/relay/cli.mjs +49 -0
- package/src/relay/client.mjs +437 -0
- package/src/relay/docs/reconnect-v1.md +27 -0
- package/src/relay/docs/working-rules-v1.md +42 -0
- package/src/relay/enroll.mjs +232 -0
- package/src/relay/export-cli.mjs +47 -0
- package/src/relay/governance.mjs +1328 -0
- package/src/relay/mailbox.mjs +103 -0
- package/src/relay/records.mjs +146 -0
- package/src/relay/render.mjs +38 -0
- package/src/relay/roster.mjs +134 -0
- package/src/relay/routes.mjs +87 -0
- package/src/relay/schema.mjs +1081 -0
- package/src/relay/seed.mjs +89 -0
- package/src/relay/serve.mjs +116 -0
- package/src/relay/server.mjs +633 -0
- package/src/relay/signing.mjs +693 -0
- package/src/relay/test-mode.mjs +73 -0
- package/src/runtime.mjs +359 -0
- package/src/spaces/manifest.mjs +245 -0
- package/src/spaces/pillar-group.mjs +12 -0
- package/src/spaces/policy.mjs +229 -0
- package/src/spaces/store.mjs +325 -0
- package/src/store.mjs +73 -0
- package/src/transport.d.mts +76 -0
- package/src/transport.mjs +267 -0
- package/src/trust.mjs +104 -0
- package/src/uuaid-namespace.d.mts +6 -0
- package/src/uuaid-namespace.mjs +48 -0
- package/src/vendor/pillar/blob/blob.mjs +508 -0
- package/src/vendor/pillar/crypto/e2e.mjs +97 -0
- package/src/vendor/pillar/groups-v2.mjs +1108 -0
- package/src/vendor/pillar/groups.mjs +175 -0
- package/src/vendor/pillar/identity/jcs.mjs +34 -0
- package/src/vendor/pillar/identity/keychain.mjs +241 -0
- package/src/vendor/pillar/identity/tier.mjs +130 -0
- package/src/vendor/pillar/net/carrier-client.mjs +219 -0
- package/src/vendor/pillar/net/delegation.mjs +209 -0
- package/src/vendor/pillar/net/envelope.mjs +258 -0
- package/src/vendor/pillar/net/refusals.mjs +129 -0
- package/src/vendor/pillar/net/seed-tier.mjs +185 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrier client — lets a pillar deliver + receive envelopes through one or
|
|
3
|
+
* more carrier servers using only outbound HTTP(S).
|
|
4
|
+
*
|
|
5
|
+
* This is the universal transport: works from sandboxes, NATed laptops,
|
|
6
|
+
* serverless, anywhere. No listening socket required.
|
|
7
|
+
*/
|
|
8
|
+
import { envelopeBodyBytes, preflightBodyBytes } from "../blob/blob.mjs";
|
|
9
|
+
import { seedTier } from "./seed-tier.mjs";
|
|
10
|
+
import { carrierFix, REFUSAL_FIXES } from "./refusals.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rank carriers by how proven they are, best first. Unknown carriers rank as
|
|
14
|
+
* `probation` — the primary carrier from a directory has no seed tier and
|
|
15
|
+
* should not be assumed better than a seed that earned one, nor worse.
|
|
16
|
+
* Array.prototype.sort is stable, so a client given no tier information at
|
|
17
|
+
* all keeps its list in exactly the order it was constructed with.
|
|
18
|
+
*/
|
|
19
|
+
function byTier(tiers) {
|
|
20
|
+
return (a, b) => seedTier(tiers[a] ?? "probation").listRank - seedTier(tiers[b] ?? "probation").listRank;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The carrier's signed acceptance receipt (uuaid.carrier-receipt.v1), kept as
|
|
25
|
+
* the carrier sent it, plus this client's own clock when the answer arrived
|
|
26
|
+
* (design §12 rule 3: record received-at beside the carrier's acceptedAt,
|
|
27
|
+
* which is only the carrier's claim). NOT verified here: verify against the
|
|
28
|
+
* pinned key for this carrier and the exact envelope posted. {} when the
|
|
29
|
+
* carrier sent no receipt (an older or keyless carrier): that never fails a
|
|
30
|
+
* delivery, the outcome is simply unreceipted.
|
|
31
|
+
*/
|
|
32
|
+
function keptReceipt(body) {
|
|
33
|
+
const r = body?.receipt;
|
|
34
|
+
if (!r || typeof r !== "object" || Array.isArray(r) || typeof body.receiptSig !== "string") return {};
|
|
35
|
+
return { receipt: r, receiptSig: body.receiptSig, receivedAt: new Date().toISOString() }; // @rule client.keeps-receipt
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class CarrierClient {
|
|
39
|
+
/**
|
|
40
|
+
* @param {{keychain: import('../identity/keychain.mjs').Keychain, carriers: string[]}} opts
|
|
41
|
+
* carriers: base URLs, e.g. ["https://pillar.uuaid.org"]
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* @param {object} opts
|
|
45
|
+
* carrierTiers optional { "<url>": "<seed tier>" } from a directory's
|
|
46
|
+
* seeds[]. Drives which carrier's receipt is authoritative
|
|
47
|
+
* and which one is polled first — see `deliver`.
|
|
48
|
+
*/
|
|
49
|
+
constructor({ keychain, carriers, tierGrant = null, carrierTiers = {} }) {
|
|
50
|
+
if (!keychain?._identity) throw new Error("CarrierClient needs a loaded keychain");
|
|
51
|
+
if (!carriers?.length) throw new Error("CarrierClient needs at least one carrier URL");
|
|
52
|
+
this.keychain = keychain;
|
|
53
|
+
// Normalise tier keys the same way as carrier URLs so a trailing slash on
|
|
54
|
+
// one side does not silently drop a seed to `probation`.
|
|
55
|
+
this.carrierTiers = Object.fromEntries(
|
|
56
|
+
Object.entries(carrierTiers).map(([u, t]) => [String(u).replace(/\/+$/, ""), t]),
|
|
57
|
+
);
|
|
58
|
+
this.carriers = carriers.map(u => u.replace(/\/+$/, "")).sort(byTier(this.carrierTiers));
|
|
59
|
+
// Tier label for the local preflight: a signed grant raises it; default
|
|
60
|
+
// stays community (matches the carrier's unauthenticated tier).
|
|
61
|
+
this.tier = tierGrant?.tier ?? "community";
|
|
62
|
+
this.maxBodyBytes = Number.isInteger(tierGrant?.maxBodyBytes) ? tierGrant.maxBodyBytes : null;
|
|
63
|
+
// Optional signed tier grant (tier.mjs) — sent as a header on deliver so
|
|
64
|
+
// qualifying senders get raised budgets at carriers that trust the issuer.
|
|
65
|
+
this.tierGrantB64 = tierGrant
|
|
66
|
+
? Buffer.from(JSON.stringify(tierGrant), "utf-8").toString("base64")
|
|
67
|
+
: null;
|
|
68
|
+
this._stopPolling = false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get uuaid() { return this.keychain._identity.uuaid; }
|
|
72
|
+
get publicKeyHex() { return this.keychain._identity.publicKeyHex; }
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Deliver an envelope to ALL carriers in parallel — p2p seed replication:
|
|
76
|
+
* the message lands on every configured seed (like a file seeded on many
|
|
77
|
+
* peers), so it survives any single carrier dying before the recipient
|
|
78
|
+
* polls. Duplicate copies are harmless: the recipient mailbox dedups on
|
|
79
|
+
* envelope.id and re-fires no handlers.
|
|
80
|
+
*
|
|
81
|
+
* Resolves once every carrier has answered. Succeeds if at least one
|
|
82
|
+
* accepted; throws if every carrier rejects. Returns the receipt from the
|
|
83
|
+
* most PROVEN accepting carrier as the primary ({ carrier, seq, sha,
|
|
84
|
+
* duplicate, receipt?, receiptSig?, receivedAt? }) plus `all` with every
|
|
85
|
+
* carrier's outcome ({ carrier, ok, seq?, sha?, duplicate?, receipt?,
|
|
86
|
+
* receiptSig?, receivedAt?, error?, status?, reason?, fix? }).
|
|
87
|
+
*
|
|
88
|
+
* `receipt` / `receiptSig` are present only when that carrier signed one
|
|
89
|
+
* (uuaid.carrier-receipt.v1); they are unverified carrier output, and
|
|
90
|
+
* `receivedAt` is this client's clock, not the carrier's.
|
|
91
|
+
*
|
|
92
|
+
* When every carrier refuses, the thrown error carries `code` (the most
|
|
93
|
+
* proven carrier's `reason`, else carrier-unreachable / carrier-bad-response),
|
|
94
|
+
* `fix`, `status`, `carrier` and `refusals` (= `all`).
|
|
95
|
+
*
|
|
96
|
+
* Which receipt is primary matters because callers quote its seq back as a
|
|
97
|
+
* cursor and cite its carrier in receipts: it should name a seed that will
|
|
98
|
+
* still be there. With no tier information every carrier ranks equally and
|
|
99
|
+
* the first-in-list receipt wins, exactly as before.
|
|
100
|
+
*/
|
|
101
|
+
async deliver(envelope, { timeoutMs = 10000 } = {}) {
|
|
102
|
+
// Preflight against the carrier limits BEFORE any network call: the
|
|
103
|
+
// tier inline limit first, then the 2 MiB direct hard cap — no tier
|
|
104
|
+
// grant can raise the hard cap. Typed failure, not a carrier 413.
|
|
105
|
+
const pf = preflightBodyBytes(envelopeBodyBytes(envelope), { tier: this.tier, maxBodyBytes: this.maxBodyBytes });
|
|
106
|
+
if (!pf.ok) {
|
|
107
|
+
const e = new Error(`carrier preflight: body ${pf.bytes}B exceeds ${pf.source} limit ${pf.limit}B — use the blob transport`);
|
|
108
|
+
e.code = pf.source === "direct-cap" ? "blob-too-large" : "over-tier-limit";
|
|
109
|
+
e.limit = pf.limit; e.source = pf.source;
|
|
110
|
+
e.fix = REFUSAL_FIXES["over-tier-limit"];
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
const attempt = async (base) => {
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(`${base}/v1/envelopes`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: {
|
|
118
|
+
"content-type": "application/json",
|
|
119
|
+
...(this.tierGrantB64 ? { "x-pillar-tier-grant": this.tierGrantB64 } : {}),
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify(envelope),
|
|
122
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
123
|
+
});
|
|
124
|
+
const raw = await res.text();
|
|
125
|
+
let body = {};
|
|
126
|
+
try { body = raw ? JSON.parse(raw) : {}; } catch (_error) {}
|
|
127
|
+
if (res.status === 202 && body.accepted) {
|
|
128
|
+
return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate, ...keptReceipt(body) };
|
|
129
|
+
}
|
|
130
|
+
const reason = typeof body.reason === "string" && body.reason ? body.reason
|
|
131
|
+
: typeof body.error === "string" && body.error ? body.error : null;
|
|
132
|
+
return {
|
|
133
|
+
carrier: base, ok: false, status: res.status,
|
|
134
|
+
error: `${res.status} ${body.reason ?? body.error ?? (raw ? "non-json-response" : "empty-response")}`,
|
|
135
|
+
...(reason ? { reason, fix: (typeof body.fix === "string" && body.fix) || carrierFix(reason) } : {}),
|
|
136
|
+
};
|
|
137
|
+
} catch (e) {
|
|
138
|
+
return { carrier: base, ok: false, error: e.message };
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const all = await Promise.allSettled(this.carriers.map(attempt)).then(rs =>
|
|
142
|
+
rs.map(r => r.status === "fulfilled" ? r.value : { carrier: "?", ok: false, error: String(r.reason) }));
|
|
143
|
+
const accepted = all.filter(r => r.ok);
|
|
144
|
+
if (!accepted.length) {
|
|
145
|
+
const e = new Error(`carrier deliver failed on all carriers: ${all.map(r => `${r.carrier}: ${r.error}`).join("; ")}`);
|
|
146
|
+
// `all` is in rank order, so this is the most proven carrier's answer.
|
|
147
|
+
const primary = all.find(r => r.reason) ?? all.find(r => r.status) ?? null;
|
|
148
|
+
e.code = primary?.reason ?? (primary ? "carrier-bad-response" : "carrier-unreachable");
|
|
149
|
+
e.fix = primary?.fix ?? carrierFix(e.code);
|
|
150
|
+
if (primary) { e.status = primary.status; e.carrier = primary.carrier; }
|
|
151
|
+
e.refusals = all;
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
const ranked = accepted.slice().sort((a, b) => byTier(this.carrierTiers)(a.carrier, b.carrier));
|
|
155
|
+
const { carrier, seq, sha, duplicate, receipt, receiptSig, receivedAt } = ranked[0];
|
|
156
|
+
return { carrier, seq, sha, duplicate, ...(receipt ? { receipt, receiptSig, receivedAt } : {}), all };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Fetch envelopes addressed to us with seq > since from a specific carrier.
|
|
161
|
+
* Signed request; carrier verifies our key before serving.
|
|
162
|
+
*/
|
|
163
|
+
async fetchInbox(base, { since = 0, waitS = 0, timeoutMs = 40000, signal = null } = {}) {
|
|
164
|
+
const path = `GET /v1/inbox/${this.uuaid}?since=${since}`;
|
|
165
|
+
const ts = String(Date.now());
|
|
166
|
+
const sig = this.keychain.sign(Buffer.from(`${path}\n${ts}`, "utf-8")).toString("hex");
|
|
167
|
+
const res = await fetch(`${base}/v1/inbox/${encodeURIComponent(this.uuaid)}?since=${since}&wait=${waitS}`, {
|
|
168
|
+
headers: {
|
|
169
|
+
"x-pillar-pubkey": this.publicKeyHex,
|
|
170
|
+
"x-pillar-ts": ts,
|
|
171
|
+
"x-pillar-sig": sig,
|
|
172
|
+
},
|
|
173
|
+
// `signal` lets a caller cancel a long-poll early (peekCarrierInbox stops
|
|
174
|
+
// the other carriers once one has mail).
|
|
175
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
|
|
176
|
+
});
|
|
177
|
+
if (res.status !== 200) {
|
|
178
|
+
const body = await res.json().catch(() => ({}));
|
|
179
|
+
const e = new Error(`inbox fetch ${base}: ${res.status} ${body.error ?? ""}`);
|
|
180
|
+
e.code = typeof body.error === "string" && body.error ? body.error : "carrier-bad-response";
|
|
181
|
+
e.fix = (typeof body.fix === "string" && body.fix) || carrierFix(e.code);
|
|
182
|
+
e.status = res.status; e.carrier = base;
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
return res.json(); // { envelopes: [{seq, envelope}], now }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Long-poll every carrier in parallel forever, invoking onEnvelope for each
|
|
190
|
+
* new envelope. Tracks per-carrier cursor. Returns a stop() function.
|
|
191
|
+
* onError(error, { carrier }) — the carrier context is passed so callers
|
|
192
|
+
* can attribute failures per carrier in metrics.
|
|
193
|
+
*/
|
|
194
|
+
startPolling({ onEnvelope, cursors = {}, waitS = 25, onError }) {
|
|
195
|
+
this._stopPolling = false;
|
|
196
|
+
const state = { cursors: { ...cursors } };
|
|
197
|
+
const loops = this.carriers.map(async (base) => {
|
|
198
|
+
while (!this._stopPolling) {
|
|
199
|
+
try {
|
|
200
|
+
const r = await this.fetchInbox(base, { since: state.cursors[base] ?? 0, waitS });
|
|
201
|
+
for (const { seq, envelope } of r.envelopes) {
|
|
202
|
+
state.cursors[base] = Math.max(state.cursors[base] ?? 0, seq);
|
|
203
|
+
try { await onEnvelope(envelope, { carrier: base, seq }); }
|
|
204
|
+
catch (e) { onError?.(new Error(`onEnvelope threw: ${e.message}`), { carrier: base }); }
|
|
205
|
+
}
|
|
206
|
+
} catch (e) {
|
|
207
|
+
onError?.(e, { carrier: base });
|
|
208
|
+
// Back off on error so a dead carrier doesn't spin the CPU.
|
|
209
|
+
await new Promise(r2 => setTimeout(r2, 2000));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
return {
|
|
214
|
+
stop: () => { this._stopPolling = true; },
|
|
215
|
+
cursors: () => ({ ...state.cursors }),
|
|
216
|
+
done: Promise.allSettled(loops),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device/persona delegation — UACP/1.0 protocol extension (carrier side).
|
|
3
|
+
*
|
|
4
|
+
* A long-lived "persona" UUAID (the principal) authorizes a short-lived
|
|
5
|
+
* device identity (the delegate) to act on its behalf for specific envelope
|
|
6
|
+
* kinds and a bounded time window. The persona signs a JCS-canonical
|
|
7
|
+
* delegation document; the device embeds that document in the envelope's
|
|
8
|
+
* `delegation` field and signs the whole envelope with ITS OWN key.
|
|
9
|
+
*
|
|
10
|
+
* delegation = {
|
|
11
|
+
* version: "uuaid-delegation/1",
|
|
12
|
+
* id: "del-...",
|
|
13
|
+
* principal: persona uuaid, principalPublicKey: hex,
|
|
14
|
+
* delegate: device uuaid, delegatePublicKey: hex,
|
|
15
|
+
* kinds: ["*"] or ["request","reply",...],
|
|
16
|
+
* notBefore, notAfter: ISO 8601,
|
|
17
|
+
* signature: { alg: "ed25519", publicKey, signature },
|
|
18
|
+
* }
|
|
19
|
+
* signature = ed25519(JCS(delegation minus .signature), principalSeed)
|
|
20
|
+
*
|
|
21
|
+
* Wire-compatible with @uuaid/pillar-client's delegation.ts (mintDelegation /
|
|
22
|
+
* verifyDelegation / sealWithDelegation / verifyDelegatedEnvelope): same
|
|
23
|
+
* document shape, same JCS canonicalization, same rejection reason strings,
|
|
24
|
+
* so a carrier and a pillar-client peer reach the same verdict on the same
|
|
25
|
+
* bytes.
|
|
26
|
+
*
|
|
27
|
+
* Envelope acceptance rules (envelope.mjs open() branches here when the
|
|
28
|
+
* optional `delegation` field is present; absent -> legacy behavior,
|
|
29
|
+
* unchanged):
|
|
30
|
+
* (a) envelope transport signature valid under the DELEGATE's pubkey and
|
|
31
|
+
* transportSignature.keyId === delegate uuaid;
|
|
32
|
+
* (b) delegation document signature valid under the PRINCIPAL's pubkey,
|
|
33
|
+
* principal === envelope.sender, delegate === signing key's uuaid;
|
|
34
|
+
* (c) local-id binding for BOTH identities:
|
|
35
|
+
* sha256(pubkey)[:16] (UUID-dash formatted) === uuaid local-id;
|
|
36
|
+
* (d) now within [notBefore, notAfter];
|
|
37
|
+
* (e) envelope.kind covered by the delegation `kinds` allowlist
|
|
38
|
+
* ("*" covers everything), and not a capability-only name such as
|
|
39
|
+
* "inbox" — those are poll capabilities, never deliverable kinds, and
|
|
40
|
+
* are refused even under "*" (see CAPABILITY_ONLY_KINDS).
|
|
41
|
+
* All five must pass; any failure rejects with
|
|
42
|
+
* `delegation-invalid:<reason>` (HTTP 403 at the carrier).
|
|
43
|
+
*
|
|
44
|
+
* Storage/mailbox attribution is unchanged: the envelope's `sender` IS the
|
|
45
|
+
* principal, so carriers key mailboxes, rate limits, and audits to the
|
|
46
|
+
* principal UUAID exactly as before.
|
|
47
|
+
*/
|
|
48
|
+
import { randomBytes } from "node:crypto";
|
|
49
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
50
|
+
import { Keychain } from "../identity/keychain.mjs";
|
|
51
|
+
|
|
52
|
+
export const DELEGATION_VERSION = "uuaid-delegation/1";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Mint a delegation document, signed by the PRINCIPAL keychain.
|
|
56
|
+
*
|
|
57
|
+
* The counterpart to verifyDelegationDoc below, and the .mjs twin of
|
|
58
|
+
* pillar-client's mintDelegation (delegation.ts): same field order, same
|
|
59
|
+
* id shape, same JCS canonicalization, same signature placement — so a
|
|
60
|
+
* document minted here verifies byte-identically in a browser, and vice
|
|
61
|
+
* versa. A divergence in either direction is a wire break, not a style
|
|
62
|
+
* difference.
|
|
63
|
+
*
|
|
64
|
+
* Nothing here knows what KIND of principal is signing. A phone, a CLI and
|
|
65
|
+
* a daemon mint the same bytes; the only requirement is custody of the
|
|
66
|
+
* persona seed. That is what lets a headless agent link a browser.
|
|
67
|
+
*
|
|
68
|
+
* CALLER BEWARE — ttlDays defaults to 90 to match pillar-client, but the
|
|
69
|
+
* desktop-link path enforces a 7-day ceiling of its own (D14, chat-web's
|
|
70
|
+
* withinLinkTtl) and DROPS a longer grant in silence. Pass ttlDays <= 7 for
|
|
71
|
+
* a browser link; see bin/pillar.mjs cmdDelegate, which refuses rather than
|
|
72
|
+
* let that failure be invisible.
|
|
73
|
+
*/
|
|
74
|
+
export function mintDelegationDoc(keychain, opts = {}) {
|
|
75
|
+
const principal = keychain?._identity;
|
|
76
|
+
if (!principal?.publicKeyHex) throw new Error("mintDelegationDoc: principal identity not loaded");
|
|
77
|
+
const { delegate, delegatePublicKey } = opts;
|
|
78
|
+
if (typeof delegate !== "string" || !delegate.startsWith("uuaid:")) {
|
|
79
|
+
throw new Error("mintDelegationDoc: delegate uuaid required");
|
|
80
|
+
}
|
|
81
|
+
if (typeof delegatePublicKey !== "string" || !/^[0-9a-f]{64}$/i.test(delegatePublicKey)) {
|
|
82
|
+
throw new Error("mintDelegationDoc: delegatePublicKey must be 64 hex");
|
|
83
|
+
}
|
|
84
|
+
const kinds = Array.isArray(opts.kinds) && opts.kinds.length ? [...opts.kinds] : ["*"];
|
|
85
|
+
const start = opts.notBefore instanceof Date ? opts.notBefore : new Date();
|
|
86
|
+
const ttlDays = Number.isFinite(opts.ttlDays) ? opts.ttlDays : 90;
|
|
87
|
+
const end = new Date(start.getTime() + ttlDays * 24 * 60 * 60 * 1000);
|
|
88
|
+
const unsigned = {
|
|
89
|
+
version: DELEGATION_VERSION,
|
|
90
|
+
id: `del-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`,
|
|
91
|
+
principal: principal.uuaid,
|
|
92
|
+
principalPublicKey: principal.publicKeyHex,
|
|
93
|
+
delegate,
|
|
94
|
+
delegatePublicKey: delegatePublicKey.toLowerCase(),
|
|
95
|
+
kinds,
|
|
96
|
+
notBefore: start.toISOString(),
|
|
97
|
+
notAfter: end.toISOString(),
|
|
98
|
+
};
|
|
99
|
+
const signature = keychain.sign(jcs(unsigned));
|
|
100
|
+
return {
|
|
101
|
+
...unsigned,
|
|
102
|
+
signature: {
|
|
103
|
+
alg: "ed25519",
|
|
104
|
+
publicKey: principal.publicKeyHex,
|
|
105
|
+
signature: Buffer.from(signature).toString("hex"),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Extract the local-id of a UUAID with pillar-client parseUuaid semantics:
|
|
112
|
+
* exactly four colon-separated segments with the "uuaid" prefix.
|
|
113
|
+
* Returns null for anything else (never throws).
|
|
114
|
+
*/
|
|
115
|
+
export function uuaidLocalId(uuaid) {
|
|
116
|
+
if (typeof uuaid !== "string") return null;
|
|
117
|
+
const parts = uuaid.split(":");
|
|
118
|
+
if (parts.length !== 4 || parts[0] !== "uuaid") return null;
|
|
119
|
+
return parts[3];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Self-authenticating identity check: sha256(pubkey)[:16] === uuaid local-id. */
|
|
123
|
+
export function uuaidBindingOk(uuaid, publicKeyHex) {
|
|
124
|
+
const localId = uuaidLocalId(uuaid);
|
|
125
|
+
if (localId === null) return false;
|
|
126
|
+
if (typeof publicKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return false;
|
|
127
|
+
return Keychain.localIdFromKey(Buffer.from(publicKeyHex, "hex")) === localId;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Verify a delegation document on its own: shape, identity bindings, time
|
|
132
|
+
* window, and the principal's signature over JCS(doc minus .signature).
|
|
133
|
+
*
|
|
134
|
+
* Reason strings intentionally mirror @uuaid/pillar-client verifyDelegation
|
|
135
|
+
* so tooling sees identical diagnostics on both implementations.
|
|
136
|
+
*
|
|
137
|
+
* Returns { ok: true } or { ok: false, reason }.
|
|
138
|
+
*/
|
|
139
|
+
export function verifyDelegationDoc(d, now = new Date()) {
|
|
140
|
+
if (!d || typeof d !== "object" || Array.isArray(d)) return { ok: false, reason: "not-object" };
|
|
141
|
+
if (d.version !== DELEGATION_VERSION) return { ok: false, reason: `bad-version:${d.version}` };
|
|
142
|
+
if (!/^[0-9a-f]{64}$/i.test(d.principalPublicKey ?? "")) return { ok: false, reason: "bad-principal-pubkey" };
|
|
143
|
+
if (!/^[0-9a-f]{64}$/i.test(d.delegatePublicKey ?? "")) return { ok: false, reason: "bad-delegate-pubkey" };
|
|
144
|
+
if (!uuaidBindingOk(d.principal, d.principalPublicKey)) {
|
|
145
|
+
return { ok: false, reason: "principal-uuaid-binding" };
|
|
146
|
+
}
|
|
147
|
+
if (!uuaidBindingOk(d.delegate, d.delegatePublicKey)) {
|
|
148
|
+
return { ok: false, reason: "delegate-uuaid-binding" };
|
|
149
|
+
}
|
|
150
|
+
if (!Array.isArray(d.kinds) || d.kinds.length === 0 || d.kinds.some(k => typeof k !== "string")) {
|
|
151
|
+
return { ok: false, reason: "bad-kinds" };
|
|
152
|
+
}
|
|
153
|
+
const nb = Date.parse(d.notBefore);
|
|
154
|
+
const na = Date.parse(d.notAfter);
|
|
155
|
+
if (Number.isNaN(nb) || Number.isNaN(na)) return { ok: false, reason: "bad-time" };
|
|
156
|
+
if (na <= nb) return { ok: false, reason: "empty-window" };
|
|
157
|
+
const t = now instanceof Date ? now.getTime() : Number(now);
|
|
158
|
+
if (t < nb) return { ok: false, reason: "not-yet-valid" };
|
|
159
|
+
if (t > na) return { ok: false, reason: "expired" };
|
|
160
|
+
|
|
161
|
+
const sig = d.signature;
|
|
162
|
+
if (!sig || sig.alg !== "ed25519" || sig.publicKey !== d.principalPublicKey) {
|
|
163
|
+
return { ok: false, reason: "bad-signature-shape" };
|
|
164
|
+
}
|
|
165
|
+
if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) {
|
|
166
|
+
return { ok: false, reason: "bad-signature-shape" };
|
|
167
|
+
}
|
|
168
|
+
// The signature covers JCS of everything except .signature.
|
|
169
|
+
const { signature: _drop, ...unsigned } = d;
|
|
170
|
+
let canonical;
|
|
171
|
+
try { canonical = jcs(unsigned); } catch (_e) { return { ok: false, reason: "bad-canonical" }; }
|
|
172
|
+
let okSig = false;
|
|
173
|
+
try {
|
|
174
|
+
okSig = Keychain.verifyDetached(sig.publicKey, Buffer.from(canonical, "utf-8"), Buffer.from(sig.signature, "hex"));
|
|
175
|
+
} catch (_e) {
|
|
176
|
+
return { ok: false, reason: "verify-threw" };
|
|
177
|
+
}
|
|
178
|
+
if (!okSig) return { ok: false, reason: "bad-signature" };
|
|
179
|
+
return { ok: true };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Names that may appear in a delegation's `kinds` as CAPABILITIES but are not
|
|
184
|
+
* envelope kinds, and so must never be deliverable.
|
|
185
|
+
*
|
|
186
|
+
* `kinds` is a single namespace answering two different questions: "may this
|
|
187
|
+
* delegate SEND envelopes of kind K" (envelope.mjs) and "may this delegate
|
|
188
|
+
* POLL the principal's inbox" (carrier-server.mjs, capability "inbox"). The
|
|
189
|
+
* overlap means a token minted read-only as `kinds: ["inbox"]` also satisfies
|
|
190
|
+
* the send check for an envelope whose `kind` is literally "inbox" — verified
|
|
191
|
+
* against a live alpha.15 carrier, which returned 202 for exactly that forgery
|
|
192
|
+
* while correctly returning 403 kind-not-covered for message/request/reply.
|
|
193
|
+
*
|
|
194
|
+
* Nothing consumes `kind: "inbox"`, so this is latent rather than live. It
|
|
195
|
+
* stops being latent the moment a read-only poll token is issued to anyone,
|
|
196
|
+
* which is what the push relay is being rebuilt to use.
|
|
197
|
+
*
|
|
198
|
+
* Enforced on the send path in envelope.mjs. Deliberately NOT enforced inside
|
|
199
|
+
* delegationCoversKind: that helper serves both questions, and the capability
|
|
200
|
+
* lookup in carrier-server.mjs depends on "inbox" matching there.
|
|
201
|
+
*/
|
|
202
|
+
export const CAPABILITY_ONLY_KINDS = new Set(["inbox"]);
|
|
203
|
+
|
|
204
|
+
/** Does this delegation cover envelopes of `kind`? "*" covers all. */
|
|
205
|
+
export function delegationCoversKind(d, kind) {
|
|
206
|
+
if (!Array.isArray(d?.kinds)) return false;
|
|
207
|
+
if (d.kinds.includes("*")) return true;
|
|
208
|
+
return d.kinds.includes(kind);
|
|
209
|
+
}
|