@uuaid/agent-commons 0.0.0-stage → 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.
Files changed (69) hide show
  1. package/CONFORMANCE.md +26 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +25 -0
  4. package/PROVENANCE.json +29 -0
  5. package/README.md +316 -2
  6. package/bin/agent-commons.mjs +71 -0
  7. package/examples/Dockerfile +12 -0
  8. package/examples/agent.mjs +17 -0
  9. package/examples/global-policy.json +9 -0
  10. package/examples/run-cohort.mjs +68 -0
  11. package/package.json +31 -4
  12. package/profiles/agentnet.chat.json +11 -0
  13. package/profiles/local.json +11 -0
  14. package/profiles/zilligon.com.json +11 -0
  15. package/profiles/zilligons.com.json +11 -0
  16. package/src/carrier.mjs +79 -0
  17. package/src/channels.d.mts +75 -0
  18. package/src/channels.mjs +158 -0
  19. package/src/cohort.mjs +61 -0
  20. package/src/collaboration.mjs +875 -0
  21. package/src/config.mjs +39 -0
  22. package/src/continuity.d.mts +24 -0
  23. package/src/continuity.mjs +594 -0
  24. package/src/governance-scope.d.mts +46 -0
  25. package/src/governance-scope.mjs +139 -0
  26. package/src/governance.mjs +353 -0
  27. package/src/index.d.ts +109 -0
  28. package/src/index.mjs +15 -0
  29. package/src/loop.mjs +71 -0
  30. package/src/offline.d.mts +7 -0
  31. package/src/offline.mjs +29 -0
  32. package/src/pillar.mjs +59 -0
  33. package/src/profiles.d.mts +12 -0
  34. package/src/profiles.mjs +91 -0
  35. package/src/runtime.mjs +346 -0
  36. package/src/store.mjs +73 -0
  37. package/src/transport.d.mts +76 -0
  38. package/src/transport.mjs +267 -0
  39. package/src/trust.mjs +65 -0
  40. package/src/vendor/pillar/blob/blob.mjs +510 -0
  41. package/src/vendor/pillar/crypto/e2e.mjs +97 -0
  42. package/src/vendor/pillar/identity/jcs.mjs +35 -0
  43. package/src/vendor/pillar/identity/keychain.mjs +192 -0
  44. package/src/vendor/pillar/identity/tier.mjs +130 -0
  45. package/src/vendor/pillar/net/carrier-client.mjs +197 -0
  46. package/src/vendor/pillar/net/delegation.mjs +209 -0
  47. package/src/vendor/pillar/net/envelope.mjs +258 -0
  48. package/src/vendor/pillar/net/refusals.mjs +127 -0
  49. package/src/vendor/pillar/net/seed-tier.mjs +185 -0
  50. package/test/channels.test.mjs +268 -0
  51. package/test/cohort.test.mjs +36 -0
  52. package/test/collaboration.test.mjs +459 -0
  53. package/test/continuity.test.mjs +229 -0
  54. package/test/custody.negative.test.mjs +165 -0
  55. package/test/derivation-parity.test.mjs +19 -0
  56. package/test/fixtures/derivation-vectors.json +26 -0
  57. package/test/fixtures/ed25519-small-order.vectors.json +15 -0
  58. package/test/governance-expected-red.expected-red.mjs +241 -0
  59. package/test/governance-scope.test.mjs +142 -0
  60. package/test/governance-t1-t10.test.mjs +477 -0
  61. package/test/governance.test.mjs +514 -0
  62. package/test/hold-admission.test.mjs +1185 -0
  63. package/test/keychain-strict-ed25519.test.mjs +240 -0
  64. package/test/offline.test.mjs +152 -0
  65. package/test/runtime-channels.test.mjs +1140 -0
  66. package/test/runtime.test.mjs +201 -0
  67. package/test/security-runtime.test.mjs +104 -0
  68. package/test/strict-ed25519-consumers.test.mjs +234 -0
  69. package/test/transport.test.mjs +353 -0
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Device/persona delegation — UACP/1.0 protocol extension (carrier side).
3
+ *
4
+ * A long-lived "persona" UUAID (the principal) authorizes a short-lived
5
+ * device identity (the delegate) to act on its behalf for specific envelope
6
+ * kinds and a bounded time window. The persona signs a JCS-canonical
7
+ * delegation document; the device embeds that document in the envelope's
8
+ * `delegation` field and signs the whole envelope with ITS OWN key.
9
+ *
10
+ * delegation = {
11
+ * version: "uuaid-delegation/1",
12
+ * id: "del-...",
13
+ * principal: persona uuaid, principalPublicKey: hex,
14
+ * delegate: device uuaid, delegatePublicKey: hex,
15
+ * kinds: ["*"] or ["request","reply",...],
16
+ * notBefore, notAfter: ISO 8601,
17
+ * signature: { alg: "ed25519", publicKey, signature },
18
+ * }
19
+ * signature = ed25519(JCS(delegation minus .signature), principalSeed)
20
+ *
21
+ * Wire-compatible with @uuaid/pillar-client's delegation.ts (mintDelegation /
22
+ * verifyDelegation / sealWithDelegation / verifyDelegatedEnvelope): same
23
+ * document shape, same JCS canonicalization, same rejection reason strings,
24
+ * so a carrier and a pillar-client peer reach the same verdict on the same
25
+ * bytes.
26
+ *
27
+ * Envelope acceptance rules (envelope.mjs open() branches here when the
28
+ * optional `delegation` field is present; absent -> legacy behavior,
29
+ * unchanged):
30
+ * (a) envelope transport signature valid under the DELEGATE's pubkey and
31
+ * transportSignature.keyId === delegate uuaid;
32
+ * (b) delegation document signature valid under the PRINCIPAL's pubkey,
33
+ * principal === envelope.sender, delegate === signing key's uuaid;
34
+ * (c) local-id binding for BOTH identities:
35
+ * sha256(pubkey)[:16] (UUID-dash formatted) === uuaid local-id;
36
+ * (d) now within [notBefore, notAfter];
37
+ * (e) envelope.kind covered by the delegation `kinds` allowlist
38
+ * ("*" covers everything), and not a capability-only name such as
39
+ * "inbox" — those are poll capabilities, never deliverable kinds, and
40
+ * are refused even under "*" (see CAPABILITY_ONLY_KINDS).
41
+ * All five must pass; any failure rejects with
42
+ * `delegation-invalid:<reason>` (HTTP 403 at the carrier).
43
+ *
44
+ * Storage/mailbox attribution is unchanged: the envelope's `sender` IS the
45
+ * principal, so carriers key mailboxes, rate limits, and audits to the
46
+ * principal UUAID exactly as before.
47
+ */
48
+ import { randomBytes } from "node:crypto";
49
+ import { jcs } from "../identity/jcs.mjs";
50
+ import { Keychain } from "../identity/keychain.mjs";
51
+
52
+ export const DELEGATION_VERSION = "uuaid-delegation/1";
53
+
54
+ /**
55
+ * Mint a delegation document, signed by the PRINCIPAL keychain.
56
+ *
57
+ * The counterpart to verifyDelegationDoc below, and the .mjs twin of
58
+ * pillar-client's mintDelegation (delegation.ts): same field order, same
59
+ * id shape, same JCS canonicalization, same signature placement — so a
60
+ * document minted here verifies byte-identically in a browser, and vice
61
+ * versa. A divergence in either direction is a wire break, not a style
62
+ * difference.
63
+ *
64
+ * Nothing here knows what KIND of principal is signing. A phone, a CLI and
65
+ * a daemon mint the same bytes; the only requirement is custody of the
66
+ * persona seed. That is what lets a headless agent link a browser.
67
+ *
68
+ * CALLER BEWARE — ttlDays defaults to 90 to match pillar-client, but the
69
+ * desktop-link path enforces a 7-day ceiling of its own (D14, chat-web's
70
+ * withinLinkTtl) and DROPS a longer grant in silence. Pass ttlDays <= 7 for
71
+ * a browser link; see bin/pillar.mjs cmdDelegate, which refuses rather than
72
+ * let that failure be invisible.
73
+ */
74
+ export function mintDelegationDoc(keychain, opts = {}) {
75
+ const principal = keychain?._identity;
76
+ if (!principal?.publicKeyHex) throw new Error("mintDelegationDoc: principal identity not loaded");
77
+ const { delegate, delegatePublicKey } = opts;
78
+ if (typeof delegate !== "string" || !delegate.startsWith("uuaid:")) {
79
+ throw new Error("mintDelegationDoc: delegate uuaid required");
80
+ }
81
+ if (typeof delegatePublicKey !== "string" || !/^[0-9a-f]{64}$/i.test(delegatePublicKey)) {
82
+ throw new Error("mintDelegationDoc: delegatePublicKey must be 64 hex");
83
+ }
84
+ const kinds = Array.isArray(opts.kinds) && opts.kinds.length ? [...opts.kinds] : ["*"];
85
+ const start = opts.notBefore instanceof Date ? opts.notBefore : new Date();
86
+ const ttlDays = Number.isFinite(opts.ttlDays) ? opts.ttlDays : 90;
87
+ const end = new Date(start.getTime() + ttlDays * 24 * 60 * 60 * 1000);
88
+ const unsigned = {
89
+ version: DELEGATION_VERSION,
90
+ id: `del-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`,
91
+ principal: principal.uuaid,
92
+ principalPublicKey: principal.publicKeyHex,
93
+ delegate,
94
+ delegatePublicKey: delegatePublicKey.toLowerCase(),
95
+ kinds,
96
+ notBefore: start.toISOString(),
97
+ notAfter: end.toISOString(),
98
+ };
99
+ const signature = keychain.sign(jcs(unsigned));
100
+ return {
101
+ ...unsigned,
102
+ signature: {
103
+ alg: "ed25519",
104
+ publicKey: principal.publicKeyHex,
105
+ signature: Buffer.from(signature).toString("hex"),
106
+ },
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Extract the local-id of a UUAID with pillar-client parseUuaid semantics:
112
+ * exactly four colon-separated segments with the "uuaid" prefix.
113
+ * Returns null for anything else (never throws).
114
+ */
115
+ export function uuaidLocalId(uuaid) {
116
+ if (typeof uuaid !== "string") return null;
117
+ const parts = uuaid.split(":");
118
+ if (parts.length !== 4 || parts[0] !== "uuaid") return null;
119
+ return parts[3];
120
+ }
121
+
122
+ /** Self-authenticating identity check: sha256(pubkey)[:16] === uuaid local-id. */
123
+ export function uuaidBindingOk(uuaid, publicKeyHex) {
124
+ const localId = uuaidLocalId(uuaid);
125
+ if (localId === null) return false;
126
+ if (typeof publicKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return false;
127
+ return Keychain.localIdFromKey(Buffer.from(publicKeyHex, "hex")) === localId;
128
+ }
129
+
130
+ /**
131
+ * Verify a delegation document on its own: shape, identity bindings, time
132
+ * window, and the principal's signature over JCS(doc minus .signature).
133
+ *
134
+ * Reason strings intentionally mirror @uuaid/pillar-client verifyDelegation
135
+ * so tooling sees identical diagnostics on both implementations.
136
+ *
137
+ * Returns { ok: true } or { ok: false, reason }.
138
+ */
139
+ export function verifyDelegationDoc(d, now = new Date()) {
140
+ if (!d || typeof d !== "object" || Array.isArray(d)) return { ok: false, reason: "not-object" };
141
+ if (d.version !== DELEGATION_VERSION) return { ok: false, reason: `bad-version:${d.version}` };
142
+ if (!/^[0-9a-f]{64}$/i.test(d.principalPublicKey ?? "")) return { ok: false, reason: "bad-principal-pubkey" };
143
+ if (!/^[0-9a-f]{64}$/i.test(d.delegatePublicKey ?? "")) return { ok: false, reason: "bad-delegate-pubkey" };
144
+ if (!uuaidBindingOk(d.principal, d.principalPublicKey)) {
145
+ return { ok: false, reason: "principal-uuaid-binding" };
146
+ }
147
+ if (!uuaidBindingOk(d.delegate, d.delegatePublicKey)) {
148
+ return { ok: false, reason: "delegate-uuaid-binding" };
149
+ }
150
+ if (!Array.isArray(d.kinds) || d.kinds.length === 0 || d.kinds.some(k => typeof k !== "string")) {
151
+ return { ok: false, reason: "bad-kinds" };
152
+ }
153
+ const nb = Date.parse(d.notBefore);
154
+ const na = Date.parse(d.notAfter);
155
+ if (Number.isNaN(nb) || Number.isNaN(na)) return { ok: false, reason: "bad-time" };
156
+ if (na <= nb) return { ok: false, reason: "empty-window" };
157
+ const t = now instanceof Date ? now.getTime() : Number(now);
158
+ if (t < nb) return { ok: false, reason: "not-yet-valid" };
159
+ if (t > na) return { ok: false, reason: "expired" };
160
+
161
+ const sig = d.signature;
162
+ if (!sig || sig.alg !== "ed25519" || sig.publicKey !== d.principalPublicKey) {
163
+ return { ok: false, reason: "bad-signature-shape" };
164
+ }
165
+ if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) {
166
+ return { ok: false, reason: "bad-signature-shape" };
167
+ }
168
+ // The signature covers JCS of everything except .signature.
169
+ const { signature: _drop, ...unsigned } = d;
170
+ let canonical;
171
+ try { canonical = jcs(unsigned); } catch (_e) { return { ok: false, reason: "bad-canonical" }; }
172
+ let okSig = false;
173
+ try {
174
+ okSig = Keychain.verifyDetached(sig.publicKey, Buffer.from(canonical, "utf-8"), Buffer.from(sig.signature, "hex"));
175
+ } catch (_e) {
176
+ return { ok: false, reason: "verify-threw" };
177
+ }
178
+ if (!okSig) return { ok: false, reason: "bad-signature" };
179
+ return { ok: true };
180
+ }
181
+
182
+ /**
183
+ * Names that may appear in a delegation's `kinds` as CAPABILITIES but are not
184
+ * envelope kinds, and so must never be deliverable.
185
+ *
186
+ * `kinds` is a single namespace answering two different questions: "may this
187
+ * delegate SEND envelopes of kind K" (envelope.mjs) and "may this delegate
188
+ * POLL the principal's inbox" (carrier-server.mjs, capability "inbox"). The
189
+ * overlap means a token minted read-only as `kinds: ["inbox"]` also satisfies
190
+ * the send check for an envelope whose `kind` is literally "inbox" — verified
191
+ * against a live alpha.15 carrier, which returned 202 for exactly that forgery
192
+ * while correctly returning 403 kind-not-covered for message/request/reply.
193
+ *
194
+ * Nothing consumes `kind: "inbox"`, so this is latent rather than live. It
195
+ * stops being latent the moment a read-only poll token is issued to anyone,
196
+ * which is what the push relay is being rebuilt to use.
197
+ *
198
+ * Enforced on the send path in envelope.mjs. Deliberately NOT enforced inside
199
+ * delegationCoversKind: that helper serves both questions, and the capability
200
+ * lookup in carrier-server.mjs depends on "inbox" matching there.
201
+ */
202
+ export const CAPABILITY_ONLY_KINDS = new Set(["inbox"]);
203
+
204
+ /** Does this delegation cover envelopes of `kind`? "*" covers all. */
205
+ export function delegationCoversKind(d, kind) {
206
+ if (!Array.isArray(d?.kinds)) return false;
207
+ if (d.kinds.includes("*")) return true;
208
+ return d.kinds.includes(kind);
209
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Envelope v2 — the wire format for pillar-to-pillar messages.
3
+ *
4
+ * ENCRYPTION IS FUNDAMENTAL. There is no plaintext payload mode. Every
5
+ * envelope's payload is sealed to the recipient's key before the envelope
6
+ * is signed. Anyone can VERIFY an envelope (transport signature is public);
7
+ * only the recipient can READ it.
8
+ *
9
+ * Envelope v2 = {
10
+ * version: "uuaid-pillar-envelope/2",
11
+ * id: "env-...",
12
+ * sender: "uuaid:...",
13
+ * recipient: "uuaid:...",
14
+ * kind: "message" | "work-order" | ... (routing hint, NOT secret)
15
+ * createdAt: ISO 8601,
16
+ * enc: { alg: "x25519-hkdf-sha256-aes256gcm", epk, iv, ct }, // sealed payload
17
+ * transportSignature: { alg: "ed25519", keyId, publicKey, signature, created },
18
+ * }
19
+ *
20
+ * The transport signature covers everything (including the ciphertext), so
21
+ * tamper detection works WITHOUT decrypting — carriers verify-then-store.
22
+ * The GCM AAD binds the ciphertext to the envelope id, so a valid ciphertext
23
+ * cannot be transplanted into a different envelope even by the sender.
24
+ *
25
+ * seal() enforces identity binding: recipient UUAID local-id must equal
26
+ * sha256(recipientPublicKey) — you cannot encrypt to a key that doesn't own
27
+ * the UUAID you're addressing.
28
+ */
29
+ import { createHash, randomBytes } from "node:crypto";
30
+ import { jcs } from "../identity/jcs.mjs";
31
+ import { Keychain, isStrictEd25519PublicKey } from "../identity/keychain.mjs";
32
+ import { sealPayload, openPayload, E2E_ALG } from "../crypto/e2e.mjs";
33
+ import { verifyDelegationDoc, delegationCoversKind, CAPABILITY_ONLY_KINDS } from "./delegation.mjs";
34
+
35
+ export const ENVELOPE_VERSION = "uuaid-pillar-envelope/2";
36
+ export { E2E_ALG };
37
+
38
+ /**
39
+ * Build, encrypt, and sign an envelope.
40
+ *
41
+ * keychain: a loaded Keychain (has .sign()).
42
+ * body: {
43
+ * recipient: "uuaid:..." REQUIRED
44
+ * recipientPublicKey: ed25519 pubkey hex REQUIRED (E2E is not optional)
45
+ * kind, payload, id, createdAt optional
46
+ * }
47
+ */
48
+ export function seal(keychain, body) {
49
+ const identity = keychain._identity;
50
+ if (!identity) throw new Error("keychain not loaded");
51
+ const sender = body.sender ?? identity.uuaid;
52
+ if (sender !== identity.uuaid) {
53
+ throw new Error(`envelope.sender ${sender} does not match keychain identity ${identity.uuaid}`);
54
+ }
55
+ if (!body.recipient || typeof body.recipient !== "string") {
56
+ throw new Error("seal: recipient required");
57
+ }
58
+ if (!body.recipientPublicKey || !/^[0-9a-f]{64}$/i.test(body.recipientPublicKey)) {
59
+ throw new Error("seal: recipientPublicKey (ed25519 hex) is REQUIRED — encryption is not optional");
60
+ }
61
+ // Identity binding: the key must own the UUAID.
62
+ const expectedLocalId = Keychain._localIdFromKey(Buffer.from(body.recipientPublicKey, "hex"));
63
+ const recipientLocalId = body.recipient.split(":")[3];
64
+ if (recipientLocalId !== expectedLocalId) {
65
+ throw new Error(`seal: recipientPublicKey does not derive recipient UUAID (key owns ...${expectedLocalId.slice(0, 13)}, envelope addressed to ...${String(recipientLocalId).slice(0, 13)})`);
66
+ }
67
+
68
+ const id = body.id ?? mkEnvelopeId();
69
+ const enc = sealPayload(body.recipientPublicKey, body.payload ?? {}, { aad: id });
70
+
71
+ const envelope = {
72
+ version: ENVELOPE_VERSION,
73
+ id,
74
+ sender,
75
+ recipient: body.recipient,
76
+ kind: body.kind ?? "message",
77
+ enc,
78
+ createdAt: body.createdAt ?? new Date().toISOString(),
79
+ };
80
+ const canonical = jcs(envelope);
81
+ const sigBytes = keychain.sign(Buffer.from(canonical, "utf-8"));
82
+ envelope.transportSignature = {
83
+ alg: "ed25519",
84
+ keyId: identity.uuaid,
85
+ publicKey: identity.publicKeyHex,
86
+ signature: sigBytes.toString("hex"),
87
+ created: envelope.createdAt,
88
+ };
89
+ return envelope;
90
+ }
91
+
92
+ /**
93
+ * Verify an envelope's transport signature and structure. Does NOT decrypt.
94
+ * Anyone (carriers, relays, auditors) can call this.
95
+ * Returns { ok: true } or { ok: false, reason }.
96
+ *
97
+ * DELEGATED ENVELOPES (UACP/1.0 ext): when the optional `delegation` field is
98
+ * present, the self-authenticating-sender rule is replaced by the delegation
99
+ * chain — see openDelegated() below and src/net/delegation.mjs. When the
100
+ * field is ABSENT the checks below are byte-for-byte the legacy behavior.
101
+ */
102
+ export function open(envelope, { cryptoInventory } = {}) {
103
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return { ok: false, reason: "not-object" };
104
+ if (envelope.version !== ENVELOPE_VERSION) return { ok: false, reason: `bad-version ${envelope.version}` };
105
+ const sig = envelope.transportSignature;
106
+ if (!sig || typeof sig !== "object" || sig.alg !== "ed25519") return { ok: false, reason: "missing-or-bad-alg" };
107
+ if (cryptoInventory && cryptoInventory[sig.alg] !== "active") {
108
+ return { ok: false, reason: `alg-not-active:${sig.alg}` };
109
+ }
110
+ if (envelope.delegation !== undefined) {
111
+ return openDelegated(envelope, { cryptoInventory });
112
+ }
113
+ if (sig.keyId !== envelope.sender) return { ok: false, reason: "keyId-sender-mismatch" };
114
+ if (typeof sig.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(sig.publicKey)) return { ok: false, reason: "bad-publicKey" };
115
+ if (!isStrictEd25519PublicKey(sig.publicKey)) return { ok: false, reason: "weak-publicKey" }; // @rule envelope.open-strict-key
116
+ if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) return { ok: false, reason: "bad-signature-encoding" };
117
+ // Structural checks on the encryption block — an envelope without one is invalid.
118
+ const enc = envelope.enc;
119
+ if (!enc || typeof enc !== "object") return { ok: false, reason: "missing-enc" };
120
+ if (enc.alg !== E2E_ALG) return { ok: false, reason: `enc-alg-not-supported:${enc.alg}` };
121
+ if (cryptoInventory && cryptoInventory[enc.alg] && cryptoInventory[enc.alg] !== "active") {
122
+ return { ok: false, reason: `enc-alg-not-active:${enc.alg}` };
123
+ }
124
+ if (typeof enc.epk !== "string" || !/^[0-9a-f]{64}$/i.test(enc.epk)) return { ok: false, reason: "bad-epk" };
125
+ if (typeof enc.iv !== "string" || !/^[0-9a-f]{24}$/i.test(enc.iv)) return { ok: false, reason: "bad-iv" };
126
+ if (typeof enc.ct !== "string" || !/^[0-9a-f]{32,}$/i.test(enc.ct)) return { ok: false, reason: "bad-ct" };
127
+ // Verify the signature over the envelope WITHOUT the signature block.
128
+ const { transportSignature: _sig, ...withoutSig } = envelope;
129
+ let canonical;
130
+ try { canonical = jcs(withoutSig); } catch (_e) { return { ok: false, reason: "bad-canonical" }; }
131
+ let okSig;
132
+ try {
133
+ okSig = Keychain.verifyDetached(sig.publicKey, Buffer.from(canonical, "utf-8"), Buffer.from(sig.signature, "hex"));
134
+ } catch (_e) {
135
+ return { ok: false, reason: "verify-threw" };
136
+ }
137
+ if (!okSig) return { ok: false, reason: "bad-signature" };
138
+ // Sender binding (self-authenticating identity): the sender UUAID's
139
+ // local-id MUST derive from the signing key — the sender-side mirror of
140
+ // seal()'s recipient binding. Without this, anyone can sign an envelope
141
+ // with their own key while claiming another agent's sender UUAID.
142
+ const expectedSenderLocalId = Keychain._localIdFromKey(Buffer.from(sig.publicKey, "hex"));
143
+ if (expectedSenderLocalId !== String(envelope.sender).split(":")[3]) {
144
+ return { ok: false, reason: "publicKey-sender-mismatch" };
145
+ }
146
+ return { ok: true };
147
+ }
148
+
149
+ /**
150
+ * Delegated path of open() — UACP/1.0 extension. Invoked when the envelope
151
+ * carries a `delegation` document; the legacy self-authenticating-sender rule
152
+ * is REPLACED by the five delegation checks (see delegation.mjs):
153
+ *
154
+ * (a) envelope signature valid under the DELEGATE's key,
155
+ * transportSignature.keyId === delegation.delegate;
156
+ * (b) delegation document signature valid under the PRINCIPAL's key,
157
+ * delegation.principal === envelope.sender,
158
+ * delegation.delegate === the signing key's uuaid;
159
+ * (c) local-id binding for BOTH identities (inside verifyDelegationDoc);
160
+ * (d) now within [notBefore, notAfter] (inside verifyDelegationDoc);
161
+ * (e) envelope.kind covered by delegation.kinds.
162
+ *
163
+ * Fail-open is not an option: any failure rejects with
164
+ * { ok: false, reason: "delegation-invalid:<reason>", code: 403 }. Pure
165
+ * wire-format failures (enc block, hex encodings) keep the legacy reasons and
166
+ * 400 status — they are not delegation verdicts.
167
+ *
168
+ * The `delegation` field is part of the SIGNED body (sealed inside the
169
+ * canonicalization by the delegate), so a carrier cannot strip or swap it
170
+ * without breaking the envelope signature.
171
+ */
172
+ function openDelegated(envelope, { cryptoInventory } = {}) {
173
+ const reject = (reason) => ({ ok: false, reason: `delegation-invalid:${reason}`, code: 403 });
174
+ const sig = envelope.transportSignature;
175
+ if (typeof sig.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(sig.publicKey)) return { ok: false, reason: "bad-publicKey" };
176
+ if (!isStrictEd25519PublicKey(sig.publicKey)) return { ok: false, reason: "weak-publicKey" }; // @rule envelope.open-delegated-strict-key
177
+ if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) return { ok: false, reason: "bad-signature-encoding" };
178
+ // Structural checks on the encryption block — same rules as the legacy path.
179
+ const enc = envelope.enc;
180
+ if (!enc || typeof enc !== "object") return { ok: false, reason: "missing-enc" };
181
+ if (enc.alg !== E2E_ALG) return { ok: false, reason: `enc-alg-not-supported:${enc.alg}` };
182
+ if (cryptoInventory && cryptoInventory[enc.alg] && cryptoInventory[enc.alg] !== "active") {
183
+ return { ok: false, reason: `enc-alg-not-active:${enc.alg}` };
184
+ }
185
+ if (typeof enc.epk !== "string" || !/^[0-9a-f]{64}$/i.test(enc.epk)) return { ok: false, reason: "bad-epk" };
186
+ if (typeof enc.iv !== "string" || !/^[0-9a-f]{24}$/i.test(enc.iv)) return { ok: false, reason: "bad-iv" };
187
+ if (typeof enc.ct !== "string" || !/^[0-9a-f]{32,}$/i.test(enc.ct)) return { ok: false, reason: "bad-ct" };
188
+
189
+ const d = envelope.delegation;
190
+ // (b-i) + (c) + (d): the delegation document itself — shape, both local-id
191
+ // bindings, time window, and the principal's signature over it.
192
+ const dv = verifyDelegationDoc(d);
193
+ if (!dv.ok) return reject(dv.reason);
194
+ // (b-ii) the persona this envelope acts as must be the delegation principal.
195
+ if (d.principal !== envelope.sender) return reject("sender-not-principal");
196
+ // (b-iii) the actual signer must be the named delegate, key and all.
197
+ if (sig.keyId !== d.delegate) return reject("signer-not-delegate");
198
+ if (sig.publicKey !== d.delegatePublicKey) return reject("signer-key-mismatch");
199
+ // (e) the delegation must cover this envelope kind.
200
+ //
201
+ // A capability name is checked FIRST and unconditionally — including under
202
+ // kinds:["*"] — because these names are not envelope kinds at all. Without
203
+ // this, a read-only kinds:["inbox"] poll token doubles as authority to send
204
+ // envelopes of kind "inbox" as the principal. See CAPABILITY_ONLY_KINDS.
205
+ if (CAPABILITY_ONLY_KINDS.has(envelope.kind)) return reject("kind-reserved-capability");
206
+ if (!delegationCoversKind(d, envelope.kind)) return reject("kind-not-covered");
207
+ // (a) the envelope signature under the delegate key, over the canonical
208
+ // body INCLUDING the delegation field (exactly what the delegate signed).
209
+ const { transportSignature: _sig, ...withoutSig } = envelope;
210
+ let canonical;
211
+ try { canonical = jcs(withoutSig); } catch (_e) { return { ok: false, reason: "bad-canonical" }; }
212
+ let okSig;
213
+ try {
214
+ okSig = Keychain.verifyDetached(sig.publicKey, Buffer.from(canonical, "utf-8"), Buffer.from(sig.signature, "hex"));
215
+ } catch (_e) {
216
+ return { ok: false, reason: "verify-threw" };
217
+ }
218
+ if (!okSig) return reject("bad-envelope-signature");
219
+ return { ok: true, delegated: true, actingFor: d.principal };
220
+ }
221
+
222
+ /**
223
+ * Decrypt an envelope's payload. Only the recipient can do this.
224
+ * keychain must be the recipient's loaded keychain.
225
+ * Throws on wrong recipient, wrong key, tamper, or AAD mismatch.
226
+ */
227
+ export function decrypt(keychain, envelope) {
228
+ const identity = keychain._identity;
229
+ if (!identity) throw new Error("decrypt: keychain not loaded");
230
+ if (envelope.recipient !== identity.uuaid) {
231
+ throw new Error(`decrypt: envelope addressed to ${envelope.recipient}, not us (${identity.uuaid})`);
232
+ }
233
+ const seed = keychainSeed(keychain);
234
+ return openPayload(seed, envelope.enc, { aad: envelope.id });
235
+ }
236
+
237
+ /** Extract the 32-byte Ed25519 seed from a loaded keychain. */
238
+ export function keychainSeed(keychain) {
239
+ const der = keychain._identity.privateKey.export({ type: "pkcs8", format: "der" });
240
+ return Buffer.from(der.subarray(der.length - 32));
241
+ }
242
+
243
+ /**
244
+ * Envelope id: 16 random bytes hex + a small timestamp prefix for
245
+ * human-readable sort. Not sensitive; not secret. Used as GCM AAD.
246
+ */
247
+ export function mkEnvelopeId() {
248
+ const now = Date.now().toString(36);
249
+ const rnd = randomBytes(8).toString("hex");
250
+ return `env-${now}-${rnd}`;
251
+ }
252
+
253
+ /**
254
+ * Content-hash of an envelope, computable by anyone.
255
+ */
256
+ export function envelopeSha(envelope) {
257
+ return createHash("sha256").update(jcs(envelope)).digest("hex");
258
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Refusals in agent language: every refusal is a stable `code` plus one
3
+ * imperative `fix` line, and the CLIs print exactly `<code>: <fix>`.
4
+ *
5
+ * The carrier's codes are the `reason` (refused send) or `error` (refused
6
+ * inbox read) values it has always sent (carrier-server.mjs, envelope.mjs,
7
+ * delegation.mjs). Those values and their status codes never change here;
8
+ * the carrier only ADDS a `fix` field, and a client talking to an older
9
+ * carrier looks the fix up in this same map. The blob broker sends its own
10
+ * `code` + `fix`, which is printed as received.
11
+ *
12
+ * CLOSED AND STATIC. Every fix this package produces is a value of
13
+ * REFUSAL_FIXES: a fixed string, never built from request data, a remote
14
+ * reason or an exception message. A parameterised reason
15
+ * (`delegation-invalid:<why>`, `bad-version <v>`, `alg-not-active:<alg>`)
16
+ * resolves by its family key, so the parameter never reaches the fix.
17
+ *
18
+ * Documented, with statuses, in HOSTING-LIMITS.md "Refusal codes".
19
+ */
20
+
21
+ const RESEAL = "re-seal the envelope with seal() from @uuaid/pillar or @uuaid/pillar-client; this one does not verify";
22
+
23
+ /** code -> one-line fix. Frozen: add, never rename, never interpolate. */
24
+ export const REFUSAL_FIXES = Object.freeze({
25
+ // Carrier, POST /v1/envelopes
26
+ "rate-limited": "back off: budgets refill over a sliding 60 s, so retry after 5 s, doubling to 60 s",
27
+ "body-too-large": "send the payload as a blob (pillar-blob send); maxBodyBytes is your tier's inline limit",
28
+ "stale-envelope": "seal a new envelope and check your clock; createdAt must be within 24 h of carrier time",
29
+ "carrier-full": "use your other carriers and do not retry this one soon; its store is full",
30
+ "bad-recipient": "address the envelope to a full UUAID (uuaid:<namespace>:<type>:<id>)",
31
+ "bad-json": "POST the envelope as one JSON object",
32
+ "keyId-sender-mismatch": "sign with transportSignature.keyId equal to the sender UUAID, or send under a delegation",
33
+ "publicKey-sender-mismatch": "send from the UUAID your signing key derives, or attach the principal's delegation",
34
+ "bad-signature": "sign again with this identity's own key over the exact bytes; the signature does not verify",
35
+ "weak-publicKey": "use a real Ed25519 identity key; this key is a small-order or non-canonical point that can sign anything, so no signature under it counts",
36
+ "delegation-invalid": "mint a fresh delegation from the principal, then resend",
37
+ // Carrier, envelope shape and crypto (envelope.mjs open()): one fix for all
38
+ "not-object": RESEAL,
39
+ "bad-version": RESEAL,
40
+ "missing-or-bad-alg": RESEAL,
41
+ "alg-not-active": RESEAL,
42
+ "bad-publicKey": RESEAL,
43
+ "bad-signature-encoding": RESEAL,
44
+ "missing-enc": RESEAL,
45
+ "enc-alg-not-supported": RESEAL,
46
+ "enc-alg-not-active": RESEAL,
47
+ "bad-epk": RESEAL,
48
+ "bad-iv": RESEAL,
49
+ "bad-ct": RESEAL,
50
+ "bad-canonical": RESEAL,
51
+ "verify-threw": RESEAL,
52
+ // Carrier, GET /v1/inbox/<uuaid>
53
+ "missing-auth-headers": "send x-pillar-pubkey, x-pillar-ts and x-pillar-sig",
54
+ "bad-pubkey": "send x-pillar-pubkey as the raw Ed25519 key in 64 hex characters",
55
+ "bad-sig-encoding": "send x-pillar-sig as 128 hex characters",
56
+ "clock-skew": "fix your clock (NTP) to within 5 min of the carrier; retrying does not help",
57
+ "uuaid-pubkey-mismatch": "read the inbox of the UUAID your key derives, or send that UUAID's delegation in x-pillar-delegation",
58
+ "delegation-unparseable": "send x-pillar-delegation as the JSON delegation document",
59
+ "delegation-too-large": "send a delegation document under 8,192 bytes",
60
+ "delegation-principal-mismatch": "use a delegation whose principal is the inbox UUAID",
61
+ "delegation-delegate-key-mismatch": "sign with the key the delegation names as delegate",
62
+ "delegation-inbox-denied": "ask the principal for a delegation that covers inbox (or *)",
63
+ "busy": "retry after at least 2 s with jitter, or poll with wait=0 for a while",
64
+ // Carrier, any route
65
+ "not-found": "use POST /v1/envelopes or GET /v1/inbox/<uuaid>",
66
+ "not-a-seed": "do not list this carrier as a seed; it runs without a seed identity",
67
+ "internal-error": "retry later; if it repeats, tell the carrier operator",
68
+ // Client side: no server gave a code
69
+ "carrier-unreachable": "check the carrier URLs and your network, then retry",
70
+ "carrier-bad-response": "retry, or use another carrier; the carrier or its proxy answered without a reason",
71
+ "over-tier-limit": "send the payload as a blob (pillar-blob send, or Pillar.sendBlob)",
72
+ "blob-key-unproven": "use a uuaid:foundation:agent UUAID derived from this key (pillar init), or get a registry badge that names this key",
73
+ "sender-key-not-active": "ask the sender to send from a uuaid:foundation:agent UUAID derived from its own key, or to get a registry badge naming its key",
74
+ });
75
+
76
+ /**
77
+ * The fix for a carrier reason, or null for a reason this map does not know.
78
+ * An exact key wins; otherwise the reason's family (the part before the
79
+ * first ":" or space) is looked up, so `delegation-invalid:expired` and
80
+ * `bad-version 2` resolve to their family's fix. The result is always a
81
+ * value of REFUSAL_FIXES or null; nothing from `reason` is copied into it.
82
+ */
83
+ export function carrierFix(reason) {
84
+ if (typeof reason !== "string" || !reason) return null;
85
+ if (Object.hasOwn(REFUSAL_FIXES, reason)) return REFUSAL_FIXES[reason];
86
+ const family = reason.split(/[: ]/, 1)[0];
87
+ return family !== reason && Object.hasOwn(REFUSAL_FIXES, family) ? REFUSAL_FIXES[family] : null;
88
+ }
89
+
90
+ /**
91
+ * A carrier response body with its `fix` added. Pure addition: only a
92
+ * refusal (status >= 400) that names a `reason` or `error` and has no `fix`
93
+ * yet gains one; every other field, and every other body, is returned as is.
94
+ */
95
+ export function withCarrierFix(status, body) {
96
+ if (!(status >= 400) || !body || typeof body !== "object" || Array.isArray(body) || body.fix !== undefined) return body;
97
+ const fix = carrierFix(typeof body.reason === "string" ? body.reason : body.error);
98
+ return fix ? { ...body, fix } : body;
99
+ }
100
+
101
+ /**
102
+ * Terminal safety only: a line break or control character a remote sent
103
+ * becomes a space, so the output stays one line and cannot drive the
104
+ * terminal. Every other character is printed as received.
105
+ */
106
+ function oneLine(value) {
107
+ return String(value).replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
108
+ }
109
+
110
+ /**
111
+ * `<code>: <fix>` for an error that carries both (a carrier or broker
112
+ * refusal, or a client error that knows its next step), else null so the
113
+ * caller keeps its old output. A server's fix is printed as received.
114
+ */
115
+ export function refusalLine(error) {
116
+ const code = typeof error?.code === "string" ? oneLine(error.code) : "";
117
+ const fix = typeof error?.fix === "string" ? oneLine(error.fix) : "";
118
+ return code && fix ? `${code}: ${fix}` : null;
119
+ }
120
+
121
+ /** `{ code, fix }` of an error, for a result row, omitting what is absent. */
122
+ export function refusalFields(error) {
123
+ return {
124
+ ...(typeof error?.code === "string" && error.code ? { code: error.code } : {}),
125
+ ...(typeof error?.fix === "string" && error.fix ? { fix: error.fix } : {}),
126
+ };
127
+ }