@uuaid/agent-commons 0.0.0 → 0.2.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFORMANCE.md +26 -0
- package/LICENSE +201 -0
- package/NOTICE +22 -0
- package/PROVENANCE.json +24 -0
- package/README.md +404 -5
- package/bin/agent-commons.mjs +76 -0
- package/examples/Dockerfile +12 -0
- package/examples/agent.mjs +17 -0
- package/examples/global-policy.json +9 -0
- package/examples/run-cohort.mjs +68 -0
- package/package.json +31 -8
- package/profiles/agentnet.chat.json +11 -0
- package/profiles/local.json +11 -0
- package/profiles/zilligon.com.json +11 -0
- package/profiles/zilligons.com.json +11 -0
- package/src/carrier.mjs +84 -0
- package/src/channels.d.mts +75 -0
- package/src/channels.mjs +158 -0
- package/src/cohort.mjs +61 -0
- package/src/collaboration.mjs +875 -0
- package/src/config.mjs +40 -0
- package/src/continuity.d.mts +24 -0
- package/src/continuity.mjs +601 -0
- package/src/governance-scope.d.mts +46 -0
- package/src/governance-scope.mjs +139 -0
- package/src/governance.mjs +355 -0
- package/src/index.d.ts +111 -0
- package/src/index.mjs +15 -0
- package/src/loop.mjs +71 -0
- package/src/offline.d.mts +7 -0
- package/src/offline.mjs +29 -0
- package/src/pillar.mjs +59 -0
- package/src/profiles.d.mts +12 -0
- package/src/profiles.mjs +92 -0
- package/src/relay/auth.mjs +294 -0
- package/src/relay/board.mjs +223 -0
- package/src/relay/claims.mjs +25 -0
- package/src/relay/cli.mjs +49 -0
- package/src/relay/client.mjs +437 -0
- package/src/relay/docs/reconnect-v1.md +27 -0
- package/src/relay/docs/working-rules-v1.md +42 -0
- package/src/relay/enroll.mjs +232 -0
- package/src/relay/export-cli.mjs +47 -0
- package/src/relay/governance.mjs +1328 -0
- package/src/relay/mailbox.mjs +103 -0
- package/src/relay/records.mjs +146 -0
- package/src/relay/render.mjs +38 -0
- package/src/relay/roster.mjs +134 -0
- package/src/relay/routes.mjs +87 -0
- package/src/relay/schema.mjs +1081 -0
- package/src/relay/seed.mjs +89 -0
- package/src/relay/serve.mjs +116 -0
- package/src/relay/server.mjs +633 -0
- package/src/relay/signing.mjs +693 -0
- package/src/relay/test-mode.mjs +73 -0
- package/src/runtime.mjs +359 -0
- package/src/spaces/manifest.mjs +245 -0
- package/src/spaces/pillar-group.mjs +12 -0
- package/src/spaces/policy.mjs +229 -0
- package/src/spaces/store.mjs +325 -0
- package/src/store.mjs +73 -0
- package/src/transport.d.mts +76 -0
- package/src/transport.mjs +267 -0
- package/src/trust.mjs +104 -0
- package/src/uuaid-namespace.d.mts +6 -0
- package/src/uuaid-namespace.mjs +48 -0
- package/src/vendor/pillar/blob/blob.mjs +508 -0
- package/src/vendor/pillar/crypto/e2e.mjs +97 -0
- package/src/vendor/pillar/groups-v2.mjs +1108 -0
- package/src/vendor/pillar/groups.mjs +175 -0
- package/src/vendor/pillar/identity/jcs.mjs +34 -0
- package/src/vendor/pillar/identity/keychain.mjs +241 -0
- package/src/vendor/pillar/identity/tier.mjs +130 -0
- package/src/vendor/pillar/net/carrier-client.mjs +219 -0
- package/src/vendor/pillar/net/delegation.mjs +209 -0
- package/src/vendor/pillar/net/envelope.mjs +258 -0
- package/src/vendor/pillar/net/refusals.mjs +129 -0
- package/src/vendor/pillar/net/seed-tier.mjs +185 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group threads — the `pillar-group` v1 app convention (INTERACTION-CONVENTION.md
|
|
3
|
+
* "group chats"), as the mobile app and chat-web already speak it.
|
|
4
|
+
*
|
|
5
|
+
* There is still no group key and no wire change: a group message is one
|
|
6
|
+
* ordinary 1:1 envelope per member, and the group rides INSIDE each sealed
|
|
7
|
+
* payload as `_group`. What this adds over the old `{ name, members }` tag is
|
|
8
|
+
* what makes it a thread rather than a fan-out:
|
|
9
|
+
*
|
|
10
|
+
* id stable `grp-<16 hex>`, so every member files it under one group
|
|
11
|
+
* msgId `gm-…`, identical across the fan-out copies of one message
|
|
12
|
+
* op message | create | rename | add | leave (roster changes travel
|
|
13
|
+
* as ordinary group messages)
|
|
14
|
+
* keys optional sealing hints, binding-checked on receipt, so members
|
|
15
|
+
* can reply to the whole group without a prior contact exchange
|
|
16
|
+
*
|
|
17
|
+
* Every member keeps its own roster (mailbox `groups` table) and applies the
|
|
18
|
+
* same rules: only a current member may rename or add, nobody can remove
|
|
19
|
+
* anyone but themselves, and a group a non-contact drops you into is a
|
|
20
|
+
* pending invitation until you accept it.
|
|
21
|
+
*/
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { localIdFromKey } from "./identity/keychain.mjs";
|
|
24
|
+
|
|
25
|
+
export const GROUP_TAG_KEY = "_group";
|
|
26
|
+
export const GROUP_TAG_V = 1;
|
|
27
|
+
export const GROUP_OPS = Object.freeze(["message", "create", "rename", "add", "leave"]);
|
|
28
|
+
export const MAX_GROUP_MEMBERS = 256;
|
|
29
|
+
export const MAX_GROUP_NAME = 100;
|
|
30
|
+
|
|
31
|
+
const GROUP_ID_RE = /^grp-[a-z0-9][a-z0-9-]{3,63}$/i;
|
|
32
|
+
const UUAID_RE = /^uuaid:[a-z0-9-]+:[a-z0-9-]+:[a-z0-9][a-z0-9-]*$/i;
|
|
33
|
+
const PUBKEY_RE = /^[0-9a-f]{64}$/i;
|
|
34
|
+
|
|
35
|
+
export function isGroupId(id) {
|
|
36
|
+
return typeof id === "string" && GROUP_ID_RE.test(id);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function newGroupId() {
|
|
40
|
+
return `grp-${randomBytes(8).toString("hex")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function newGroupMsgId() {
|
|
44
|
+
return `gm-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The key really owns the uuaid (the same check seal() makes). */
|
|
48
|
+
export function keyBindsUuaid(uuaid, publicKeyHex) {
|
|
49
|
+
if (typeof uuaid !== "string" || typeof publicKeyHex !== "string" || !PUBKEY_RE.test(publicKeyHex)) return false;
|
|
50
|
+
try { return localIdFromKey(Buffer.from(publicKeyHex, "hex")) === uuaid.split(":")[3]; }
|
|
51
|
+
catch { return false; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function djb2(s) {
|
|
55
|
+
let h = 5381;
|
|
56
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
|
|
57
|
+
return h;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A local id for an id-less tag (an older pillar that sent only
|
|
62
|
+
* `{ name, members }`). Same derivation as the mobile app, so the same
|
|
63
|
+
* conversation groups the same way there and here. Never an authority.
|
|
64
|
+
*/
|
|
65
|
+
export function deriveGroupId(name, members) {
|
|
66
|
+
const canon = `${String(name).trim().toLowerCase()}|${[...members].sort().join(",")}`;
|
|
67
|
+
const a = djb2(canon).toString(16).padStart(8, "0");
|
|
68
|
+
const b = djb2(canon.split("").reverse().join("")).toString(16).padStart(8, "0");
|
|
69
|
+
return `grp-ref-${a}${b}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Deduplicated, validated member list, order kept. */
|
|
73
|
+
export function normalizeMembers(members) {
|
|
74
|
+
const out = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
for (const m of Array.isArray(members) ? members : []) {
|
|
77
|
+
if (typeof m !== "string" || !UUAID_RE.test(m) || seen.has(m)) continue;
|
|
78
|
+
seen.add(m);
|
|
79
|
+
out.push(m);
|
|
80
|
+
if (out.length >= MAX_GROUP_MEMBERS) break; // fan-out amplification guard
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Parse a `_group` tag out of a decrypted payload; null when absent or unusable. */
|
|
86
|
+
export function extractGroupTag(payload) {
|
|
87
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
88
|
+
const g = payload[GROUP_TAG_KEY];
|
|
89
|
+
if (!g || typeof g !== "object" || Array.isArray(g)) return null;
|
|
90
|
+
const name = typeof g.name === "string" ? g.name.trim().slice(0, MAX_GROUP_NAME) : "";
|
|
91
|
+
const members = normalizeMembers(g.members);
|
|
92
|
+
if (!members.length) return null;
|
|
93
|
+
const derivedId = !isGroupId(g.id);
|
|
94
|
+
const id = derivedId ? deriveGroupId(name || "group", members) : g.id;
|
|
95
|
+
let keys;
|
|
96
|
+
if (g.keys && typeof g.keys === "object" && !Array.isArray(g.keys)) {
|
|
97
|
+
for (const [u, pk] of Object.entries(g.keys)) {
|
|
98
|
+
if (UUAID_RE.test(u) && typeof pk === "string" && PUBKEY_RE.test(pk)) (keys ??= {})[u] = pk.toLowerCase();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const msgId = typeof g.msgId === "string" && g.msgId.length > 0 && g.msgId.length <= 64 ? g.msgId : null;
|
|
102
|
+
const op = typeof g.op === "string" && GROUP_OPS.includes(g.op) ? g.op : "message";
|
|
103
|
+
return { v: typeof g.v === "number" ? g.v : GROUP_TAG_V, id, name: name || "Group", members, keys, msgId, op, derivedId };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The `_group` tag for an outbound payload. */
|
|
107
|
+
export function buildGroupTag(group, { msgId, keys, op = "message" }) {
|
|
108
|
+
const tag = { v: GROUP_TAG_V, id: group.id, name: group.name, members: [...group.members], msgId };
|
|
109
|
+
if (keys && Object.keys(keys).length) tag.keys = keys;
|
|
110
|
+
if (op && op !== "message") tag.op = op;
|
|
111
|
+
return tag;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fold an inbound tag into the local roster (mailbox `groups`). Returns the
|
|
116
|
+
* group as stored, or null when the tag changed nothing we keep.
|
|
117
|
+
*
|
|
118
|
+
* unknown group -> join it; pending when the sender is not a contact. A
|
|
119
|
+
* wire id (not derived here) needs the SENDER on its own
|
|
120
|
+
* roster: the first tag seen for an id sets the roster, so
|
|
121
|
+
* a stranger must not be able to seed it, and after that
|
|
122
|
+
* the real members would be the strangers
|
|
123
|
+
* known group -> only a current MEMBER may change it: `leave` drops the
|
|
124
|
+
* sender, `create`/`rename` set the name, every op unions
|
|
125
|
+
* new members in; a re-`add` naming us clears our `left`
|
|
126
|
+
* key hints -> kept per group, only when the key derives its uuaid;
|
|
127
|
+
* they let us SEAL to a member, and never make anyone a
|
|
128
|
+
* trusted sender (the address book and trust.json do that)
|
|
129
|
+
*/
|
|
130
|
+
export function absorbGroupTag(mailbox, tag, { sender, self, text = "", fromContact = false }) {
|
|
131
|
+
const hints = {};
|
|
132
|
+
for (const [u, pk] of Object.entries(tag.keys ?? {})) {
|
|
133
|
+
if (u !== self && keyBindsUuaid(u, pk)) hints[u] = pk;
|
|
134
|
+
}
|
|
135
|
+
const existing = mailbox.getGroup(tag.id);
|
|
136
|
+
if (!existing) {
|
|
137
|
+
if (!tag.derivedId && !tag.members.includes(sender)) return null;
|
|
138
|
+
const members = tag.members.includes(self) ? tag.members : [...tag.members, self];
|
|
139
|
+
return mailbox.putGroup({
|
|
140
|
+
id: tag.id, name: tag.name, members, keys: hints, createdBy: sender, pending: !fromContact,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (!existing.members.includes(sender)) return existing; // strangers cannot touch our roster
|
|
144
|
+
const keys = { ...hints, ...existing.keys }; // first key wins
|
|
145
|
+
if (tag.op === "leave") {
|
|
146
|
+
return mailbox.putGroup({ ...existing, keys, members: existing.members.filter((m) => m !== sender) });
|
|
147
|
+
}
|
|
148
|
+
const members = [...existing.members];
|
|
149
|
+
for (const m of tag.members) if (!members.includes(m) && members.length < MAX_GROUP_MEMBERS) members.push(m);
|
|
150
|
+
const addedUs = tag.op === "add" && String(text).split(",").map((x) => x.trim()).includes(self);
|
|
151
|
+
return mailbox.putGroup({
|
|
152
|
+
...existing,
|
|
153
|
+
keys,
|
|
154
|
+
members,
|
|
155
|
+
name: tag.op === "rename" || tag.op === "create" ? tag.name : existing.name,
|
|
156
|
+
left: existing.left && !addedUs,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve a group by exact id, else by unique case-insensitive name.
|
|
162
|
+
* Throws with the candidates listed when the name is ambiguous or unknown.
|
|
163
|
+
*/
|
|
164
|
+
export function findGroup(mailbox, ref) {
|
|
165
|
+
const byId = mailbox.getGroup(ref);
|
|
166
|
+
if (byId) return byId;
|
|
167
|
+
const all = mailbox.listGroups();
|
|
168
|
+
const hits = all.filter((g) => g.name.toLowerCase() === String(ref).toLowerCase());
|
|
169
|
+
if (hits.length === 1) return hits[0];
|
|
170
|
+
const e = new Error(hits.length
|
|
171
|
+
? `group name "${ref}" is ambiguous: ${hits.map((g) => g.id).join(", ")} — use the id`
|
|
172
|
+
: `no group "${ref}" in this home${all.length ? ` (known: ${all.map((g) => `${g.name} ${g.id}`).join("; ")})` : ""} — list them with: pillar groups`);
|
|
173
|
+
e.code = hits.length ? "group-ambiguous" : "group-unknown";
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 8785 JCS canonicalization. Same implementation as the pillar-core reference,
|
|
3
|
+
* kept independent so the pillar-node package has no upward dependency.
|
|
4
|
+
*
|
|
5
|
+
* Numbers: integers only (safe for the wire we design; enforce it). Floats
|
|
6
|
+
* throw because deterministic ECMA-262 number serialization requires more code
|
|
7
|
+
* than we want in a reference impl and this package's message payloads never
|
|
8
|
+
* legitimately contain non-integer numbers.
|
|
9
|
+
*/
|
|
10
|
+
export function jcs(value) {
|
|
11
|
+
return _ser(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function _ser(v) {
|
|
15
|
+
if (v === null) return "null";
|
|
16
|
+
if (v === true) return "true";
|
|
17
|
+
if (v === false) return "false";
|
|
18
|
+
if (typeof v === "string") return JSON.stringify(v);
|
|
19
|
+
if (typeof v === "number") {
|
|
20
|
+
if (!Number.isFinite(v)) throw new Error("JCS: NaN/Infinity not permitted");
|
|
21
|
+
if (Number.isInteger(v)) return String(v);
|
|
22
|
+
throw new Error("JCS: non-integer numbers not permitted; use string decimal encoding");
|
|
23
|
+
}
|
|
24
|
+
if (typeof v === "bigint") return v.toString();
|
|
25
|
+
if (Array.isArray(v)) return "[" + v.map(_ser).join(",") + "]";
|
|
26
|
+
if (typeof v === "object") {
|
|
27
|
+
// RFC 8785 §3.2.3: keys sort by their UTF-16 code units, which is exactly
|
|
28
|
+
// JavaScript's default string order. (Comparing UTF-16LE *bytes* is not: it
|
|
29
|
+
// puts "Ā" (U+0100) before "a", and disagreed with @uuaid/pillar-client.)
|
|
30
|
+
const keys = Object.keys(v).sort(); // @rule jcs.code-unit-order
|
|
31
|
+
return "{" + keys.map(k => JSON.stringify(k) + ":" + _ser(v[k])).join(",") + "}";
|
|
32
|
+
}
|
|
33
|
+
throw new Error("JCS: unsupported type " + typeof v);
|
|
34
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
/** Registry namespaces cannot be asserted by possession of a key alone. */
|
|
69
|
+
export const SELF_CERTIFYING_NAMESPACES = Object.freeze(["foundation", "pillar", "agentnet"]);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The namespace `pillar init` mints a NEW agent identity in when no --namespace is given.
|
|
73
|
+
* Expand before contract (owner order 2026-10-24; Uuaid-Codex review K1): 2.2.0 makes every
|
|
74
|
+
* verifier accept foundation, pillar and agentnet but still mints `foundation`, because a
|
|
75
|
+
* `pillar:` identity cannot read its inbox on a pre-2.2.0 carrier. 2.2.1 flips this to
|
|
76
|
+
* `pillar` once the carrier fleet, the main relay and the blob brokers run 2.2.x/1.2.0.
|
|
77
|
+
* Existing keychains keep the UUAID they were saved with. `Keychain.generate`'s own default
|
|
78
|
+
* stays `foundation` for library callers; CLI mint sites pass `mintNamespace(...)` instead.
|
|
79
|
+
*/
|
|
80
|
+
export const NEW_IDENTITY_NAMESPACE = "foundation";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Carrier-infrastructure identities (pillar-seed, pillar-directory, pillar-escrow) stay in
|
|
84
|
+
* `foundation`: they are spoken to by third-party seeds and DHT peers on any version, and
|
|
85
|
+
* the owner's rule covers the identities people create, not the fleet's service keys
|
|
86
|
+
* (Uuaid-Codex review K2).
|
|
87
|
+
*/
|
|
88
|
+
export const SERVICE_IDENTITY_NAMESPACE = "foundation";
|
|
89
|
+
|
|
90
|
+
/** The namespace to mint in: `requested` if it is self-certifying, else NEW_IDENTITY_NAMESPACE. Throws on any other namespace. */
|
|
91
|
+
export function mintNamespace(requested) {
|
|
92
|
+
if (requested === undefined || requested === null) return NEW_IDENTITY_NAMESPACE;
|
|
93
|
+
if (typeof requested !== "string" || !SELF_CERTIFYING_NAMESPACES.includes(requested)) {
|
|
94
|
+
throw new Error(`namespace must be one of ${SELF_CERTIFYING_NAMESPACES.join(", ")} (a key alone cannot claim ${String(requested)})`);
|
|
95
|
+
}
|
|
96
|
+
return requested;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Rebuild the complete key-derived identity; minting still defaults to foundation. */
|
|
100
|
+
export function keyDerivedUuaidFor(publicKeyHex, { namespace = "foundation", objectType = "agent" } = {}) {
|
|
101
|
+
if (!SELF_CERTIFYING_NAMESPACES.includes(namespace)
|
|
102
|
+
|| typeof objectType !== "string" || !/^[a-z0-9-]+$/.test(objectType)
|
|
103
|
+
|| typeof publicKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return null;
|
|
104
|
+
return `uuaid:${namespace}:${objectType}:${localIdFromKey(Buffer.from(publicKeyHex, "hex"))}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Compare the full identity, optionally constraining a path's existing object type. */
|
|
108
|
+
export function keyDerivesUuaid(publicKeyHex, uuaid, objectType = null) {
|
|
109
|
+
if (typeof uuaid !== "string") return false;
|
|
110
|
+
const parts = uuaid.split(":");
|
|
111
|
+
if (parts.length !== 4 || parts[0] !== "uuaid" || (objectType !== null && parts[2] !== objectType)) return false;
|
|
112
|
+
const expected = keyDerivedUuaidFor(publicKeyHex, { namespace: parts[1], objectType: parts[2] });
|
|
113
|
+
return expected !== null && uuaid === expected;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class Keychain {
|
|
117
|
+
constructor(path) {
|
|
118
|
+
this.path = path;
|
|
119
|
+
this._identity = null; // { uuaid, publicKeyHex, privateKey (KeyObject), createdAt }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
exists() {
|
|
123
|
+
return existsSync(this.path);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Generate a fresh identity. Never overwrites.
|
|
128
|
+
*
|
|
129
|
+
* uuaidNamespace: default "foundation" (the genesis namespace) for library
|
|
130
|
+
* callers. The Pillar CLIs pass mintNamespace(...) explicitly, so a new CLI
|
|
131
|
+
* identity is `pillar` unless the operator asks for agentnet or foundation.
|
|
132
|
+
* objectType: default "agent"; overridable to "service" etc.
|
|
133
|
+
* localId: default random UUIDv4-like; caller may supply.
|
|
134
|
+
*/
|
|
135
|
+
static generate({ uuaidNamespace = "foundation", objectType = "agent", localId = null } = {}) {
|
|
136
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
137
|
+
const pubDer = publicKey.export({ type: "spki", format: "der" });
|
|
138
|
+
const rawPub = pubDer.slice(pubDer.length - 32);
|
|
139
|
+
const localIdActual = localId ?? Keychain._localIdFromKey(rawPub);
|
|
140
|
+
const uuaid = `uuaid:${uuaidNamespace}:${objectType}:${localIdActual}`;
|
|
141
|
+
return {
|
|
142
|
+
uuaid,
|
|
143
|
+
publicKey,
|
|
144
|
+
privateKey,
|
|
145
|
+
publicKeyHex: Buffer.from(rawPub).toString("hex"),
|
|
146
|
+
createdAt: new Date().toISOString(),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Derive the local-id from a raw Ed25519 public key.
|
|
152
|
+
*
|
|
153
|
+
* Kept on the class for the call sites that already reach for it; the
|
|
154
|
+
* implementation is the module-level `localIdFromKey`, which is the public
|
|
155
|
+
* export.
|
|
156
|
+
*/
|
|
157
|
+
static localIdFromKey = localIdFromKey;
|
|
158
|
+
|
|
159
|
+
/** @deprecated Alias kept for compatibility — use `localIdFromKey`. */
|
|
160
|
+
static _localIdFromKey = localIdFromKey;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Load an identity from disk.
|
|
164
|
+
*/
|
|
165
|
+
load({ passphrase } = {}) {
|
|
166
|
+
if (!this.exists()) throw new Error("keychain not initialized; run `pillar init` first");
|
|
167
|
+
const blob = JSON.parse(readFileSync(this.path, "utf-8"));
|
|
168
|
+
if (blob.version !== KEYCHAIN_VERSION) throw new Error(`unsupported keychain version ${blob.version}`);
|
|
169
|
+
const salt = Buffer.from(blob.salt, "hex");
|
|
170
|
+
const key = hkdfSync("sha256", Buffer.from(passphrase ?? "", "utf-8"), salt, "uuaid-pillar-keychain-v1", 32);
|
|
171
|
+
const iv = Buffer.from(blob.iv, "hex");
|
|
172
|
+
const ct = Buffer.from(blob.ciphertext, "base64");
|
|
173
|
+
const tag = Buffer.from(blob.tag, "hex");
|
|
174
|
+
const decipher = createDecipheriv("aes-256-gcm", Buffer.from(key), iv);
|
|
175
|
+
decipher.setAuthTag(tag);
|
|
176
|
+
const plain = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
177
|
+
const seed = plain; // 32-byte raw ed25519 seed
|
|
178
|
+
const pkcs8Prefix = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
179
|
+
const privateKey = createPrivateKey({ key: Buffer.concat([pkcs8Prefix, seed]), format: "der", type: "pkcs8" });
|
|
180
|
+
const publicKey = createPublicKey(privateKey);
|
|
181
|
+
const pubDer = publicKey.export({ type: "spki", format: "der" });
|
|
182
|
+
const rawPub = pubDer.slice(pubDer.length - 32);
|
|
183
|
+
const publicKeyHex = Buffer.from(rawPub).toString("hex");
|
|
184
|
+
// Sanity: derived UUAID must match stored UUAID.
|
|
185
|
+
const derivedLocalId = Keychain._localIdFromKey(rawPub);
|
|
186
|
+
const parts = blob.uuaid.split(":");
|
|
187
|
+
if (parts.length < 4 || parts[3] !== derivedLocalId) {
|
|
188
|
+
throw new Error("keychain integrity failure: UUAID does not match keypair");
|
|
189
|
+
}
|
|
190
|
+
this._identity = { uuaid: blob.uuaid, publicKey, privateKey, publicKeyHex, createdAt: blob.createdAt };
|
|
191
|
+
return this._identity;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Save identity to disk, encrypted with the passphrase.
|
|
196
|
+
*/
|
|
197
|
+
save(identity, { passphrase } = {}) {
|
|
198
|
+
const seedDer = identity.privateKey.export({ type: "pkcs8", format: "der" });
|
|
199
|
+
const seed = seedDer.slice(seedDer.length - 32);
|
|
200
|
+
const salt = randomBytes(16);
|
|
201
|
+
const key = hkdfSync("sha256", Buffer.from(passphrase ?? "", "utf-8"), salt, "uuaid-pillar-keychain-v1", 32);
|
|
202
|
+
const iv = randomBytes(12);
|
|
203
|
+
const cipher = createCipheriv("aes-256-gcm", Buffer.from(key), iv);
|
|
204
|
+
const ct = Buffer.concat([cipher.update(seed), cipher.final()]);
|
|
205
|
+
const tag = cipher.getAuthTag();
|
|
206
|
+
const blob = {
|
|
207
|
+
version: KEYCHAIN_VERSION,
|
|
208
|
+
uuaid: identity.uuaid,
|
|
209
|
+
createdAt: identity.createdAt,
|
|
210
|
+
salt: salt.toString("hex"),
|
|
211
|
+
iv: iv.toString("hex"),
|
|
212
|
+
ciphertext: ct.toString("base64"),
|
|
213
|
+
tag: tag.toString("hex"),
|
|
214
|
+
// Public material is stored in the clear so `pillar profile` doesn't need a passphrase.
|
|
215
|
+
publicKeyHex: identity.publicKeyHex,
|
|
216
|
+
};
|
|
217
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
218
|
+
writeFileSync(this.path, JSON.stringify(blob, null, 2));
|
|
219
|
+
chmodSync(this.path, 0o600);
|
|
220
|
+
this._identity = identity;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
publicView() {
|
|
224
|
+
if (!this.exists()) return null;
|
|
225
|
+
const blob = JSON.parse(readFileSync(this.path, "utf-8"));
|
|
226
|
+
return { uuaid: blob.uuaid, publicKeyHex: blob.publicKeyHex, createdAt: blob.createdAt };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
sign(data) {
|
|
230
|
+
if (!this._identity) throw new Error("identity not loaded");
|
|
231
|
+
return sign(null, Buffer.isBuffer(data) ? data : Buffer.from(data, "utf-8"), this._identity.privateKey);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
static verifyDetached(publicKeyHex, data, signatureBytes) {
|
|
235
|
+
if (!isStrictEd25519PublicKey(publicKeyHex)) return false; // @rule keychain.strict-key
|
|
236
|
+
const spkiPrefix = Buffer.from("302a300506032b6570032100", "hex");
|
|
237
|
+
const der = Buffer.concat([spkiPrefix, Buffer.from(publicKeyHex, "hex")]);
|
|
238
|
+
const pub = createPublicKey({ key: der, format: "der", type: "spki" });
|
|
239
|
+
return verify(null, Buffer.isBuffer(data) ? data : Buffer.from(data, "utf-8"), pub, signatureBytes);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -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
|
+
}
|