@uuaid/agent-commons 0.0.0 → 0.2.0-alpha.2
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 +25 -0
- package/PROVENANCE.json +29 -0
- package/README.md +313 -5
- package/bin/agent-commons.mjs +71 -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 +28 -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 +79 -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 +39 -0
- package/src/continuity.d.mts +24 -0
- package/src/continuity.mjs +594 -0
- package/src/governance-scope.d.mts +46 -0
- package/src/governance-scope.mjs +139 -0
- package/src/governance.mjs +353 -0
- package/src/index.d.ts +109 -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 +91 -0
- package/src/runtime.mjs +346 -0
- package/src/store.mjs +73 -0
- package/src/transport.d.mts +76 -0
- package/src/transport.mjs +267 -0
- package/src/trust.mjs +65 -0
- package/src/vendor/pillar/blob/blob.mjs +510 -0
- package/src/vendor/pillar/crypto/e2e.mjs +97 -0
- package/src/vendor/pillar/identity/jcs.mjs +35 -0
- package/src/vendor/pillar/identity/keychain.mjs +192 -0
- package/src/vendor/pillar/identity/tier.mjs +130 -0
- package/src/vendor/pillar/net/carrier-client.mjs +197 -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 +127 -0
- package/src/vendor/pillar/net/seed-tier.mjs +185 -0
- package/test/channels.test.mjs +268 -0
- package/test/cohort.test.mjs +36 -0
- package/test/collaboration.test.mjs +459 -0
- package/test/continuity.test.mjs +229 -0
- package/test/custody.negative.test.mjs +165 -0
- package/test/derivation-parity.test.mjs +19 -0
- package/test/fixtures/derivation-vectors.json +26 -0
- package/test/fixtures/ed25519-small-order.vectors.json +15 -0
- package/test/governance-expected-red.expected-red.mjs +241 -0
- package/test/governance-scope.test.mjs +142 -0
- package/test/governance-t1-t10.test.mjs +477 -0
- package/test/governance.test.mjs +514 -0
- package/test/hold-admission.test.mjs +1185 -0
- package/test/keychain-strict-ed25519.test.mjs +240 -0
- package/test/offline.test.mjs +152 -0
- package/test/runtime-channels.test.mjs +1140 -0
- package/test/runtime.test.mjs +201 -0
- package/test/security-runtime.test.mjs +104 -0
- package/test/strict-ed25519-consumers.test.mjs +234 -0
- package/test/transport.test.mjs +353 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keychain — persistent identity for a pillar peer.
|
|
3
|
+
*
|
|
4
|
+
* Every agent has exactly one identity: an Ed25519 keypair. The private key
|
|
5
|
+
* is stored encrypted at rest with a password derived from the OS keyring
|
|
6
|
+
* (Windows Credential Manager / macOS Keychain / GNOME libsecret) if
|
|
7
|
+
* available, or a user-supplied passphrase otherwise.
|
|
8
|
+
*
|
|
9
|
+
* The keychain is deliberately simple: one keypair, one UUAID, one file.
|
|
10
|
+
* Rotation is a new keypair + a new signed profile in the DHT.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
import { randomBytes, createHash, generateKeyPairSync, sign, verify, createPublicKey, createPrivateKey, hkdfSync, createCipheriv, createDecipheriv } from "node:crypto";
|
|
15
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
16
|
+
|
|
17
|
+
const KEYCHAIN_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* RFC 8032 / FIPS 186-5 rule for an Ed25519 public key: the canonical encoding
|
|
21
|
+
* of a point that is not of small order and lies in the prime-order subgroup.
|
|
22
|
+
*
|
|
23
|
+
* OpenSSL's Ed25519 verify (node `crypto.verify`) does not check this: it uses
|
|
24
|
+
* the non-cofactored equation, and 5 of the 8 small-order points verify as
|
|
25
|
+
* keys. With the identity point as the key, R = identity and S = 0 verifies on
|
|
26
|
+
* every message, so anyone could forge a signature "from" that key's uuaid
|
|
27
|
+
* (found 2026-10-06). The same rule as `isStrictEd25519PublicKey` in
|
|
28
|
+
* @uuaid/pillar-client.
|
|
29
|
+
*
|
|
30
|
+
* The answer depends on the key alone, so it is cached (bounded).
|
|
31
|
+
*/
|
|
32
|
+
const STRICT_KEY_CACHE_MAX = 4096;
|
|
33
|
+
const strictKeyCache = new Map();
|
|
34
|
+
|
|
35
|
+
export function isStrictEd25519PublicKey(publicKeyHex) {
|
|
36
|
+
if (typeof publicKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return false;
|
|
37
|
+
const hex = publicKeyHex.toLowerCase();
|
|
38
|
+
const cached = strictKeyCache.get(hex);
|
|
39
|
+
if (cached !== undefined) return cached;
|
|
40
|
+
let ok = false;
|
|
41
|
+
try {
|
|
42
|
+
const point = ed25519.Point.fromHex(hex, false);
|
|
43
|
+
ok = point.toHex() === hex && !point.isSmallOrder() && point.isTorsionFree();
|
|
44
|
+
} catch (_e) {
|
|
45
|
+
ok = false;
|
|
46
|
+
}
|
|
47
|
+
if (strictKeyCache.size >= STRICT_KEY_CACHE_MAX) strictKeyCache.delete(strictKeyCache.keys().next().value);
|
|
48
|
+
strictKeyCache.set(hex, ok);
|
|
49
|
+
return ok;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Derive a stable, publishable local id from a raw Ed25519 public key. This
|
|
54
|
+
* makes the UUAID self-authenticating: given the UUAID, anyone can
|
|
55
|
+
* independently check that the public key hashes to the expected local-id.
|
|
56
|
+
*
|
|
57
|
+
* Public because consumers verifying a UUAID against a public key need it
|
|
58
|
+
* without reaching into `Keychain._localIdFromKey`.
|
|
59
|
+
*/
|
|
60
|
+
export function localIdFromKey(rawPub) {
|
|
61
|
+
const h = createHash("sha256").update(rawPub).digest();
|
|
62
|
+
// First 16 bytes -> UUID-style dash format for readability.
|
|
63
|
+
const b = h.slice(0, 16);
|
|
64
|
+
const hex = Buffer.from(b).toString("hex");
|
|
65
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class Keychain {
|
|
69
|
+
constructor(path) {
|
|
70
|
+
this.path = path;
|
|
71
|
+
this._identity = null; // { uuaid, publicKeyHex, privateKey (KeyObject), createdAt }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
exists() {
|
|
75
|
+
return existsSync(this.path);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Generate a fresh identity. Never overwrites.
|
|
80
|
+
*
|
|
81
|
+
* uuaidNamespace: default "foundation" per IAASO-1001 today; will support
|
|
82
|
+
* federated forms once federation profile ratifies.
|
|
83
|
+
* objectType: default "agent"; overridable to "service" etc.
|
|
84
|
+
* localId: default random UUIDv4-like; caller may supply.
|
|
85
|
+
*/
|
|
86
|
+
static generate({ uuaidNamespace = "foundation", objectType = "agent", localId = null } = {}) {
|
|
87
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
88
|
+
const pubDer = publicKey.export({ type: "spki", format: "der" });
|
|
89
|
+
const rawPub = pubDer.slice(pubDer.length - 32);
|
|
90
|
+
const localIdActual = localId ?? Keychain._localIdFromKey(rawPub);
|
|
91
|
+
const uuaid = `uuaid:${uuaidNamespace}:${objectType}:${localIdActual}`;
|
|
92
|
+
return {
|
|
93
|
+
uuaid,
|
|
94
|
+
publicKey,
|
|
95
|
+
privateKey,
|
|
96
|
+
publicKeyHex: Buffer.from(rawPub).toString("hex"),
|
|
97
|
+
createdAt: new Date().toISOString(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Derive the local-id from a raw Ed25519 public key.
|
|
103
|
+
*
|
|
104
|
+
* Kept on the class for the call sites that already reach for it; the
|
|
105
|
+
* implementation is the module-level `localIdFromKey`, which is the public
|
|
106
|
+
* export.
|
|
107
|
+
*/
|
|
108
|
+
static localIdFromKey = localIdFromKey;
|
|
109
|
+
|
|
110
|
+
/** @deprecated Alias kept for compatibility — use `localIdFromKey`. */
|
|
111
|
+
static _localIdFromKey = localIdFromKey;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Load an identity from disk.
|
|
115
|
+
*/
|
|
116
|
+
load({ passphrase } = {}) {
|
|
117
|
+
if (!this.exists()) throw new Error("keychain not initialized; run `pillar init` first");
|
|
118
|
+
const blob = JSON.parse(readFileSync(this.path, "utf-8"));
|
|
119
|
+
if (blob.version !== KEYCHAIN_VERSION) throw new Error(`unsupported keychain version ${blob.version}`);
|
|
120
|
+
const salt = Buffer.from(blob.salt, "hex");
|
|
121
|
+
const key = hkdfSync("sha256", Buffer.from(passphrase ?? "", "utf-8"), salt, "uuaid-pillar-keychain-v1", 32);
|
|
122
|
+
const iv = Buffer.from(blob.iv, "hex");
|
|
123
|
+
const ct = Buffer.from(blob.ciphertext, "base64");
|
|
124
|
+
const tag = Buffer.from(blob.tag, "hex");
|
|
125
|
+
const decipher = createDecipheriv("aes-256-gcm", Buffer.from(key), iv);
|
|
126
|
+
decipher.setAuthTag(tag);
|
|
127
|
+
const plain = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
128
|
+
const seed = plain; // 32-byte raw ed25519 seed
|
|
129
|
+
const pkcs8Prefix = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
130
|
+
const privateKey = createPrivateKey({ key: Buffer.concat([pkcs8Prefix, seed]), format: "der", type: "pkcs8" });
|
|
131
|
+
const publicKey = createPublicKey(privateKey);
|
|
132
|
+
const pubDer = publicKey.export({ type: "spki", format: "der" });
|
|
133
|
+
const rawPub = pubDer.slice(pubDer.length - 32);
|
|
134
|
+
const publicKeyHex = Buffer.from(rawPub).toString("hex");
|
|
135
|
+
// Sanity: derived UUAID must match stored UUAID.
|
|
136
|
+
const derivedLocalId = Keychain._localIdFromKey(rawPub);
|
|
137
|
+
const parts = blob.uuaid.split(":");
|
|
138
|
+
if (parts.length < 4 || parts[3] !== derivedLocalId) {
|
|
139
|
+
throw new Error("keychain integrity failure: UUAID does not match keypair");
|
|
140
|
+
}
|
|
141
|
+
this._identity = { uuaid: blob.uuaid, publicKey, privateKey, publicKeyHex, createdAt: blob.createdAt };
|
|
142
|
+
return this._identity;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Save identity to disk, encrypted with the passphrase.
|
|
147
|
+
*/
|
|
148
|
+
save(identity, { passphrase } = {}) {
|
|
149
|
+
const seedDer = identity.privateKey.export({ type: "pkcs8", format: "der" });
|
|
150
|
+
const seed = seedDer.slice(seedDer.length - 32);
|
|
151
|
+
const salt = randomBytes(16);
|
|
152
|
+
const key = hkdfSync("sha256", Buffer.from(passphrase ?? "", "utf-8"), salt, "uuaid-pillar-keychain-v1", 32);
|
|
153
|
+
const iv = randomBytes(12);
|
|
154
|
+
const cipher = createCipheriv("aes-256-gcm", Buffer.from(key), iv);
|
|
155
|
+
const ct = Buffer.concat([cipher.update(seed), cipher.final()]);
|
|
156
|
+
const tag = cipher.getAuthTag();
|
|
157
|
+
const blob = {
|
|
158
|
+
version: KEYCHAIN_VERSION,
|
|
159
|
+
uuaid: identity.uuaid,
|
|
160
|
+
createdAt: identity.createdAt,
|
|
161
|
+
salt: salt.toString("hex"),
|
|
162
|
+
iv: iv.toString("hex"),
|
|
163
|
+
ciphertext: ct.toString("base64"),
|
|
164
|
+
tag: tag.toString("hex"),
|
|
165
|
+
// Public material is stored in the clear so `pillar profile` doesn't need a passphrase.
|
|
166
|
+
publicKeyHex: identity.publicKeyHex,
|
|
167
|
+
};
|
|
168
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
169
|
+
writeFileSync(this.path, JSON.stringify(blob, null, 2));
|
|
170
|
+
chmodSync(this.path, 0o600);
|
|
171
|
+
this._identity = identity;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
publicView() {
|
|
175
|
+
if (!this.exists()) return null;
|
|
176
|
+
const blob = JSON.parse(readFileSync(this.path, "utf-8"));
|
|
177
|
+
return { uuaid: blob.uuaid, publicKeyHex: blob.publicKeyHex, createdAt: blob.createdAt };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
sign(data) {
|
|
181
|
+
if (!this._identity) throw new Error("identity not loaded");
|
|
182
|
+
return sign(null, Buffer.isBuffer(data) ? data : Buffer.from(data, "utf-8"), this._identity.privateKey);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
static verifyDetached(publicKeyHex, data, signatureBytes) {
|
|
186
|
+
if (!isStrictEd25519PublicKey(publicKeyHex)) return false; // @rule keychain.strict-key
|
|
187
|
+
const spkiPrefix = Buffer.from("302a300506032b6570032100", "hex");
|
|
188
|
+
const der = Buffer.concat([spkiPrefix, Buffer.from(publicKeyHex, "hex")]);
|
|
189
|
+
const pub = createPublicKey({ key: der, format: "der", type: "spki" });
|
|
190
|
+
return verify(null, Buffer.isBuffer(data) ? data : Buffer.from(data, "utf-8"), pub, signatureBytes);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier grants — carrier-side volume/size flexibility for qualified identities.
|
|
3
|
+
*
|
|
4
|
+
* Foundations, orgs, and enterprise supporters that pass the UUAID
|
|
5
|
+
* qualification process receive a SIGNED TIER GRANT: a portable JSON object,
|
|
6
|
+
* Ed25519-signed by a trusted issuer (the UUAID Foundation identity, or any
|
|
7
|
+
* issuer a carrier operator chooses to trust). Carriers verify the signature
|
|
8
|
+
* and expiry locally — no registry lookup, no account, consistent with the
|
|
9
|
+
* self-authenticating design. The wire API is unchanged: a grant only
|
|
10
|
+
* raises the holder's rate/size budgets at carriers that trust the issuer.
|
|
11
|
+
*
|
|
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.
|
|
18
|
+
*
|
|
19
|
+
* Grant shape (JCS-signed over every field except `sig`):
|
|
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.
|
|
28
|
+
*/
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import { jcs } from "./jcs.mjs";
|
|
31
|
+
import { Keychain } from "./keychain.mjs";
|
|
32
|
+
|
|
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 = {
|
|
52
|
+
community: { envelopesPerMin: 120, maxBodyBytes: 512 * 1024 },
|
|
53
|
+
supporter: { envelopesPerMin: 300, maxBodyBytes: 1024 * 1024 },
|
|
54
|
+
org: { envelopesPerMin: 600, maxBodyBytes: 2 * 1024 * 1024 },
|
|
55
|
+
enterprise: { envelopesPerMin: 1200, maxBodyBytes: 2 * 1024 * 1024 },
|
|
56
|
+
foundation: { envelopesPerMin: 3000, maxBodyBytes: 2 * 1024 * 1024 },
|
|
57
|
+
};
|
|
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
|
+
|
|
66
|
+
/** Hard ceiling any grant can reach — also the carrier's raw-body stream cap. */
|
|
67
|
+
export const ABSOLUTE_MAX_BODY = 2 * 1024 * 1024;
|
|
68
|
+
export const ABSOLUTE_MAX_RATE = 6000;
|
|
69
|
+
|
|
70
|
+
function signable(grant) {
|
|
71
|
+
const { sig, ...rest } = grant;
|
|
72
|
+
return jcs(rest);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Issue a grant, signed by the loaded keychain (the issuer identity).
|
|
77
|
+
* Rates/sizes default from the tier ladder; explicit overrides are clamped
|
|
78
|
+
* to the absolute ceilings.
|
|
79
|
+
*/
|
|
80
|
+
export function makeTierGrant(keychain, { uuaid, tier, envelopesPerMin, maxBodyBytes, expires, priority, seedTier, seedUrl }) {
|
|
81
|
+
if (!TIER_DEFAULTS[tier]) throw new Error(`unknown tier "${tier}" (${Object.keys(TIER_DEFAULTS).join("|")})`);
|
|
82
|
+
if (!uuaid?.startsWith("uuaid:")) throw new Error("grant subject must be a uuaid:...");
|
|
83
|
+
if (!expires || !Number.isFinite(Date.parse(expires))) throw new Error("expires must be an ISO date");
|
|
84
|
+
const grant = {
|
|
85
|
+
v: 1,
|
|
86
|
+
uuaid,
|
|
87
|
+
tier,
|
|
88
|
+
envelopesPerMin: Math.min(envelopesPerMin ?? TIER_DEFAULTS[tier].envelopesPerMin, ABSOLUTE_MAX_RATE),
|
|
89
|
+
maxBodyBytes: Math.min(maxBodyBytes ?? TIER_DEFAULTS[tier].maxBodyBytes, ABSOLUTE_MAX_BODY),
|
|
90
|
+
expires,
|
|
91
|
+
issuer: keychain._identity.publicKeyHex,
|
|
92
|
+
// Optional fields are omitted rather than set false/null so that a plain
|
|
93
|
+
// grant's signed body stays byte-identical to what pre-priority issuers
|
|
94
|
+
// produced.
|
|
95
|
+
...(priority ? { priority: true } : {}),
|
|
96
|
+
...(seedTier ? { seedTier } : {}),
|
|
97
|
+
...(seedUrl ? { seedUrl } : {}),
|
|
98
|
+
};
|
|
99
|
+
grant.sig = keychain.sign(Buffer.from(signable(grant), "utf-8")).toString("hex");
|
|
100
|
+
return grant;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Verify a grant against a set of trusted issuer pubkeys (hex).
|
|
105
|
+
* Returns { ok: true, grant } or { ok: false, reason }.
|
|
106
|
+
*/
|
|
107
|
+
export function verifyTierGrant(grant, trustedIssuers, now = Date.now()) {
|
|
108
|
+
if (grant?.v !== 1) return { ok: false, reason: "bad-version" };
|
|
109
|
+
if (!TIER_DEFAULTS[grant.tier]) return { ok: false, reason: "unknown-tier" };
|
|
110
|
+
if (!trustedIssuers?.includes(grant.issuer)) return { ok: false, reason: "untrusted-issuer" };
|
|
111
|
+
const exp = Date.parse(grant.expires);
|
|
112
|
+
if (!Number.isFinite(exp) || exp < now) return { ok: false, reason: "expired" };
|
|
113
|
+
if (!(grant.envelopesPerMin <= ABSOLUTE_MAX_RATE) || !(grant.maxBodyBytes <= ABSOLUTE_MAX_BODY)) {
|
|
114
|
+
return { ok: false, reason: "over-ceiling" };
|
|
115
|
+
}
|
|
116
|
+
// Fail closed on a malformed privilege: `priority` is either absent or the
|
|
117
|
+
// boolean true. Anything else (a truthy string, 1, an object) is a grant we
|
|
118
|
+
// do not understand, and understanding it wrongly grants a reserved lane.
|
|
119
|
+
if ("priority" in grant && grant.priority !== true) return { ok: false, reason: "bad-priority" };
|
|
120
|
+
let verified = false;
|
|
121
|
+
try {
|
|
122
|
+
verified = Keychain.verifyDetached(grant.issuer, Buffer.from(signable(grant), "utf-8"), Buffer.from(grant.sig, "hex"));
|
|
123
|
+
} catch (_e) { return { ok: false, reason: "verify-threw" }; }
|
|
124
|
+
if (!verified) return { ok: false, reason: "bad-signature" };
|
|
125
|
+
return { ok: true, grant };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function grantFingerprint(grant) {
|
|
129
|
+
return createHash("sha256").update(signable(grant)).digest("hex").slice(0, 16);
|
|
130
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
export class CarrierClient {
|
|
24
|
+
/**
|
|
25
|
+
* @param {{keychain: import('../identity/keychain.mjs').Keychain, carriers: string[]}} opts
|
|
26
|
+
* carriers: base URLs, e.g. ["https://pillar.uuaid.org"]
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* carrierTiers optional { "<url>": "<seed tier>" } from a directory's
|
|
31
|
+
* seeds[]. Drives which carrier's receipt is authoritative
|
|
32
|
+
* and which one is polled first — see `deliver`.
|
|
33
|
+
*/
|
|
34
|
+
constructor({ keychain, carriers, tierGrant = null, carrierTiers = {} }) {
|
|
35
|
+
if (!keychain?._identity) throw new Error("CarrierClient needs a loaded keychain");
|
|
36
|
+
if (!carriers?.length) throw new Error("CarrierClient needs at least one carrier URL");
|
|
37
|
+
this.keychain = keychain;
|
|
38
|
+
// Normalise tier keys the same way as carrier URLs so a trailing slash on
|
|
39
|
+
// one side does not silently drop a seed to `probation`.
|
|
40
|
+
this.carrierTiers = Object.fromEntries(
|
|
41
|
+
Object.entries(carrierTiers).map(([u, t]) => [String(u).replace(/\/+$/, ""), t]),
|
|
42
|
+
);
|
|
43
|
+
this.carriers = carriers.map(u => u.replace(/\/+$/, "")).sort(byTier(this.carrierTiers));
|
|
44
|
+
// Tier label for the local preflight: a signed grant raises it; default
|
|
45
|
+
// stays community (matches the carrier's unauthenticated tier).
|
|
46
|
+
this.tier = tierGrant?.tier ?? "community";
|
|
47
|
+
this.maxBodyBytes = Number.isInteger(tierGrant?.maxBodyBytes) ? tierGrant.maxBodyBytes : null;
|
|
48
|
+
// Optional signed tier grant (tier.mjs) — sent as a header on deliver so
|
|
49
|
+
// qualifying senders get raised budgets at carriers that trust the issuer.
|
|
50
|
+
this.tierGrantB64 = tierGrant
|
|
51
|
+
? Buffer.from(JSON.stringify(tierGrant), "utf-8").toString("base64")
|
|
52
|
+
: null;
|
|
53
|
+
this._stopPolling = false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get uuaid() { return this.keychain._identity.uuaid; }
|
|
57
|
+
get publicKeyHex() { return this.keychain._identity.publicKeyHex; }
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Deliver an envelope to ALL carriers in parallel — p2p seed replication:
|
|
61
|
+
* the message lands on every configured seed (like a file seeded on many
|
|
62
|
+
* peers), so it survives any single carrier dying before the recipient
|
|
63
|
+
* polls. Duplicate copies are harmless: the recipient mailbox dedups on
|
|
64
|
+
* envelope.id and re-fires no handlers.
|
|
65
|
+
*
|
|
66
|
+
* Resolves once every carrier has answered. Succeeds if at least one
|
|
67
|
+
* accepted; throws if every carrier rejects. Returns the receipt from the
|
|
68
|
+
* most PROVEN accepting carrier as the primary ({ carrier, seq, sha,
|
|
69
|
+
* duplicate }) plus `all` with every carrier's outcome ({ carrier, ok,
|
|
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`).
|
|
75
|
+
*
|
|
76
|
+
* Which receipt is primary matters because callers quote its seq back as a
|
|
77
|
+
* cursor and cite its carrier in receipts: it should name a seed that will
|
|
78
|
+
* still be there. With no tier information every carrier ranks equally and
|
|
79
|
+
* the first-in-list receipt wins, exactly as before.
|
|
80
|
+
*/
|
|
81
|
+
async deliver(envelope, { timeoutMs = 10000 } = {}) {
|
|
82
|
+
// Preflight against the carrier limits BEFORE any network call: the
|
|
83
|
+
// tier inline limit first, then the 2 MiB direct hard cap — no tier
|
|
84
|
+
// grant can raise the hard cap. Typed failure, not a carrier 413.
|
|
85
|
+
const pf = preflightBodyBytes(envelopeBodyBytes(envelope), { tier: this.tier, maxBodyBytes: this.maxBodyBytes });
|
|
86
|
+
if (!pf.ok) {
|
|
87
|
+
const e = new Error(`carrier preflight: body ${pf.bytes}B exceeds ${pf.source} limit ${pf.limit}B — use the blob transport`);
|
|
88
|
+
e.code = pf.source === "direct-cap" ? "blob-too-large" : "over-tier-limit";
|
|
89
|
+
e.limit = pf.limit; e.source = pf.source;
|
|
90
|
+
e.fix = REFUSAL_FIXES["over-tier-limit"];
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
const attempt = async (base) => {
|
|
94
|
+
try {
|
|
95
|
+
const res = await fetch(`${base}/v1/envelopes`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: {
|
|
98
|
+
"content-type": "application/json",
|
|
99
|
+
...(this.tierGrantB64 ? { "x-pillar-tier-grant": this.tierGrantB64 } : {}),
|
|
100
|
+
},
|
|
101
|
+
body: JSON.stringify(envelope),
|
|
102
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
103
|
+
});
|
|
104
|
+
const raw = await res.text();
|
|
105
|
+
let body = {};
|
|
106
|
+
try { body = raw ? JSON.parse(raw) : {}; } catch (_error) {}
|
|
107
|
+
if (res.status === 202 && body.accepted) {
|
|
108
|
+
return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate };
|
|
109
|
+
}
|
|
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
|
+
};
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return { carrier: base, ok: false, error: e.message };
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const all = await Promise.allSettled(this.carriers.map(attempt)).then(rs =>
|
|
122
|
+
rs.map(r => r.status === "fulfilled" ? r.value : { carrier: "?", ok: false, error: String(r.reason) }));
|
|
123
|
+
const accepted = all.filter(r => r.ok);
|
|
124
|
+
if (!accepted.length) {
|
|
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;
|
|
133
|
+
}
|
|
134
|
+
const ranked = accepted.slice().sort((a, b) => byTier(this.carrierTiers)(a.carrier, b.carrier));
|
|
135
|
+
const { carrier, seq, sha, duplicate } = ranked[0];
|
|
136
|
+
return { carrier, seq, sha, duplicate, all };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fetch envelopes addressed to us with seq > since from a specific carrier.
|
|
141
|
+
* Signed request; carrier verifies our key before serving.
|
|
142
|
+
*/
|
|
143
|
+
async fetchInbox(base, { since = 0, waitS = 0, timeoutMs = 40000 } = {}) {
|
|
144
|
+
const path = `GET /v1/inbox/${this.uuaid}?since=${since}`;
|
|
145
|
+
const ts = String(Date.now());
|
|
146
|
+
const sig = this.keychain.sign(Buffer.from(`${path}\n${ts}`, "utf-8")).toString("hex");
|
|
147
|
+
const res = await fetch(`${base}/v1/inbox/${encodeURIComponent(this.uuaid)}?since=${since}&wait=${waitS}`, {
|
|
148
|
+
headers: {
|
|
149
|
+
"x-pillar-pubkey": this.publicKeyHex,
|
|
150
|
+
"x-pillar-ts": ts,
|
|
151
|
+
"x-pillar-sig": sig,
|
|
152
|
+
},
|
|
153
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
154
|
+
});
|
|
155
|
+
if (res.status !== 200) {
|
|
156
|
+
const body = await res.json().catch(() => ({}));
|
|
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;
|
|
162
|
+
}
|
|
163
|
+
return res.json(); // { envelopes: [{seq, envelope}], now }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Long-poll every carrier in parallel forever, invoking onEnvelope for each
|
|
168
|
+
* new envelope. Tracks per-carrier cursor. Returns a stop() function.
|
|
169
|
+
* onError(error, { carrier }) — the carrier context is passed so callers
|
|
170
|
+
* can attribute failures per carrier in metrics.
|
|
171
|
+
*/
|
|
172
|
+
startPolling({ onEnvelope, cursors = {}, waitS = 25, onError }) {
|
|
173
|
+
this._stopPolling = false;
|
|
174
|
+
const state = { cursors: { ...cursors } };
|
|
175
|
+
const loops = this.carriers.map(async (base) => {
|
|
176
|
+
while (!this._stopPolling) {
|
|
177
|
+
try {
|
|
178
|
+
const r = await this.fetchInbox(base, { since: state.cursors[base] ?? 0, waitS });
|
|
179
|
+
for (const { seq, envelope } of r.envelopes) {
|
|
180
|
+
state.cursors[base] = Math.max(state.cursors[base] ?? 0, seq);
|
|
181
|
+
try { await onEnvelope(envelope, { carrier: base, seq }); }
|
|
182
|
+
catch (e) { onError?.(new Error(`onEnvelope threw: ${e.message}`), { carrier: base }); }
|
|
183
|
+
}
|
|
184
|
+
} catch (e) {
|
|
185
|
+
onError?.(e, { carrier: base });
|
|
186
|
+
// Back off on error so a dead carrier doesn't spin the CPU.
|
|
187
|
+
await new Promise(r2 => setTimeout(r2, 2000));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return {
|
|
192
|
+
stop: () => { this._stopPolling = true; },
|
|
193
|
+
cursors: () => ({ ...state.cursors }),
|
|
194
|
+
done: Promise.allSettled(loops),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|