@uuaid/pillar 2.0.2 → 2.2.0

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/src/groups.mjs ADDED
@@ -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
+ }
@@ -24,11 +24,10 @@ function _ser(v) {
24
24
  if (typeof v === "bigint") return v.toString();
25
25
  if (Array.isArray(v)) return "[" + v.map(_ser).join(",") + "]";
26
26
  if (typeof v === "object") {
27
- // UTF-16 code unit sort — the RFC 8785 requirement.
28
- const keys = Object.keys(v).sort((a, b) => {
29
- const ab = Buffer.from(a, "utf16le"), bb = Buffer.from(b, "utf16le");
30
- return Buffer.compare(ab, bb);
31
- });
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
32
31
  return "{" + keys.map(k => JSON.stringify(k) + ":" + _ser(v[k])).join(",") + "}";
33
32
  }
34
33
  throw new Error("JCS: unsupported type " + typeof v);
@@ -12,9 +12,43 @@
12
12
  import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
13
13
  import { dirname } from "node:path";
14
14
  import { randomBytes, createHash, generateKeyPairSync, sign, verify, createPublicKey, createPrivateKey, hkdfSync, createCipheriv, createDecipheriv } from "node:crypto";
15
+ import { ed25519 } from "@noble/curves/ed25519.js";
15
16
 
16
17
  const KEYCHAIN_VERSION = 1;
17
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
+
18
52
  /**
19
53
  * Derive a stable, publishable local id from a raw Ed25519 public key. This
20
54
  * makes the UUAID self-authenticating: given the UUAID, anyone can
@@ -31,6 +65,54 @@ export function localIdFromKey(rawPub) {
31
65
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
32
66
  }
33
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
+
34
116
  export class Keychain {
35
117
  constructor(path) {
36
118
  this.path = path;
@@ -44,8 +126,9 @@ export class Keychain {
44
126
  /**
45
127
  * Generate a fresh identity. Never overwrites.
46
128
  *
47
- * uuaidNamespace: default "foundation" per IAASO-1001 today; will support
48
- * federated forms once federation profile ratifies.
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.
49
132
  * objectType: default "agent"; overridable to "service" etc.
50
133
  * localId: default random UUIDv4-like; caller may supply.
51
134
  */
@@ -149,6 +232,7 @@ export class Keychain {
149
232
  }
150
233
 
151
234
  static verifyDetached(publicKeyHex, data, signatureBytes) {
235
+ if (!isStrictEd25519PublicKey(publicKeyHex)) return false; // @rule keychain.strict-key
152
236
  const spkiPrefix = Buffer.from("302a300506032b6570032100", "hex");
153
237
  const der = Buffer.concat([spkiPrefix, Buffer.from(publicKeyHex, "hex")]);
154
238
  const pub = createPublicKey({ key: der, format: "der", type: "spki" });
package/src/index.mjs CHANGED
@@ -7,7 +7,7 @@
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, localIdFromKey } from "./identity/keychain.mjs";
10
+ export { Keychain, localIdFromKey, SELF_CERTIFYING_NAMESPACES, NEW_IDENTITY_NAMESPACE, SERVICE_IDENTITY_NAMESPACE, mintNamespace, keyDerivedUuaidFor, keyDerivesUuaid } 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
13
  export { DELEGATION_VERSION, verifyDelegationDoc, delegationCoversKind } from "./net/delegation.mjs";