@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/src/blob/http.mjs
CHANGED
|
@@ -9,16 +9,22 @@ import {
|
|
|
9
9
|
BlobNotFoundError,
|
|
10
10
|
BlobSenderKeyError,
|
|
11
11
|
BlobUploadError,
|
|
12
|
+
SELF_CERTIFIED_KEY_ID,
|
|
13
|
+
isSelfCertifiedUuaid,
|
|
14
|
+
senderSelfCertified,
|
|
12
15
|
} from "./blob.mjs";
|
|
16
|
+
import { REFUSAL_FIXES } from "../net/refusals.mjs";
|
|
13
17
|
|
|
14
18
|
export const PILLAR_BLOB_AUDIENCE = "uuaid:pillar-blob:v1";
|
|
19
|
+
export { SELF_CERTIFIED_KEY_ID };
|
|
20
|
+
const KEY_ID_TTL_MS = 300_000;
|
|
15
21
|
|
|
16
22
|
function payloadHash(payload) {
|
|
17
23
|
return `0x${createHash("sha256").update(jcs(payload)).digest("hex")}`;
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
function signedRequest(keychain, keyId, challenge, request) {
|
|
21
|
-
if (!keyId) throw new BlobBrokerError("blob-broker-key-id-required", "a
|
|
27
|
+
if (!keyId) throw new BlobBrokerError("blob-broker-key-id-required", "a presentation key id is required (registry badge key or self-certified:ed25519)");
|
|
22
28
|
const payload = { v: 1, audience: PILLAR_BLOB_AUDIENCE, challenge, request };
|
|
23
29
|
return {
|
|
24
30
|
payload,
|
|
@@ -33,6 +39,25 @@ function signedRequest(keychain, keyId, challenge, request) {
|
|
|
33
39
|
};
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
/** `{ code, fix, body }` from a refusal body; empty for a non-JSON one (e.g. S3 XML). */
|
|
43
|
+
async function refusalOf(response) {
|
|
44
|
+
const body = await response.json().catch(() => null);
|
|
45
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
|
|
46
|
+
return {
|
|
47
|
+
code: typeof body.code === "string" && body.code ? body.code : null,
|
|
48
|
+
fix: typeof body.fix === "string" && body.fix ? body.fix : null,
|
|
49
|
+
body,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Put the broker's code/fix on a typed error; its own code moves to legacyCode. */
|
|
54
|
+
function withRefusal(error, { code = null, fix = null, body = null } = {}) {
|
|
55
|
+
if (code && code !== error.code) { error.legacyCode = error.code; error.code = code; }
|
|
56
|
+
if (fix) error.fix = fix;
|
|
57
|
+
if (body) error.body = body;
|
|
58
|
+
return error;
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
function safeBase(url) {
|
|
37
62
|
const parsed = new URL(url);
|
|
38
63
|
if (parsed.protocol !== "https:" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") {
|
|
@@ -41,17 +66,35 @@ function safeBase(url) {
|
|
|
41
66
|
return parsed.href.replace(/\/+$/, "");
|
|
42
67
|
}
|
|
43
68
|
|
|
44
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* HTTP client for the UUAID broker. URLs are returned only to the live caller.
|
|
71
|
+
*
|
|
72
|
+
* `keyId` is optional. Given, it is presented as is. Omitted, the client
|
|
73
|
+
* resolves one per identity (resolveKeyId): the registry badge's key when a
|
|
74
|
+
* badge names this exact key, else "self-certified:ed25519" when the UUAID is
|
|
75
|
+
* exactly the key's uuaid:foundation:agent UUAID, else a `blob-key-unproven`
|
|
76
|
+
* refusal.
|
|
77
|
+
*/
|
|
78
|
+
/**
|
|
79
|
+
* The brokers this package talks to without being told, same list and order as
|
|
80
|
+
* @uuaid/pillar-client (hosts.ts BLOB_BROKERS). pillar-sfo runs broker 1.1.0,
|
|
81
|
+
* which also serves self-certified identities; api.uuaid.org serves registry
|
|
82
|
+
* badges only, so it is no longer the default (pass --broker to use it).
|
|
83
|
+
*/
|
|
84
|
+
export const BLOB_BROKERS = Object.freeze(["https://pillar-sfo.uuaid.org/blob", "https://pillar-nyc.uuaid.org/blob"]);
|
|
85
|
+
export const DEFAULT_BLOB_BROKER = BLOB_BROKERS[0];
|
|
86
|
+
|
|
45
87
|
export class PillarBlobHttpClient {
|
|
46
|
-
constructor({ baseUrl =
|
|
88
|
+
constructor({ baseUrl = DEFAULT_BLOB_BROKER, keychain, keyId = null, fetchImpl = globalThis.fetch, timeoutMs = 15_000 } = {}) {
|
|
47
89
|
if (!keychain?._identity) throw new BlobBrokerError("blob-broker-no-identity", "loaded keychain required");
|
|
48
90
|
if (typeof fetchImpl !== "function") throw new BlobBrokerError("blob-broker-no-fetch", "fetch implementation required");
|
|
49
91
|
this.baseUrl = safeBase(baseUrl);
|
|
50
92
|
this.keychain = keychain;
|
|
51
|
-
this.keyId = keyId;
|
|
93
|
+
this.keyId = keyId || null;
|
|
52
94
|
this.fetch = fetchImpl;
|
|
53
95
|
this.timeoutMs = timeoutMs;
|
|
54
96
|
this._trust = null;
|
|
97
|
+
this._resolvedKeyId = null;
|
|
55
98
|
}
|
|
56
99
|
|
|
57
100
|
async _json(path, { method = "POST", body } = {}) {
|
|
@@ -68,15 +111,62 @@ export class PillarBlobHttpClient {
|
|
|
68
111
|
}
|
|
69
112
|
const parsed = await response.json().catch(() => ({}));
|
|
70
113
|
if (!response.ok) {
|
|
71
|
-
const
|
|
114
|
+
const legacyCode = response.status === 404 ? "blob-broker-not-found"
|
|
72
115
|
: response.status === 410 ? "blob-broker-expired"
|
|
73
116
|
: response.status === 409 ? "blob-broker-replay"
|
|
74
117
|
: "blob-broker-rejected";
|
|
75
|
-
|
|
118
|
+
// The broker's own code + fix win; an older broker sends neither.
|
|
119
|
+
const brokerCode = typeof parsed?.code === "string" && parsed.code ? parsed.code : null;
|
|
120
|
+
const fix = typeof parsed?.fix === "string" && parsed.fix ? parsed.fix : null;
|
|
121
|
+
throw new BlobBrokerError(brokerCode ?? legacyCode, parsed?.error ?? `blob broker returned ${response.status}`, response.status, {
|
|
122
|
+
fix,
|
|
123
|
+
legacyCode,
|
|
124
|
+
body: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null,
|
|
125
|
+
});
|
|
76
126
|
}
|
|
77
127
|
return parsed;
|
|
78
128
|
}
|
|
79
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The keyId this identity presents, strongest proof first:
|
|
132
|
+
* 1. the constructor's explicit `keyId`;
|
|
133
|
+
* 2. the registry badge's presentation keyId, when a fresh registry-signed
|
|
134
|
+
* badge for this UUAID names this exact public key (badge limits);
|
|
135
|
+
* 3. "self-certified:ed25519", when this UUAID is exactly
|
|
136
|
+
* `uuaid:foundation:agent:` + the key's local id (self-certified
|
|
137
|
+
* limits; the full string, as the broker compares it);
|
|
138
|
+
* else throws `blob-key-unproven`. A badge that names another key, a
|
|
139
|
+
* missing badge and an unreachable registry all fall through to 3.
|
|
140
|
+
* Cached for 5 minutes.
|
|
141
|
+
*/
|
|
142
|
+
async resolveKeyId() {
|
|
143
|
+
if (this.keyId) return this.keyId;
|
|
144
|
+
if (this._resolvedKeyId && this._resolvedKeyId.until > Date.now()) return this._resolvedKeyId.value;
|
|
145
|
+
const { uuaid, publicKeyHex } = this.keychain._identity;
|
|
146
|
+
let value = null;
|
|
147
|
+
let badgeProblem = "no registry badge names this key";
|
|
148
|
+
try {
|
|
149
|
+
const presentation = await this._verifiedPresentationKey(uuaid);
|
|
150
|
+
if (presentation && presentation.publicKey.toLowerCase() === publicKeyHex.toLowerCase()
|
|
151
|
+
&& typeof presentation.keyId === "string" && presentation.keyId) {
|
|
152
|
+
value = presentation.keyId;
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
badgeProblem = `registry badge lookup failed (${error.code ?? error.message})`;
|
|
156
|
+
}
|
|
157
|
+
if (!value && isSelfCertifiedUuaid(uuaid, publicKeyHex)) value = SELF_CERTIFIED_KEY_ID;
|
|
158
|
+
if (!value) {
|
|
159
|
+
throw new BlobBrokerError(
|
|
160
|
+
"blob-key-unproven",
|
|
161
|
+
`${uuaid} cannot sign broker requests: ${badgeProblem}, and it is not the uuaid:foundation:agent UUAID derived from its key`,
|
|
162
|
+
null,
|
|
163
|
+
{ fix: REFUSAL_FIXES["blob-key-unproven"] },
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
this._resolvedKeyId = { value, until: Date.now() + KEY_ID_TTL_MS };
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
|
|
80
170
|
challenge(action, objectId = null) {
|
|
81
171
|
return this._json("/pillar/blobs/challenge", {
|
|
82
172
|
body: {
|
|
@@ -87,9 +177,10 @@ export class PillarBlobHttpClient {
|
|
|
87
177
|
}).then((body) => body.challenge);
|
|
88
178
|
}
|
|
89
179
|
|
|
90
|
-
reserve(challenge, { recipient, ciphertextSha256, ciphertextBytes, expiresAt }) {
|
|
180
|
+
async reserve(challenge, { recipient, ciphertextSha256, ciphertextBytes, expiresAt }) {
|
|
181
|
+
const keyId = await this.resolveKeyId();
|
|
91
182
|
return this._json("/pillar/blobs/reserve", {
|
|
92
|
-
body: signedRequest(this.keychain,
|
|
183
|
+
body: signedRequest(this.keychain, keyId, challenge, {
|
|
93
184
|
action: "reserve",
|
|
94
185
|
agent_uuaid: this.keychain._identity.uuaid,
|
|
95
186
|
recipient_uuaid: recipient,
|
|
@@ -100,36 +191,44 @@ export class PillarBlobHttpClient {
|
|
|
100
191
|
});
|
|
101
192
|
}
|
|
102
193
|
|
|
103
|
-
finalize(objectId) {
|
|
104
|
-
|
|
105
|
-
|
|
194
|
+
async finalize(objectId) {
|
|
195
|
+
const keyId = await this.resolveKeyId();
|
|
196
|
+
const challenge = await this.challenge("finalize", objectId);
|
|
197
|
+
return this._json("/pillar/blobs/finalize", {
|
|
198
|
+
body: signedRequest(this.keychain, keyId, challenge, {
|
|
106
199
|
action: "finalize",
|
|
107
200
|
agent_uuaid: this.keychain._identity.uuaid,
|
|
108
201
|
}),
|
|
109
|
-
})
|
|
202
|
+
});
|
|
110
203
|
}
|
|
111
204
|
|
|
112
|
-
redeem(objectId) {
|
|
113
|
-
|
|
114
|
-
|
|
205
|
+
async redeem(objectId) {
|
|
206
|
+
const keyId = await this.resolveKeyId();
|
|
207
|
+
const challenge = await this.challenge("redeem", objectId);
|
|
208
|
+
return this._json("/pillar/blobs/redeem", {
|
|
209
|
+
body: signedRequest(this.keychain, keyId, challenge, {
|
|
115
210
|
action: "redeem",
|
|
116
211
|
agent_uuaid: this.keychain._identity.uuaid,
|
|
117
212
|
}),
|
|
118
|
-
})
|
|
213
|
+
});
|
|
119
214
|
}
|
|
120
215
|
|
|
121
216
|
async upload(ticket, ciphertext) {
|
|
217
|
+
let response;
|
|
122
218
|
try {
|
|
123
|
-
|
|
219
|
+
response = await this.fetch(ticket.url, {
|
|
124
220
|
method: "PUT",
|
|
125
221
|
headers: ticket.headers,
|
|
126
222
|
body: ciphertext,
|
|
127
223
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
128
224
|
});
|
|
129
|
-
if (!response.ok) throw new Error(`upload returned ${response.status}`);
|
|
130
225
|
} catch (error) {
|
|
131
226
|
throw new BlobUploadError(`temporary blob upload failed: ${error.message}`, error);
|
|
132
227
|
}
|
|
228
|
+
if (!response.ok) {
|
|
229
|
+
const cause = new Error(`upload returned ${response.status}`);
|
|
230
|
+
throw withRefusal(new BlobUploadError(`temporary blob upload failed: ${cause.message}`, cause), await refusalOf(response));
|
|
231
|
+
}
|
|
133
232
|
}
|
|
134
233
|
|
|
135
234
|
async download(ticket) {
|
|
@@ -139,9 +238,12 @@ export class PillarBlobHttpClient {
|
|
|
139
238
|
} catch (error) {
|
|
140
239
|
throw new BlobFetchError(`temporary blob download failed: ${error.message}`, error);
|
|
141
240
|
}
|
|
142
|
-
if (response.
|
|
143
|
-
|
|
144
|
-
|
|
241
|
+
if (!response.ok) {
|
|
242
|
+
const error = response.status === 404 ? new BlobNotFoundError("temporary-object")
|
|
243
|
+
: response.status === 403 ? new BlobExpiryError("temporary blob download capability expired")
|
|
244
|
+
: new BlobFetchError(`temporary blob download returned ${response.status}`);
|
|
245
|
+
throw withRefusal(error, await refusalOf(response));
|
|
246
|
+
}
|
|
145
247
|
return Buffer.from(await response.arrayBuffer());
|
|
146
248
|
}
|
|
147
249
|
|
|
@@ -155,16 +257,28 @@ export class PillarBlobHttpClient {
|
|
|
155
257
|
return value;
|
|
156
258
|
}
|
|
157
259
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
260
|
+
/**
|
|
261
|
+
* The presentation key ({ alg, keyId, publicKey }) of a fresh badge for
|
|
262
|
+
* `uuaid` whose payload hash matches and whose ed25519 signature pins to a
|
|
263
|
+
* live key of the registry trust root; null when there is no badge (404)
|
|
264
|
+
* or it fails any of those checks. Other broker errors propagate.
|
|
265
|
+
*/
|
|
266
|
+
async _verifiedPresentationKey(uuaid) {
|
|
267
|
+
let badge;
|
|
268
|
+
try {
|
|
269
|
+
badge = await this._json(`/iaaso/v1/badge/${encodeURIComponent(uuaid)}`, { method: "GET" });
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error?.status === 404) return null;
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
160
274
|
const trust = await this._registryTrust();
|
|
161
|
-
const badge = await this._json(`/iaaso/v1/badge/${encodeURIComponent(sender)}`, { method: "GET" });
|
|
162
275
|
const presentation = badge?.payload?.subject?.presentationKey;
|
|
163
|
-
const
|
|
276
|
+
const subjectMatches = badge?.payload?.subject?.uuaid === uuaid
|
|
164
277
|
&& presentation?.alg === "ed25519"
|
|
165
|
-
&& presentation?.publicKey
|
|
278
|
+
&& typeof presentation?.publicKey === "string";
|
|
166
279
|
const fresh = Number.isFinite(Date.parse(badge?.payload?.freshUntil)) && Date.parse(badge.payload.freshUntil) > Date.now();
|
|
167
|
-
|
|
280
|
+
let hashMatches = false;
|
|
281
|
+
try { hashMatches = badge?.payloadHash === payloadHash(badge?.payload); } catch (_error) { hashMatches = false; }
|
|
168
282
|
const registrySignature = Array.isArray(badge?.signatures)
|
|
169
283
|
? badge.signatures.find((signature) => {
|
|
170
284
|
if (signature.alg !== "ed25519") return false;
|
|
@@ -175,12 +289,36 @@ export class PillarBlobHttpClient {
|
|
|
175
289
|
} catch (_error) { return false; }
|
|
176
290
|
})
|
|
177
291
|
: null;
|
|
178
|
-
|
|
292
|
+
return subjectMatches && fresh && hashMatches && registrySignature ? presentation : null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Fail closed unless a fresh registry-signed badge names this exact active
|
|
297
|
+
* key. The receive flows call this only for a sender whose UUAID does not
|
|
298
|
+
* derive from its signing key; see assertSenderKey.
|
|
299
|
+
*/
|
|
300
|
+
async assertRegistryActiveSender(sender, expectedPublicKey) {
|
|
301
|
+
const presentation = await this._verifiedPresentationKey(sender);
|
|
302
|
+
const keyMatches = typeof expectedPublicKey === "string"
|
|
303
|
+
&& presentation?.publicKey.toLowerCase() === expectedPublicKey.toLowerCase();
|
|
304
|
+
if (!keyMatches) {
|
|
179
305
|
throw new BlobSenderKeyError("sender presentation key is not registry-active", sender, "inactive");
|
|
180
306
|
}
|
|
181
307
|
return "active";
|
|
182
308
|
}
|
|
183
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Recipient sender gate for a received blob-ref. "self-certified" when the
|
|
312
|
+
* ref's signature verifies and its sender is exactly that signing key's
|
|
313
|
+
* uuaid:foundation:agent UUAID (proven key holder, unverified identity);
|
|
314
|
+
* otherwise "active" when a registry badge names that key; otherwise
|
|
315
|
+
* throws BlobSenderKeyError (code sender-key-not-active, with a fix).
|
|
316
|
+
*/
|
|
317
|
+
async assertSenderKey(ref) {
|
|
318
|
+
if (senderSelfCertified(ref)) return "self-certified";
|
|
319
|
+
return this.assertRegistryActiveSender(ref?.sender, ref?.sig?.publicKey);
|
|
320
|
+
}
|
|
321
|
+
|
|
184
322
|
crossCheckRedemption(ref, brokerRecord) {
|
|
185
323
|
if (
|
|
186
324
|
brokerRecord?.object_id !== ref.object ||
|
package/src/identity/tier.mjs
CHANGED
|
@@ -30,8 +30,25 @@ import { createHash } from "node:crypto";
|
|
|
30
30
|
import { jcs } from "./jcs.mjs";
|
|
31
31
|
import { Keychain } from "./keychain.mjs";
|
|
32
32
|
|
|
33
|
-
/** Tier ladder. community = the unauthenticated default (F2 hardening values).
|
|
34
|
-
|
|
33
|
+
/** Tier ladder. community = the unauthenticated default (F2 hardening values).
|
|
34
|
+
*
|
|
35
|
+
* Greek-order names (Doric, Ionic, Corinthian) are ALIASES for the lower
|
|
36
|
+
* three rungs of the canonical ladder. They were added 2026-09-19 so that
|
|
37
|
+
* grants may carry a feature-tier name — Doric = pillar core only,
|
|
38
|
+
* Ionic = + plinth mix hops, Corinthian = + full optional stack — while
|
|
39
|
+
* the carrier still enforces a single budget table. A grant with
|
|
40
|
+
* `tier: "Doric"` and a grant with `tier: "community"` resolve to the same
|
|
41
|
+
* rate/size budget; carriers verify by `TIER_DEFAULTS[grant.tier]` and
|
|
42
|
+
* never see the difference.
|
|
43
|
+
*
|
|
44
|
+
* Aliasing is additive — the canonical five names still work, no carrier
|
|
45
|
+
* upgrade required. Enterprise and foundation keep their original names
|
|
46
|
+
* (no alias needed; they are not feature-presence markers).
|
|
47
|
+
*
|
|
48
|
+
* Adding a new alias is a one-line change here: spread the budget object
|
|
49
|
+
* under the new key. No other file needs to know.
|
|
50
|
+
*/
|
|
51
|
+
const _CANONICAL_TIERS = {
|
|
35
52
|
community: { envelopesPerMin: 120, maxBodyBytes: 512 * 1024 },
|
|
36
53
|
supporter: { envelopesPerMin: 300, maxBodyBytes: 1024 * 1024 },
|
|
37
54
|
org: { envelopesPerMin: 600, maxBodyBytes: 2 * 1024 * 1024 },
|
|
@@ -39,6 +56,13 @@ export const TIER_DEFAULTS = {
|
|
|
39
56
|
foundation: { envelopesPerMin: 3000, maxBodyBytes: 2 * 1024 * 1024 },
|
|
40
57
|
};
|
|
41
58
|
|
|
59
|
+
export const TIER_DEFAULTS = {
|
|
60
|
+
..._CANONICAL_TIERS,
|
|
61
|
+
Doric: _CANONICAL_TIERS.community, // alias of community
|
|
62
|
+
Ionic: _CANONICAL_TIERS.supporter, // alias of supporter
|
|
63
|
+
Corinthian: _CANONICAL_TIERS.org, // alias of org
|
|
64
|
+
};
|
|
65
|
+
|
|
42
66
|
/** Hard ceiling any grant can reach — also the carrier's raw-body stream cap. */
|
|
43
67
|
export const ABSOLUTE_MAX_BODY = 2 * 1024 * 1024;
|
|
44
68
|
export const ABSOLUTE_MAX_RATE = 6000;
|
package/src/index.mjs
CHANGED
|
@@ -36,6 +36,9 @@ export {
|
|
|
36
36
|
signBlobRef, verifyBlobRefSig, blobRefMetadata, blobRefSecret, refSerializedBytes,
|
|
37
37
|
makeBlobRef, validateBlobRef, envelopeBodyBytes, preflightBodyBytes, chooseTransport,
|
|
38
38
|
prepareBlobSend, fetchAndDecryptBlob, prepareBrokerBlobSend, fetchBrokerBlob,
|
|
39
|
+
SELF_CERTIFIED_KEY_ID, isSelfCertifiedUuaid, selfCertifiedUuaidFor, senderSelfCertified,
|
|
39
40
|
} from "./blob/blob.mjs";
|
|
40
41
|
export { MemoryBlobStore, FsBlobStore } from "./blob/store.mjs";
|
|
41
42
|
export { PillarBlobHttpClient, PILLAR_BLOB_AUDIENCE } from "./blob/http.mjs";
|
|
43
|
+
// Refusals in agent language: carrier reason -> one-line fix, `<code>: <fix>`.
|
|
44
|
+
export { REFUSAL_FIXES, carrierFix, withCarrierFix, refusalLine, refusalFields } from "./net/refusals.mjs";
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { envelopeBodyBytes, preflightBodyBytes } from "../blob/blob.mjs";
|
|
9
9
|
import { seedTier } from "./seed-tier.mjs";
|
|
10
|
+
import { carrierFix, REFUSAL_FIXES } from "./refusals.mjs";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Rank carriers by how proven they are, best first. Unknown carriers rank as
|
|
@@ -66,7 +67,11 @@ export class CarrierClient {
|
|
|
66
67
|
* accepted; throws if every carrier rejects. Returns the receipt from the
|
|
67
68
|
* most PROVEN accepting carrier as the primary ({ carrier, seq, sha,
|
|
68
69
|
* duplicate }) plus `all` with every carrier's outcome ({ carrier, ok,
|
|
69
|
-
* seq?, sha?, duplicate?, error? }).
|
|
70
|
+
* seq?, sha?, duplicate?, error?, status?, reason?, fix? }).
|
|
71
|
+
*
|
|
72
|
+
* When every carrier refuses, the thrown error carries `code` (the most
|
|
73
|
+
* proven carrier's `reason`, else carrier-unreachable / carrier-bad-response),
|
|
74
|
+
* `fix`, `status`, `carrier` and `refusals` (= `all`).
|
|
70
75
|
*
|
|
71
76
|
* Which receipt is primary matters because callers quote its seq back as a
|
|
72
77
|
* cursor and cite its carrier in receipts: it should name a seed that will
|
|
@@ -82,6 +87,7 @@ export class CarrierClient {
|
|
|
82
87
|
const e = new Error(`carrier preflight: body ${pf.bytes}B exceeds ${pf.source} limit ${pf.limit}B — use the blob transport`);
|
|
83
88
|
e.code = pf.source === "direct-cap" ? "blob-too-large" : "over-tier-limit";
|
|
84
89
|
e.limit = pf.limit; e.source = pf.source;
|
|
90
|
+
e.fix = REFUSAL_FIXES["over-tier-limit"];
|
|
85
91
|
throw e;
|
|
86
92
|
}
|
|
87
93
|
const attempt = async (base) => {
|
|
@@ -101,7 +107,13 @@ export class CarrierClient {
|
|
|
101
107
|
if (res.status === 202 && body.accepted) {
|
|
102
108
|
return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate };
|
|
103
109
|
}
|
|
104
|
-
|
|
110
|
+
const reason = typeof body.reason === "string" && body.reason ? body.reason
|
|
111
|
+
: typeof body.error === "string" && body.error ? body.error : null;
|
|
112
|
+
return {
|
|
113
|
+
carrier: base, ok: false, status: res.status,
|
|
114
|
+
error: `${res.status} ${body.reason ?? body.error ?? (raw ? "non-json-response" : "empty-response")}`,
|
|
115
|
+
...(reason ? { reason, fix: (typeof body.fix === "string" && body.fix) || carrierFix(reason) } : {}),
|
|
116
|
+
};
|
|
105
117
|
} catch (e) {
|
|
106
118
|
return { carrier: base, ok: false, error: e.message };
|
|
107
119
|
}
|
|
@@ -110,7 +122,14 @@ export class CarrierClient {
|
|
|
110
122
|
rs.map(r => r.status === "fulfilled" ? r.value : { carrier: "?", ok: false, error: String(r.reason) }));
|
|
111
123
|
const accepted = all.filter(r => r.ok);
|
|
112
124
|
if (!accepted.length) {
|
|
113
|
-
|
|
125
|
+
const e = new Error(`carrier deliver failed on all carriers: ${all.map(r => `${r.carrier}: ${r.error}`).join("; ")}`);
|
|
126
|
+
// `all` is in rank order, so this is the most proven carrier's answer.
|
|
127
|
+
const primary = all.find(r => r.reason) ?? all.find(r => r.status) ?? null;
|
|
128
|
+
e.code = primary?.reason ?? (primary ? "carrier-bad-response" : "carrier-unreachable");
|
|
129
|
+
e.fix = primary?.fix ?? carrierFix(e.code);
|
|
130
|
+
if (primary) { e.status = primary.status; e.carrier = primary.carrier; }
|
|
131
|
+
e.refusals = all;
|
|
132
|
+
throw e;
|
|
114
133
|
}
|
|
115
134
|
const ranked = accepted.slice().sort((a, b) => byTier(this.carrierTiers)(a.carrier, b.carrier));
|
|
116
135
|
const { carrier, seq, sha, duplicate } = ranked[0];
|
|
@@ -135,7 +154,11 @@ export class CarrierClient {
|
|
|
135
154
|
});
|
|
136
155
|
if (res.status !== 200) {
|
|
137
156
|
const body = await res.json().catch(() => ({}));
|
|
138
|
-
|
|
157
|
+
const e = new Error(`inbox fetch ${base}: ${res.status} ${body.error ?? ""}`);
|
|
158
|
+
e.code = typeof body.error === "string" && body.error ? body.error : "carrier-bad-response";
|
|
159
|
+
e.fix = (typeof body.fix === "string" && body.fix) || carrierFix(e.code);
|
|
160
|
+
e.status = res.status; e.carrier = base;
|
|
161
|
+
throw e;
|
|
139
162
|
}
|
|
140
163
|
return res.json(); // { envelopes: [{seq, envelope}], now }
|
|
141
164
|
}
|
|
@@ -58,6 +58,7 @@ import { Keychain } from "../identity/keychain.mjs";
|
|
|
58
58
|
import { jcs } from "../identity/jcs.mjs";
|
|
59
59
|
import { verifyTierGrant } from "../identity/tier.mjs";
|
|
60
60
|
import { verifyDelegationDoc, delegationCoversKind } from "./delegation.mjs";
|
|
61
|
+
import { withCarrierFix } from "./refusals.mjs"; // additive `fix` on refusals; reason/error/status unchanged
|
|
61
62
|
|
|
62
63
|
const MAX_WAIT_S = 30;
|
|
63
64
|
const MAX_BODY = 512 * 1024; // 512 KiB per envelope (community tier)
|
|
@@ -245,7 +246,7 @@ export class CarrierServer {
|
|
|
245
246
|
// No-op on a dead socket: close() destroys connections, and a long-poll
|
|
246
247
|
// handler whose timer fires afterwards must not throw on write.
|
|
247
248
|
if (res.destroyed || res.writableEnded) return;
|
|
248
|
-
const body = JSON.stringify(obj);
|
|
249
|
+
const body = JSON.stringify(withCarrierFix(code, obj));
|
|
249
250
|
res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
|
|
250
251
|
res.end(body);
|
|
251
252
|
}
|
package/src/net/delegation.mjs
CHANGED
|
@@ -45,11 +45,68 @@
|
|
|
45
45
|
* principal, so carriers key mailboxes, rate limits, and audits to the
|
|
46
46
|
* principal UUAID exactly as before.
|
|
47
47
|
*/
|
|
48
|
+
import { randomBytes } from "node:crypto";
|
|
48
49
|
import { jcs } from "../identity/jcs.mjs";
|
|
49
50
|
import { Keychain } from "../identity/keychain.mjs";
|
|
50
51
|
|
|
51
52
|
export const DELEGATION_VERSION = "uuaid-delegation/1";
|
|
52
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
|
+
|
|
53
110
|
/**
|
|
54
111
|
* Extract the local-id of a UUAID with pillar-client parseUuaid semantics:
|
|
55
112
|
* exactly four colon-separated segments with the "uuaid" prefix.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `uuaid://link/…` — desktop linking offer, principal side.
|
|
3
|
+
*
|
|
4
|
+
* A browser tab that is not yet paired mints its OWN device identity and
|
|
5
|
+
* publishes it as an offer:
|
|
6
|
+
*
|
|
7
|
+
* uuaid://link/<url-encoded-delegate-uuaid>?pk=<64hex>&nonce=<hex>&exp=<iso>
|
|
8
|
+
*
|
|
9
|
+
* A phone renders that string as a QR and scans it. Nothing about the offer
|
|
10
|
+
* requires a camera: the QR is a rendering of this string, not a format of
|
|
11
|
+
* its own, so any principal that can read the string can complete the
|
|
12
|
+
* handshake. That is what makes `pillar delegate --to-browser` possible for
|
|
13
|
+
* an agent, which has a persona seed but no phone.
|
|
14
|
+
*
|
|
15
|
+
* Structural parity with `apps/mobile/src/services/link-offer.ts` is
|
|
16
|
+
* deliberate and load-bearing — same regexes, same rejection points, same
|
|
17
|
+
* "null means ignore safely" contract — because the browser on the other end
|
|
18
|
+
* validates what the phone would have sent. Change one, change both.
|
|
19
|
+
*
|
|
20
|
+
* Parsing is not authorization: `parseLinkOffer` checks shape only. Liveness
|
|
21
|
+
* (`isLinkOfferLive`) and single-use are the caller's job.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const LINK_OFFER_DEEPLINK_HOST = "link";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The kinds a desktop-link grant covers: exactly what the phone grants
|
|
28
|
+
* (apps/mobile pillar.ts LINK_GRANT_KINDS, plan D12). No "inbox": the desktop
|
|
29
|
+
* polls its OWN inbox and never reads the principal's, and no "*", which a
|
|
30
|
+
* carrier reads as covering "inbox" (carrier-server delegationCoversKind).
|
|
31
|
+
*/
|
|
32
|
+
export const LINK_GRANT_KINDS = Object.freeze(["message", "request", "reply", "ack"]);
|
|
33
|
+
|
|
34
|
+
const UUAID_RE = /^uuaid:[a-z0-9-]+:[a-z0-9-]+:[a-z0-9][a-z0-9-]*$/i;
|
|
35
|
+
const PK_RE = /^[0-9a-f]{64}$/i;
|
|
36
|
+
const NONCE_RE = /^[0-9a-f]{8,64}$/i;
|
|
37
|
+
|
|
38
|
+
export function isValidLinkDelegate(delegate) {
|
|
39
|
+
return typeof delegate === "string" && UUAID_RE.test(delegate);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isValidLinkPublicKey(pk) {
|
|
43
|
+
return typeof pk === "string" && PK_RE.test(pk);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isValidLinkNonce(nonce) {
|
|
47
|
+
return typeof nonce === "string" && NONCE_RE.test(nonce);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a `uuaid://link/…` offer. Structural validation only — shape and
|
|
52
|
+
* field formats, including that `exp` parses as a date. Does NOT check
|
|
53
|
+
* whether the offer has expired (see isLinkOfferLive). Returns null for
|
|
54
|
+
* anything malformed; callers must treat null as "ignore safely".
|
|
55
|
+
*/
|
|
56
|
+
export function parseLinkOffer(raw) {
|
|
57
|
+
if (typeof raw !== "string") return null;
|
|
58
|
+
const trimmed = raw.trim();
|
|
59
|
+
if (!trimmed) return null;
|
|
60
|
+
let u;
|
|
61
|
+
try {
|
|
62
|
+
// URL/URLSearchParams can silently mangle malformed percent escapes
|
|
63
|
+
// rather than throwing, so validate the encoding across the whole link
|
|
64
|
+
// before trusting any field of it.
|
|
65
|
+
decodeURI(trimmed);
|
|
66
|
+
u = new URL(trimmed);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (u.protocol.toLowerCase() !== "uuaid:") return null;
|
|
71
|
+
if (u.hostname.toLowerCase() !== LINK_OFFER_DEEPLINK_HOST) return null;
|
|
72
|
+
let delegate;
|
|
73
|
+
try {
|
|
74
|
+
delegate = decodeURIComponent(u.pathname.replace(/^\/+/, ""));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (!isValidLinkDelegate(delegate)) return null;
|
|
79
|
+
const pk = (u.searchParams.get("pk") ?? "").trim().toLowerCase();
|
|
80
|
+
const nonce = (u.searchParams.get("nonce") ?? "").trim().toLowerCase();
|
|
81
|
+
const exp = (u.searchParams.get("exp") ?? "").trim();
|
|
82
|
+
if (!isValidLinkPublicKey(pk)) return null;
|
|
83
|
+
if (!isValidLinkNonce(nonce)) return null;
|
|
84
|
+
if (!exp || Number.isNaN(Date.parse(exp))) return null;
|
|
85
|
+
return { delegate, pk, nonce, exp };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build the offer string (the form a browser publishes; here for tests). */
|
|
89
|
+
export function buildLinkOffer(offer) {
|
|
90
|
+
if (!isValidLinkDelegate(offer?.delegate)) throw new Error("link offer: invalid delegate uuaid");
|
|
91
|
+
if (!isValidLinkPublicKey(offer?.pk)) throw new Error("link offer: invalid pk (need 64 hex)");
|
|
92
|
+
if (!isValidLinkNonce(offer?.nonce)) throw new Error("link offer: invalid nonce");
|
|
93
|
+
if (Number.isNaN(Date.parse(offer?.exp))) throw new Error("link offer: invalid exp (need ISO date)");
|
|
94
|
+
const params = new URLSearchParams({
|
|
95
|
+
pk: offer.pk.toLowerCase(),
|
|
96
|
+
nonce: offer.nonce.toLowerCase(),
|
|
97
|
+
exp: offer.exp,
|
|
98
|
+
});
|
|
99
|
+
return `uuaid://${LINK_OFFER_DEEPLINK_HOST}/${encodeURIComponent(offer.delegate)}?${params.toString()}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True while `now` is strictly before the offer's exp instant. */
|
|
103
|
+
export function isLinkOfferLive(offer, now = Date.now()) {
|
|
104
|
+
const expMs = Date.parse(offer?.exp);
|
|
105
|
+
if (Number.isNaN(expMs)) return false;
|
|
106
|
+
return now < expMs;
|
|
107
|
+
}
|