@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,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
|
+
}
|