@uuaid/pillar 0.2.0-alpha.12 → 0.2.0-alpha.16
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/INTERACTION-CONVENTION.md +216 -0
- package/README.md +275 -11
- package/bin/pillar-blob.mjs +112 -0
- package/bin/pillar-directory.mjs +231 -0
- package/bin/pillar-seed.mjs +112 -13
- package/bin/pillar.mjs +6 -2
- package/package.json +20 -12
- package/src/blob/blob.mjs +426 -0
- package/src/blob/broker.mjs +129 -0
- package/src/blob/http.mjs +196 -0
- package/src/blob/store.mjs +55 -0
- package/src/identity/keychain.mjs +27 -10
- package/src/identity/tier.mjs +24 -3
- package/src/index.mjs +25 -2
- package/src/net/carrier-client.mjs +55 -9
- package/src/net/carrier-server.mjs +169 -11
- package/src/net/delegation.mjs +152 -0
- package/src/net/envelope.mjs +81 -0
- package/src/net/seed-registry.mjs +467 -0
- package/src/net/seed-tier.mjs +185 -0
- package/src/pillar.mjs +96 -3
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
3
|
+
import { Keychain } from "../identity/keychain.mjs";
|
|
4
|
+
import {
|
|
5
|
+
BlobBrokerError,
|
|
6
|
+
BlobExpiryError,
|
|
7
|
+
BlobFetchError,
|
|
8
|
+
BlobIntegrityError,
|
|
9
|
+
BlobNotFoundError,
|
|
10
|
+
BlobSenderKeyError,
|
|
11
|
+
BlobUploadError,
|
|
12
|
+
} from "./blob.mjs";
|
|
13
|
+
|
|
14
|
+
export const PILLAR_BLOB_AUDIENCE = "uuaid:pillar-blob:v1";
|
|
15
|
+
|
|
16
|
+
function payloadHash(payload) {
|
|
17
|
+
return `0x${createHash("sha256").update(jcs(payload)).digest("hex")}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function signedRequest(keychain, keyId, challenge, request) {
|
|
21
|
+
if (!keyId) throw new BlobBrokerError("blob-broker-key-id-required", "a registry-bound presentation key id is required");
|
|
22
|
+
const payload = { v: 1, audience: PILLAR_BLOB_AUDIENCE, challenge, request };
|
|
23
|
+
return {
|
|
24
|
+
payload,
|
|
25
|
+
payloadHash: payloadHash(payload),
|
|
26
|
+
signatures: [{
|
|
27
|
+
alg: "ed25519",
|
|
28
|
+
keyId,
|
|
29
|
+
publicKey: keychain._identity.publicKeyHex,
|
|
30
|
+
signature: keychain.sign(Buffer.from(jcs(payload), "utf-8")).toString("hex"),
|
|
31
|
+
created: new Date().toISOString(),
|
|
32
|
+
}],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function safeBase(url) {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
if (parsed.protocol !== "https:" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") {
|
|
39
|
+
throw new BlobBrokerError("blob-broker-insecure-url", "blob broker must use HTTPS");
|
|
40
|
+
}
|
|
41
|
+
return parsed.href.replace(/\/+$/, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** HTTP client for the UUAID broker. URLs are returned only to the live caller. */
|
|
45
|
+
export class PillarBlobHttpClient {
|
|
46
|
+
constructor({ baseUrl = "https://api.uuaid.org", keychain, keyId, fetchImpl = globalThis.fetch, timeoutMs = 15_000 } = {}) {
|
|
47
|
+
if (!keychain?._identity) throw new BlobBrokerError("blob-broker-no-identity", "loaded keychain required");
|
|
48
|
+
if (typeof fetchImpl !== "function") throw new BlobBrokerError("blob-broker-no-fetch", "fetch implementation required");
|
|
49
|
+
this.baseUrl = safeBase(baseUrl);
|
|
50
|
+
this.keychain = keychain;
|
|
51
|
+
this.keyId = keyId;
|
|
52
|
+
this.fetch = fetchImpl;
|
|
53
|
+
this.timeoutMs = timeoutMs;
|
|
54
|
+
this._trust = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async _json(path, { method = "POST", body } = {}) {
|
|
58
|
+
let response;
|
|
59
|
+
try {
|
|
60
|
+
response = await this.fetch(`${this.baseUrl}${path}`, {
|
|
61
|
+
method,
|
|
62
|
+
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
|
63
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
64
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new BlobBrokerError("blob-broker-unreachable", `blob broker request failed: ${error.message}`);
|
|
68
|
+
}
|
|
69
|
+
const parsed = await response.json().catch(() => ({}));
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const code = response.status === 404 ? "blob-broker-not-found"
|
|
72
|
+
: response.status === 410 ? "blob-broker-expired"
|
|
73
|
+
: response.status === 409 ? "blob-broker-replay"
|
|
74
|
+
: "blob-broker-rejected";
|
|
75
|
+
throw new BlobBrokerError(code, parsed.error ?? `blob broker returned ${response.status}`, response.status);
|
|
76
|
+
}
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
challenge(action, objectId = null) {
|
|
81
|
+
return this._json("/pillar/blobs/challenge", {
|
|
82
|
+
body: {
|
|
83
|
+
action,
|
|
84
|
+
agent_uuaid: this.keychain._identity.uuaid,
|
|
85
|
+
...(objectId ? { object_id: objectId } : {}),
|
|
86
|
+
},
|
|
87
|
+
}).then((body) => body.challenge);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
reserve(challenge, { recipient, ciphertextSha256, ciphertextBytes, expiresAt }) {
|
|
91
|
+
return this._json("/pillar/blobs/reserve", {
|
|
92
|
+
body: signedRequest(this.keychain, this.keyId, challenge, {
|
|
93
|
+
action: "reserve",
|
|
94
|
+
agent_uuaid: this.keychain._identity.uuaid,
|
|
95
|
+
recipient_uuaid: recipient,
|
|
96
|
+
ciphertext_sha256: ciphertextSha256,
|
|
97
|
+
ciphertext_bytes: ciphertextBytes,
|
|
98
|
+
expires_at: expiresAt,
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
finalize(objectId) {
|
|
104
|
+
return this.challenge("finalize", objectId).then((challenge) => this._json("/pillar/blobs/finalize", {
|
|
105
|
+
body: signedRequest(this.keychain, this.keyId, challenge, {
|
|
106
|
+
action: "finalize",
|
|
107
|
+
agent_uuaid: this.keychain._identity.uuaid,
|
|
108
|
+
}),
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
redeem(objectId) {
|
|
113
|
+
return this.challenge("redeem", objectId).then((challenge) => this._json("/pillar/blobs/redeem", {
|
|
114
|
+
body: signedRequest(this.keychain, this.keyId, challenge, {
|
|
115
|
+
action: "redeem",
|
|
116
|
+
agent_uuaid: this.keychain._identity.uuaid,
|
|
117
|
+
}),
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async upload(ticket, ciphertext) {
|
|
122
|
+
try {
|
|
123
|
+
const response = await this.fetch(ticket.url, {
|
|
124
|
+
method: "PUT",
|
|
125
|
+
headers: ticket.headers,
|
|
126
|
+
body: ciphertext,
|
|
127
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
128
|
+
});
|
|
129
|
+
if (!response.ok) throw new Error(`upload returned ${response.status}`);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw new BlobUploadError(`temporary blob upload failed: ${error.message}`, error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async download(ticket) {
|
|
136
|
+
let response;
|
|
137
|
+
try {
|
|
138
|
+
response = await this.fetch(ticket.url, { signal: AbortSignal.timeout(this.timeoutMs) });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
throw new BlobFetchError(`temporary blob download failed: ${error.message}`, error);
|
|
141
|
+
}
|
|
142
|
+
if (response.status === 404) throw new BlobNotFoundError("temporary-object");
|
|
143
|
+
if (response.status === 403) throw new BlobExpiryError("temporary blob download capability expired");
|
|
144
|
+
if (!response.ok) throw new BlobFetchError(`temporary blob download returned ${response.status}`);
|
|
145
|
+
return Buffer.from(await response.arrayBuffer());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async _registryTrust() {
|
|
149
|
+
if (this._trust && this._trust.until > Date.now()) return this._trust.value;
|
|
150
|
+
const value = await this._json("/.well-known/uuaid-registry.json", { method: "GET" });
|
|
151
|
+
if (value.provisioned !== true || !Array.isArray(value.keys)) {
|
|
152
|
+
throw new BlobSenderKeyError("registry trust root is not provisioned", "unknown", "unprovisioned");
|
|
153
|
+
}
|
|
154
|
+
this._trust = { value, until: Date.now() + 300_000 };
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Fail closed unless a fresh registry-signed badge names this exact active key. */
|
|
159
|
+
async assertRegistryActiveSender(sender, expectedPublicKey) {
|
|
160
|
+
const trust = await this._registryTrust();
|
|
161
|
+
const badge = await this._json(`/iaaso/v1/badge/${encodeURIComponent(sender)}`, { method: "GET" });
|
|
162
|
+
const presentation = badge?.payload?.subject?.presentationKey;
|
|
163
|
+
const keyMatches = badge?.payload?.subject?.uuaid === sender
|
|
164
|
+
&& presentation?.alg === "ed25519"
|
|
165
|
+
&& presentation?.publicKey?.toLowerCase() === expectedPublicKey?.toLowerCase();
|
|
166
|
+
const fresh = Number.isFinite(Date.parse(badge?.payload?.freshUntil)) && Date.parse(badge.payload.freshUntil) > Date.now();
|
|
167
|
+
const hashMatches = badge?.payloadHash === payloadHash(badge?.payload);
|
|
168
|
+
const registrySignature = Array.isArray(badge?.signatures)
|
|
169
|
+
? badge.signatures.find((signature) => {
|
|
170
|
+
if (signature.alg !== "ed25519") return false;
|
|
171
|
+
const pinned = trust.keys.find((key) => key.keyId === signature.keyId && key.alg === "ed25519" && key.retired !== true);
|
|
172
|
+
if (!pinned || pinned.publicKey.toLowerCase() !== signature.publicKey?.toLowerCase()) return false;
|
|
173
|
+
try {
|
|
174
|
+
return Keychain.verifyDetached(signature.publicKey, Buffer.from(jcs(badge.payload), "utf-8"), Buffer.from(signature.signature, "hex"));
|
|
175
|
+
} catch (_error) { return false; }
|
|
176
|
+
})
|
|
177
|
+
: null;
|
|
178
|
+
if (!keyMatches || !fresh || !hashMatches || !registrySignature) {
|
|
179
|
+
throw new BlobSenderKeyError("sender presentation key is not registry-active", sender, "inactive");
|
|
180
|
+
}
|
|
181
|
+
return "active";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
crossCheckRedemption(ref, brokerRecord) {
|
|
185
|
+
if (
|
|
186
|
+
brokerRecord?.object_id !== ref.object ||
|
|
187
|
+
brokerRecord?.sender_uuaid !== ref.sender ||
|
|
188
|
+
brokerRecord?.recipient_uuaid !== ref.recipient ||
|
|
189
|
+
brokerRecord?.ciphertext_sha256 !== ref.hash ||
|
|
190
|
+
brokerRecord?.ciphertext_bytes !== ref.size ||
|
|
191
|
+
brokerRecord?.expires_at !== ref.expiry
|
|
192
|
+
) {
|
|
193
|
+
throw new BlobIntegrityError("broker metadata does not match the signed blob-ref");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blob stores — where encrypted blob objects (ciphertext only) live while
|
|
3
|
+
* they wait to be redeemed. Stores hold NO plaintext and NO key material:
|
|
4
|
+
* the DEK travels only inside the sealed envelope's blob-ref.
|
|
5
|
+
*
|
|
6
|
+
* Temporary-bucket semantics: objects are ephemeral; the blob-ref carries
|
|
7
|
+
* the expiry and the receive path re-checks it. Stores SHOULD garbage-
|
|
8
|
+
* collect expired objects, but an object living past its expiry is inert —
|
|
9
|
+
* the ref is refused before any fetch result is trusted.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync } from "node:fs";
|
|
12
|
+
import { readFile, writeFile, rm, access } from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { BlobUploadError } from "./blob.mjs";
|
|
15
|
+
|
|
16
|
+
export class MemoryBlobStore {
|
|
17
|
+
constructor() { this._map = new Map(); }
|
|
18
|
+
async put(object, buf) { this._map.set(object, Buffer.from(buf)); return { object, bytes: buf.length }; }
|
|
19
|
+
async get(object) { return this._map.get(object) ?? null; }
|
|
20
|
+
async has(object) { return this._map.has(object); }
|
|
21
|
+
async delete(object) { return this._map.delete(object); }
|
|
22
|
+
get size() { return this._map.size; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class FsBlobStore {
|
|
26
|
+
constructor({ dir }) {
|
|
27
|
+
if (!dir) throw new Error("FsBlobStore: dir required");
|
|
28
|
+
this.dir = dir;
|
|
29
|
+
this._ready = mkdirSync(dir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
_path(object) {
|
|
32
|
+
// Object names are opaque 256-bit hex ids; encode defensively anyway.
|
|
33
|
+
return join(this.dir, encodeURIComponent(object));
|
|
34
|
+
}
|
|
35
|
+
async put(object, buf) {
|
|
36
|
+
try {
|
|
37
|
+
await writeFile(this._path(object), buf);
|
|
38
|
+
return { object, bytes: buf.length };
|
|
39
|
+
} catch (e) {
|
|
40
|
+
throw new BlobUploadError(`FsBlobStore write failed for ${object}: ${e.message}`, e);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async get(object) {
|
|
44
|
+
try { return await readFile(this._path(object)); }
|
|
45
|
+
catch (e) { if (e?.code === "ENOENT") return null; throw e; }
|
|
46
|
+
}
|
|
47
|
+
async has(object) {
|
|
48
|
+
try { await access(this._path(object)); return true; }
|
|
49
|
+
catch (_e) { return false; }
|
|
50
|
+
}
|
|
51
|
+
async delete(object) {
|
|
52
|
+
try { await rm(this._path(object), { force: true }); return true; }
|
|
53
|
+
catch (_e) { return false; }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -15,6 +15,22 @@ import { randomBytes, createHash, generateKeyPairSync, sign, verify, createPubli
|
|
|
15
15
|
|
|
16
16
|
const KEYCHAIN_VERSION = 1;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Derive a stable, publishable local id from a raw Ed25519 public key. This
|
|
20
|
+
* makes the UUAID self-authenticating: given the UUAID, anyone can
|
|
21
|
+
* independently check that the public key hashes to the expected local-id.
|
|
22
|
+
*
|
|
23
|
+
* Public because consumers verifying a UUAID against a public key need it
|
|
24
|
+
* without reaching into `Keychain._localIdFromKey`.
|
|
25
|
+
*/
|
|
26
|
+
export function localIdFromKey(rawPub) {
|
|
27
|
+
const h = createHash("sha256").update(rawPub).digest();
|
|
28
|
+
// First 16 bytes -> UUID-style dash format for readability.
|
|
29
|
+
const b = h.slice(0, 16);
|
|
30
|
+
const hex = Buffer.from(b).toString("hex");
|
|
31
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
export class Keychain {
|
|
19
35
|
constructor(path) {
|
|
20
36
|
this.path = path;
|
|
@@ -48,16 +64,17 @@ export class Keychain {
|
|
|
48
64
|
};
|
|
49
65
|
}
|
|
50
66
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Derive the local-id from a raw Ed25519 public key.
|
|
69
|
+
*
|
|
70
|
+
* Kept on the class for the call sites that already reach for it; the
|
|
71
|
+
* implementation is the module-level `localIdFromKey`, which is the public
|
|
72
|
+
* export.
|
|
73
|
+
*/
|
|
74
|
+
static localIdFromKey = localIdFromKey;
|
|
75
|
+
|
|
76
|
+
/** @deprecated Alias kept for compatibility — use `localIdFromKey`. */
|
|
77
|
+
static _localIdFromKey = localIdFromKey;
|
|
61
78
|
|
|
62
79
|
/**
|
|
63
80
|
* Load an identity from disk.
|
package/src/identity/tier.mjs
CHANGED
|
@@ -9,11 +9,22 @@
|
|
|
9
9
|
* self-authenticating design. The wire API is unchanged: a grant only
|
|
10
10
|
* raises the holder's rate/size budgets at carriers that trust the issuer.
|
|
11
11
|
*
|
|
12
|
-
* Qualification
|
|
13
|
-
* WHO gets a grant
|
|
12
|
+
* Qualification has two routes. The original is off-wire: the foundation's
|
|
13
|
+
* review process decides WHO gets a grant. The second is earned on-wire —
|
|
14
|
+
* operators who keep a public seed alive are promoted by the seed registry
|
|
15
|
+
* (net/seed-registry.mjs) and issued a grant automatically, no review. This
|
|
16
|
+
* module only makes grants unforgeable and portable; it does not care which
|
|
17
|
+
* route produced one.
|
|
14
18
|
*
|
|
15
19
|
* Grant shape (JCS-signed over every field except `sig`):
|
|
16
20
|
* { v: 1, uuaid, tier, envelopesPerMin, maxBodyBytes, expires, issuer, sig }
|
|
21
|
+
* ...plus optional { priority: true, seedTier, seedUrl } on earned grants.
|
|
22
|
+
*
|
|
23
|
+
* `priority` is the wire half of priority delivery: a carrier that trusts the
|
|
24
|
+
* issuer admits the holder from a reserved lane when it is otherwise shedding
|
|
25
|
+
* load. It is inside the signed body, so it cannot be added to a grant after
|
|
26
|
+
* issue. Older carriers that predate the field verify such a grant unchanged
|
|
27
|
+
* (JCS covers whatever keys are present) and simply ignore the privilege.
|
|
17
28
|
*/
|
|
18
29
|
import { createHash } from "node:crypto";
|
|
19
30
|
import { jcs } from "./jcs.mjs";
|
|
@@ -42,7 +53,7 @@ function signable(grant) {
|
|
|
42
53
|
* Rates/sizes default from the tier ladder; explicit overrides are clamped
|
|
43
54
|
* to the absolute ceilings.
|
|
44
55
|
*/
|
|
45
|
-
export function makeTierGrant(keychain, { uuaid, tier, envelopesPerMin, maxBodyBytes, expires }) {
|
|
56
|
+
export function makeTierGrant(keychain, { uuaid, tier, envelopesPerMin, maxBodyBytes, expires, priority, seedTier, seedUrl }) {
|
|
46
57
|
if (!TIER_DEFAULTS[tier]) throw new Error(`unknown tier "${tier}" (${Object.keys(TIER_DEFAULTS).join("|")})`);
|
|
47
58
|
if (!uuaid?.startsWith("uuaid:")) throw new Error("grant subject must be a uuaid:...");
|
|
48
59
|
if (!expires || !Number.isFinite(Date.parse(expires))) throw new Error("expires must be an ISO date");
|
|
@@ -54,6 +65,12 @@ export function makeTierGrant(keychain, { uuaid, tier, envelopesPerMin, maxBodyB
|
|
|
54
65
|
maxBodyBytes: Math.min(maxBodyBytes ?? TIER_DEFAULTS[tier].maxBodyBytes, ABSOLUTE_MAX_BODY),
|
|
55
66
|
expires,
|
|
56
67
|
issuer: keychain._identity.publicKeyHex,
|
|
68
|
+
// Optional fields are omitted rather than set false/null so that a plain
|
|
69
|
+
// grant's signed body stays byte-identical to what pre-priority issuers
|
|
70
|
+
// produced.
|
|
71
|
+
...(priority ? { priority: true } : {}),
|
|
72
|
+
...(seedTier ? { seedTier } : {}),
|
|
73
|
+
...(seedUrl ? { seedUrl } : {}),
|
|
57
74
|
};
|
|
58
75
|
grant.sig = keychain.sign(Buffer.from(signable(grant), "utf-8")).toString("hex");
|
|
59
76
|
return grant;
|
|
@@ -72,6 +89,10 @@ export function verifyTierGrant(grant, trustedIssuers, now = Date.now()) {
|
|
|
72
89
|
if (!(grant.envelopesPerMin <= ABSOLUTE_MAX_RATE) || !(grant.maxBodyBytes <= ABSOLUTE_MAX_BODY)) {
|
|
73
90
|
return { ok: false, reason: "over-ceiling" };
|
|
74
91
|
}
|
|
92
|
+
// Fail closed on a malformed privilege: `priority` is either absent or the
|
|
93
|
+
// boolean true. Anything else (a truthy string, 1, an object) is a grant we
|
|
94
|
+
// do not understand, and understanding it wrongly grants a reserved lane.
|
|
95
|
+
if ("priority" in grant && grant.priority !== true) return { ok: false, reason: "bad-priority" };
|
|
75
96
|
let verified = false;
|
|
76
97
|
try {
|
|
77
98
|
verified = Keychain.verifyDetached(grant.issuer, Buffer.from(signable(grant), "utf-8"), Buffer.from(grant.sig, "hex"));
|
package/src/index.mjs
CHANGED
|
@@ -1,18 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @uuaid/pillar — v0.2.0-alpha.0
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Every agent runs its own PDP pillar, discovers peers
|
|
5
5
|
* via a Kademlia DHT, exchanges cryptographically signed messages over libp2p.
|
|
6
6
|
*
|
|
7
7
|
* Entry point. Not intended for import in browsers today — browser build
|
|
8
8
|
* lives at @uuaid/pillar-browser (later).
|
|
9
9
|
*/
|
|
10
|
-
export { Keychain } from "./identity/keychain.mjs";
|
|
10
|
+
export { Keychain, localIdFromKey } from "./identity/keychain.mjs";
|
|
11
11
|
export { jcs } from "./identity/jcs.mjs";
|
|
12
12
|
export { seal, open, decrypt, envelopeSha, keychainSeed, ENVELOPE_VERSION, E2E_ALG } from "./net/envelope.mjs";
|
|
13
|
+
export { DELEGATION_VERSION, verifyDelegationDoc, delegationCoversKind } from "./net/delegation.mjs";
|
|
13
14
|
export { sealPayload, openPayload, edPubToX25519, edSeedToX25519 } from "./crypto/e2e.mjs";
|
|
14
15
|
export { Mailbox } from "./mailbox/mailbox.mjs";
|
|
15
16
|
export { Pillar } from "./pillar.mjs";
|
|
16
17
|
export { createTransport, PILLAR_PROTOCOL, uuaidToDhtKey, peerIdFromEd25519Hex } from "./net/transport.mjs";
|
|
17
18
|
export { CarrierClient } from "./net/carrier-client.mjs";
|
|
18
19
|
export { CarrierServer } from "./net/carrier-server.mjs";
|
|
20
|
+
// Seeds: the ladder that turns sustained relay service into earned tier
|
|
21
|
+
// grants + priority delivery, and the registry that observes and publishes it.
|
|
22
|
+
export {
|
|
23
|
+
SEED_TIERS, SEED_TIER_NAMES, GRANT_TTL_DAYS, DEMOTE_AFTER_FAILURES,
|
|
24
|
+
seedTier, seedTierAtLeast, uptimeOf, evaluateSeedTier,
|
|
25
|
+
} from "./net/seed-tier.mjs";
|
|
26
|
+
export { SeedRegistry, checkSeedUrl, verifySeedInfo, PROBE_WINDOW } from "./net/seed-registry.mjs";
|
|
27
|
+
// Blob transport (client/core half): oversized payloads ride an ephemeral
|
|
28
|
+
// encrypted object store; only a <=4KiB signed ref travels in the envelope.
|
|
29
|
+
export {
|
|
30
|
+
BLOB_REF_V, BLOB_KIND, MAX_BLOB_REF_BYTES, MAX_INLINE_BODY_BYTES,
|
|
31
|
+
MAX_BLOB_OBJECT_BYTES, MAX_BLOB_PLAINTEXT_BYTES,
|
|
32
|
+
DEFAULT_BLOB_TTL_MS, DEFAULT_DIRECT_THRESHOLD_BYTES,
|
|
33
|
+
BlobError, BlobSizeError, BlobExpiryError, BlobNotFoundError, BlobUploadError,
|
|
34
|
+
BlobFetchError, BlobIntegrityError, BlobRefError, BlobBrokerError, BlobSenderKeyError,
|
|
35
|
+
blobAad, encryptBlob, decryptBlob, contentHash, mkObjectName,
|
|
36
|
+
signBlobRef, verifyBlobRefSig, blobRefMetadata, blobRefSecret, refSerializedBytes,
|
|
37
|
+
makeBlobRef, validateBlobRef, envelopeBodyBytes, preflightBodyBytes, chooseTransport,
|
|
38
|
+
prepareBlobSend, fetchAndDecryptBlob, prepareBrokerBlobSend, fetchBrokerBlob,
|
|
39
|
+
} from "./blob/blob.mjs";
|
|
40
|
+
export { MemoryBlobStore, FsBlobStore } from "./blob/store.mjs";
|
|
41
|
+
export { PillarBlobHttpClient, PILLAR_BLOB_AUDIENCE } from "./blob/http.mjs";
|
|
@@ -5,17 +5,45 @@
|
|
|
5
5
|
* This is the universal transport: works from sandboxes, NATed laptops,
|
|
6
6
|
* serverless, anywhere. No listening socket required.
|
|
7
7
|
*/
|
|
8
|
+
import { envelopeBodyBytes, preflightBodyBytes } from "../blob/blob.mjs";
|
|
9
|
+
import { seedTier } from "./seed-tier.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Rank carriers by how proven they are, best first. Unknown carriers rank as
|
|
13
|
+
* `probation` — the primary carrier from a directory has no seed tier and
|
|
14
|
+
* should not be assumed better than a seed that earned one, nor worse.
|
|
15
|
+
* Array.prototype.sort is stable, so a client given no tier information at
|
|
16
|
+
* all keeps its list in exactly the order it was constructed with.
|
|
17
|
+
*/
|
|
18
|
+
function byTier(tiers) {
|
|
19
|
+
return (a, b) => seedTier(tiers[a] ?? "probation").listRank - seedTier(tiers[b] ?? "probation").listRank;
|
|
20
|
+
}
|
|
8
21
|
|
|
9
22
|
export class CarrierClient {
|
|
10
23
|
/**
|
|
11
24
|
* @param {{keychain: import('../identity/keychain.mjs').Keychain, carriers: string[]}} opts
|
|
12
25
|
* carriers: base URLs, e.g. ["https://pillar.uuaid.org"]
|
|
13
26
|
*/
|
|
14
|
-
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} opts
|
|
29
|
+
* carrierTiers optional { "<url>": "<seed tier>" } from a directory's
|
|
30
|
+
* seeds[]. Drives which carrier's receipt is authoritative
|
|
31
|
+
* and which one is polled first — see `deliver`.
|
|
32
|
+
*/
|
|
33
|
+
constructor({ keychain, carriers, tierGrant = null, carrierTiers = {} }) {
|
|
15
34
|
if (!keychain?._identity) throw new Error("CarrierClient needs a loaded keychain");
|
|
16
35
|
if (!carriers?.length) throw new Error("CarrierClient needs at least one carrier URL");
|
|
17
36
|
this.keychain = keychain;
|
|
18
|
-
|
|
37
|
+
// Normalise tier keys the same way as carrier URLs so a trailing slash on
|
|
38
|
+
// one side does not silently drop a seed to `probation`.
|
|
39
|
+
this.carrierTiers = Object.fromEntries(
|
|
40
|
+
Object.entries(carrierTiers).map(([u, t]) => [String(u).replace(/\/+$/, ""), t]),
|
|
41
|
+
);
|
|
42
|
+
this.carriers = carriers.map(u => u.replace(/\/+$/, "")).sort(byTier(this.carrierTiers));
|
|
43
|
+
// Tier label for the local preflight: a signed grant raises it; default
|
|
44
|
+
// stays community (matches the carrier's unauthenticated tier).
|
|
45
|
+
this.tier = tierGrant?.tier ?? "community";
|
|
46
|
+
this.maxBodyBytes = Number.isInteger(tierGrant?.maxBodyBytes) ? tierGrant.maxBodyBytes : null;
|
|
19
47
|
// Optional signed tier grant (tier.mjs) — sent as a header on deliver so
|
|
20
48
|
// qualifying senders get raised budgets at carriers that trust the issuer.
|
|
21
49
|
this.tierGrantB64 = tierGrant
|
|
@@ -35,12 +63,27 @@ export class CarrierClient {
|
|
|
35
63
|
* envelope.id and re-fires no handlers.
|
|
36
64
|
*
|
|
37
65
|
* Resolves once every carrier has answered. Succeeds if at least one
|
|
38
|
-
* accepted; throws if every carrier rejects. Returns the
|
|
39
|
-
* accepting
|
|
40
|
-
* `all` with every carrier's outcome ({ carrier, ok,
|
|
41
|
-
* duplicate?, error? }).
|
|
66
|
+
* accepted; throws if every carrier rejects. Returns the receipt from the
|
|
67
|
+
* most PROVEN accepting carrier as the primary ({ carrier, seq, sha,
|
|
68
|
+
* duplicate }) plus `all` with every carrier's outcome ({ carrier, ok,
|
|
69
|
+
* seq?, sha?, duplicate?, error? }).
|
|
70
|
+
*
|
|
71
|
+
* Which receipt is primary matters because callers quote its seq back as a
|
|
72
|
+
* cursor and cite its carrier in receipts: it should name a seed that will
|
|
73
|
+
* still be there. With no tier information every carrier ranks equally and
|
|
74
|
+
* the first-in-list receipt wins, exactly as before.
|
|
42
75
|
*/
|
|
43
76
|
async deliver(envelope, { timeoutMs = 10000 } = {}) {
|
|
77
|
+
// Preflight against the carrier limits BEFORE any network call: the
|
|
78
|
+
// tier inline limit first, then the 2 MiB direct hard cap — no tier
|
|
79
|
+
// grant can raise the hard cap. Typed failure, not a carrier 413.
|
|
80
|
+
const pf = preflightBodyBytes(envelopeBodyBytes(envelope), { tier: this.tier, maxBodyBytes: this.maxBodyBytes });
|
|
81
|
+
if (!pf.ok) {
|
|
82
|
+
const e = new Error(`carrier preflight: body ${pf.bytes}B exceeds ${pf.source} limit ${pf.limit}B — use the blob transport`);
|
|
83
|
+
e.code = pf.source === "direct-cap" ? "blob-too-large" : "over-tier-limit";
|
|
84
|
+
e.limit = pf.limit; e.source = pf.source;
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
44
87
|
const attempt = async (base) => {
|
|
45
88
|
try {
|
|
46
89
|
const res = await fetch(`${base}/v1/envelopes`, {
|
|
@@ -52,11 +95,13 @@ export class CarrierClient {
|
|
|
52
95
|
body: JSON.stringify(envelope),
|
|
53
96
|
signal: AbortSignal.timeout(timeoutMs),
|
|
54
97
|
});
|
|
55
|
-
const
|
|
98
|
+
const raw = await res.text();
|
|
99
|
+
let body = {};
|
|
100
|
+
try { body = raw ? JSON.parse(raw) : {}; } catch (_error) {}
|
|
56
101
|
if (res.status === 202 && body.accepted) {
|
|
57
102
|
return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate };
|
|
58
103
|
}
|
|
59
|
-
return { carrier: base, ok: false, error: `${res.status} ${body.reason ?? body.error ?? ""}` };
|
|
104
|
+
return { carrier: base, ok: false, error: `${res.status} ${body.reason ?? body.error ?? (raw ? "non-json-response" : "empty-response")}` };
|
|
60
105
|
} catch (e) {
|
|
61
106
|
return { carrier: base, ok: false, error: e.message };
|
|
62
107
|
}
|
|
@@ -67,7 +112,8 @@ export class CarrierClient {
|
|
|
67
112
|
if (!accepted.length) {
|
|
68
113
|
throw new Error(`carrier deliver failed on all carriers: ${all.map(r => `${r.carrier}: ${r.error}`).join("; ")}`);
|
|
69
114
|
}
|
|
70
|
-
const
|
|
115
|
+
const ranked = accepted.slice().sort((a, b) => byTier(this.carrierTiers)(a.carrier, b.carrier));
|
|
116
|
+
const { carrier, seq, sha, duplicate } = ranked[0];
|
|
71
117
|
return { carrier, seq, sha, duplicate, all };
|
|
72
118
|
}
|
|
73
119
|
|