@uuaid/pillar 0.2.0-alpha.9 → 2.0.1
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/HOSTING-LIMITS.md +432 -0
- package/INTERACTION-CONVENTION.md +222 -1
- package/README.md +358 -17
- package/bin/carrier.mjs +7 -1
- package/bin/pillar-ask.mjs +1 -0
- package/bin/pillar-blob.mjs +139 -0
- package/bin/pillar-directory.mjs +232 -0
- package/bin/pillar-escrow.mjs +675 -0
- package/bin/pillar-responder.mjs +47 -1
- package/bin/pillar-seed.mjs +113 -13
- package/bin/pillar.mjs +299 -7
- package/package.json +30 -7
- package/src/blob/blob.mjs +510 -0
- package/src/blob/broker.mjs +129 -0
- package/src/blob/http.mjs +334 -0
- package/src/blob/store.mjs +55 -0
- package/src/escrow/box.mjs +43 -0
- package/src/escrow/escrow-service.mjs +538 -0
- package/src/escrow/rails/evm-rail.mjs +427 -0
- package/src/escrow/rails/index.mjs +41 -0
- package/src/escrow/rails/zilligon-rail.mjs +73 -0
- package/src/escrow/terms.mjs +368 -0
- package/src/escrow/token-registry.mjs +231 -0
- package/src/escrow/verify.mjs +119 -0
- package/src/escrow/zilligon.mjs +266 -0
- package/src/identity/keychain.mjs +27 -10
- package/src/identity/tier.mjs +130 -0
- package/src/identity/trust.mjs +63 -0
- package/src/index.mjs +28 -2
- package/src/mailbox/mailbox.mjs +33 -2
- package/src/net/carrier-client.mjs +93 -14
- package/src/net/carrier-server.mjs +212 -15
- package/src/net/delegation.mjs +209 -0
- package/src/net/envelope.mjs +81 -0
- package/src/net/link-offer.mjs +107 -0
- package/src/net/refusals.mjs +126 -0
- package/src/net/seed-registry.mjs +467 -0
- package/src/net/seed-tier.mjs +185 -0
- package/src/node-check.mjs +32 -0
- package/src/observability.mjs +141 -0
- package/src/pillar.mjs +180 -17
- package/src/vda/core.mjs +290 -0
- package/src/vda/directory.mjs +87 -0
- package/src/vda/instruments.mjs +246 -0
- package/src/vda/shims.mjs +77 -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
|
+
}
|
package/src/net/envelope.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
30
30
|
import { jcs } from "../identity/jcs.mjs";
|
|
31
31
|
import { Keychain } from "../identity/keychain.mjs";
|
|
32
32
|
import { sealPayload, openPayload, E2E_ALG } from "../crypto/e2e.mjs";
|
|
33
|
+
import { verifyDelegationDoc, delegationCoversKind, CAPABILITY_ONLY_KINDS } from "./delegation.mjs";
|
|
33
34
|
|
|
34
35
|
export const ENVELOPE_VERSION = "uuaid-pillar-envelope/2";
|
|
35
36
|
export { E2E_ALG };
|
|
@@ -92,6 +93,11 @@ export function seal(keychain, body) {
|
|
|
92
93
|
* Verify an envelope's transport signature and structure. Does NOT decrypt.
|
|
93
94
|
* Anyone (carriers, relays, auditors) can call this.
|
|
94
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.
|
|
95
101
|
*/
|
|
96
102
|
export function open(envelope, { cryptoInventory } = {}) {
|
|
97
103
|
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return { ok: false, reason: "not-object" };
|
|
@@ -101,6 +107,9 @@ export function open(envelope, { cryptoInventory } = {}) {
|
|
|
101
107
|
if (cryptoInventory && cryptoInventory[sig.alg] !== "active") {
|
|
102
108
|
return { ok: false, reason: `alg-not-active:${sig.alg}` };
|
|
103
109
|
}
|
|
110
|
+
if (envelope.delegation !== undefined) {
|
|
111
|
+
return openDelegated(envelope, { cryptoInventory });
|
|
112
|
+
}
|
|
104
113
|
if (sig.keyId !== envelope.sender) return { ok: false, reason: "keyId-sender-mismatch" };
|
|
105
114
|
if (typeof sig.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(sig.publicKey)) return { ok: false, reason: "bad-publicKey" };
|
|
106
115
|
if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) return { ok: false, reason: "bad-signature-encoding" };
|
|
@@ -136,6 +145,78 @@ export function open(envelope, { cryptoInventory } = {}) {
|
|
|
136
145
|
return { ok: true };
|
|
137
146
|
}
|
|
138
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Delegated path of open() — UACP/1.0 extension. Invoked when the envelope
|
|
150
|
+
* carries a `delegation` document; the legacy self-authenticating-sender rule
|
|
151
|
+
* is REPLACED by the five delegation checks (see delegation.mjs):
|
|
152
|
+
*
|
|
153
|
+
* (a) envelope signature valid under the DELEGATE's key,
|
|
154
|
+
* transportSignature.keyId === delegation.delegate;
|
|
155
|
+
* (b) delegation document signature valid under the PRINCIPAL's key,
|
|
156
|
+
* delegation.principal === envelope.sender,
|
|
157
|
+
* delegation.delegate === the signing key's uuaid;
|
|
158
|
+
* (c) local-id binding for BOTH identities (inside verifyDelegationDoc);
|
|
159
|
+
* (d) now within [notBefore, notAfter] (inside verifyDelegationDoc);
|
|
160
|
+
* (e) envelope.kind covered by delegation.kinds.
|
|
161
|
+
*
|
|
162
|
+
* Fail-open is not an option: any failure rejects with
|
|
163
|
+
* { ok: false, reason: "delegation-invalid:<reason>", code: 403 }. Pure
|
|
164
|
+
* wire-format failures (enc block, hex encodings) keep the legacy reasons and
|
|
165
|
+
* 400 status — they are not delegation verdicts.
|
|
166
|
+
*
|
|
167
|
+
* The `delegation` field is part of the SIGNED body (sealed inside the
|
|
168
|
+
* canonicalization by the delegate), so a carrier cannot strip or swap it
|
|
169
|
+
* without breaking the envelope signature.
|
|
170
|
+
*/
|
|
171
|
+
function openDelegated(envelope, { cryptoInventory } = {}) {
|
|
172
|
+
const reject = (reason) => ({ ok: false, reason: `delegation-invalid:${reason}`, code: 403 });
|
|
173
|
+
const sig = envelope.transportSignature;
|
|
174
|
+
if (typeof sig.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(sig.publicKey)) return { ok: false, reason: "bad-publicKey" };
|
|
175
|
+
if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) return { ok: false, reason: "bad-signature-encoding" };
|
|
176
|
+
// Structural checks on the encryption block — same rules as the legacy path.
|
|
177
|
+
const enc = envelope.enc;
|
|
178
|
+
if (!enc || typeof enc !== "object") return { ok: false, reason: "missing-enc" };
|
|
179
|
+
if (enc.alg !== E2E_ALG) return { ok: false, reason: `enc-alg-not-supported:${enc.alg}` };
|
|
180
|
+
if (cryptoInventory && cryptoInventory[enc.alg] && cryptoInventory[enc.alg] !== "active") {
|
|
181
|
+
return { ok: false, reason: `enc-alg-not-active:${enc.alg}` };
|
|
182
|
+
}
|
|
183
|
+
if (typeof enc.epk !== "string" || !/^[0-9a-f]{64}$/i.test(enc.epk)) return { ok: false, reason: "bad-epk" };
|
|
184
|
+
if (typeof enc.iv !== "string" || !/^[0-9a-f]{24}$/i.test(enc.iv)) return { ok: false, reason: "bad-iv" };
|
|
185
|
+
if (typeof enc.ct !== "string" || !/^[0-9a-f]{32,}$/i.test(enc.ct)) return { ok: false, reason: "bad-ct" };
|
|
186
|
+
|
|
187
|
+
const d = envelope.delegation;
|
|
188
|
+
// (b-i) + (c) + (d): the delegation document itself — shape, both local-id
|
|
189
|
+
// bindings, time window, and the principal's signature over it.
|
|
190
|
+
const dv = verifyDelegationDoc(d);
|
|
191
|
+
if (!dv.ok) return reject(dv.reason);
|
|
192
|
+
// (b-ii) the persona this envelope acts as must be the delegation principal.
|
|
193
|
+
if (d.principal !== envelope.sender) return reject("sender-not-principal");
|
|
194
|
+
// (b-iii) the actual signer must be the named delegate, key and all.
|
|
195
|
+
if (sig.keyId !== d.delegate) return reject("signer-not-delegate");
|
|
196
|
+
if (sig.publicKey !== d.delegatePublicKey) return reject("signer-key-mismatch");
|
|
197
|
+
// (e) the delegation must cover this envelope kind.
|
|
198
|
+
//
|
|
199
|
+
// A capability name is checked FIRST and unconditionally — including under
|
|
200
|
+
// kinds:["*"] — because these names are not envelope kinds at all. Without
|
|
201
|
+
// this, a read-only kinds:["inbox"] poll token doubles as authority to send
|
|
202
|
+
// envelopes of kind "inbox" as the principal. See CAPABILITY_ONLY_KINDS.
|
|
203
|
+
if (CAPABILITY_ONLY_KINDS.has(envelope.kind)) return reject("kind-reserved-capability");
|
|
204
|
+
if (!delegationCoversKind(d, envelope.kind)) return reject("kind-not-covered");
|
|
205
|
+
// (a) the envelope signature under the delegate key, over the canonical
|
|
206
|
+
// body INCLUDING the delegation field (exactly what the delegate signed).
|
|
207
|
+
const { transportSignature: _sig, ...withoutSig } = envelope;
|
|
208
|
+
let canonical;
|
|
209
|
+
try { canonical = jcs(withoutSig); } catch (_e) { return { ok: false, reason: "bad-canonical" }; }
|
|
210
|
+
let okSig;
|
|
211
|
+
try {
|
|
212
|
+
okSig = Keychain.verifyDetached(sig.publicKey, Buffer.from(canonical, "utf-8"), Buffer.from(sig.signature, "hex"));
|
|
213
|
+
} catch (_e) {
|
|
214
|
+
return { ok: false, reason: "verify-threw" };
|
|
215
|
+
}
|
|
216
|
+
if (!okSig) return reject("bad-envelope-signature");
|
|
217
|
+
return { ok: true, delegated: true, actingFor: d.principal };
|
|
218
|
+
}
|
|
219
|
+
|
|
139
220
|
/**
|
|
140
221
|
* Decrypt an envelope's payload. Only the recipient can do this.
|
|
141
222
|
* keychain must be the recipient's loaded keychain.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `uuaid://link/…` — desktop linking offer, principal side.
|
|
3
|
+
*
|
|
4
|
+
* A browser tab that is not yet paired mints its OWN device identity and
|
|
5
|
+
* publishes it as an offer:
|
|
6
|
+
*
|
|
7
|
+
* uuaid://link/<url-encoded-delegate-uuaid>?pk=<64hex>&nonce=<hex>&exp=<iso>
|
|
8
|
+
*
|
|
9
|
+
* A phone renders that string as a QR and scans it. Nothing about the offer
|
|
10
|
+
* requires a camera: the QR is a rendering of this string, not a format of
|
|
11
|
+
* its own, so any principal that can read the string can complete the
|
|
12
|
+
* handshake. That is what makes `pillar delegate --to-browser` possible for
|
|
13
|
+
* an agent, which has a persona seed but no phone.
|
|
14
|
+
*
|
|
15
|
+
* Structural parity with `apps/mobile/src/services/link-offer.ts` is
|
|
16
|
+
* deliberate and load-bearing — same regexes, same rejection points, same
|
|
17
|
+
* "null means ignore safely" contract — because the browser on the other end
|
|
18
|
+
* validates what the phone would have sent. Change one, change both.
|
|
19
|
+
*
|
|
20
|
+
* Parsing is not authorization: `parseLinkOffer` checks shape only. Liveness
|
|
21
|
+
* (`isLinkOfferLive`) and single-use are the caller's job.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const LINK_OFFER_DEEPLINK_HOST = "link";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The kinds a desktop-link grant covers: exactly what the phone grants
|
|
28
|
+
* (apps/mobile pillar.ts LINK_GRANT_KINDS, plan D12). No "inbox": the desktop
|
|
29
|
+
* polls its OWN inbox and never reads the principal's, and no "*", which a
|
|
30
|
+
* carrier reads as covering "inbox" (carrier-server delegationCoversKind).
|
|
31
|
+
*/
|
|
32
|
+
export const LINK_GRANT_KINDS = Object.freeze(["message", "request", "reply", "ack"]);
|
|
33
|
+
|
|
34
|
+
const UUAID_RE = /^uuaid:[a-z0-9-]+:[a-z0-9-]+:[a-z0-9][a-z0-9-]*$/i;
|
|
35
|
+
const PK_RE = /^[0-9a-f]{64}$/i;
|
|
36
|
+
const NONCE_RE = /^[0-9a-f]{8,64}$/i;
|
|
37
|
+
|
|
38
|
+
export function isValidLinkDelegate(delegate) {
|
|
39
|
+
return typeof delegate === "string" && UUAID_RE.test(delegate);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isValidLinkPublicKey(pk) {
|
|
43
|
+
return typeof pk === "string" && PK_RE.test(pk);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isValidLinkNonce(nonce) {
|
|
47
|
+
return typeof nonce === "string" && NONCE_RE.test(nonce);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a `uuaid://link/…` offer. Structural validation only — shape and
|
|
52
|
+
* field formats, including that `exp` parses as a date. Does NOT check
|
|
53
|
+
* whether the offer has expired (see isLinkOfferLive). Returns null for
|
|
54
|
+
* anything malformed; callers must treat null as "ignore safely".
|
|
55
|
+
*/
|
|
56
|
+
export function parseLinkOffer(raw) {
|
|
57
|
+
if (typeof raw !== "string") return null;
|
|
58
|
+
const trimmed = raw.trim();
|
|
59
|
+
if (!trimmed) return null;
|
|
60
|
+
let u;
|
|
61
|
+
try {
|
|
62
|
+
// URL/URLSearchParams can silently mangle malformed percent escapes
|
|
63
|
+
// rather than throwing, so validate the encoding across the whole link
|
|
64
|
+
// before trusting any field of it.
|
|
65
|
+
decodeURI(trimmed);
|
|
66
|
+
u = new URL(trimmed);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (u.protocol.toLowerCase() !== "uuaid:") return null;
|
|
71
|
+
if (u.hostname.toLowerCase() !== LINK_OFFER_DEEPLINK_HOST) return null;
|
|
72
|
+
let delegate;
|
|
73
|
+
try {
|
|
74
|
+
delegate = decodeURIComponent(u.pathname.replace(/^\/+/, ""));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (!isValidLinkDelegate(delegate)) return null;
|
|
79
|
+
const pk = (u.searchParams.get("pk") ?? "").trim().toLowerCase();
|
|
80
|
+
const nonce = (u.searchParams.get("nonce") ?? "").trim().toLowerCase();
|
|
81
|
+
const exp = (u.searchParams.get("exp") ?? "").trim();
|
|
82
|
+
if (!isValidLinkPublicKey(pk)) return null;
|
|
83
|
+
if (!isValidLinkNonce(nonce)) return null;
|
|
84
|
+
if (!exp || Number.isNaN(Date.parse(exp))) return null;
|
|
85
|
+
return { delegate, pk, nonce, exp };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build the offer string (the form a browser publishes; here for tests). */
|
|
89
|
+
export function buildLinkOffer(offer) {
|
|
90
|
+
if (!isValidLinkDelegate(offer?.delegate)) throw new Error("link offer: invalid delegate uuaid");
|
|
91
|
+
if (!isValidLinkPublicKey(offer?.pk)) throw new Error("link offer: invalid pk (need 64 hex)");
|
|
92
|
+
if (!isValidLinkNonce(offer?.nonce)) throw new Error("link offer: invalid nonce");
|
|
93
|
+
if (Number.isNaN(Date.parse(offer?.exp))) throw new Error("link offer: invalid exp (need ISO date)");
|
|
94
|
+
const params = new URLSearchParams({
|
|
95
|
+
pk: offer.pk.toLowerCase(),
|
|
96
|
+
nonce: offer.nonce.toLowerCase(),
|
|
97
|
+
exp: offer.exp,
|
|
98
|
+
});
|
|
99
|
+
return `uuaid://${LINK_OFFER_DEEPLINK_HOST}/${encodeURIComponent(offer.delegate)}?${params.toString()}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True while `now` is strictly before the offer's exp instant. */
|
|
103
|
+
export function isLinkOfferLive(offer, now = Date.now()) {
|
|
104
|
+
const expMs = Date.parse(offer?.exp);
|
|
105
|
+
if (Number.isNaN(expMs)) return false;
|
|
106
|
+
return now < expMs;
|
|
107
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
"delegation-invalid": "mint a fresh delegation from the principal, then resend",
|
|
36
|
+
// Carrier, envelope shape and crypto (envelope.mjs open()): one fix for all
|
|
37
|
+
"not-object": RESEAL,
|
|
38
|
+
"bad-version": RESEAL,
|
|
39
|
+
"missing-or-bad-alg": RESEAL,
|
|
40
|
+
"alg-not-active": RESEAL,
|
|
41
|
+
"bad-publicKey": RESEAL,
|
|
42
|
+
"bad-signature-encoding": RESEAL,
|
|
43
|
+
"missing-enc": RESEAL,
|
|
44
|
+
"enc-alg-not-supported": RESEAL,
|
|
45
|
+
"enc-alg-not-active": RESEAL,
|
|
46
|
+
"bad-epk": RESEAL,
|
|
47
|
+
"bad-iv": RESEAL,
|
|
48
|
+
"bad-ct": RESEAL,
|
|
49
|
+
"bad-canonical": RESEAL,
|
|
50
|
+
"verify-threw": RESEAL,
|
|
51
|
+
// Carrier, GET /v1/inbox/<uuaid>
|
|
52
|
+
"missing-auth-headers": "send x-pillar-pubkey, x-pillar-ts and x-pillar-sig",
|
|
53
|
+
"bad-pubkey": "send x-pillar-pubkey as the raw Ed25519 key in 64 hex characters",
|
|
54
|
+
"bad-sig-encoding": "send x-pillar-sig as 128 hex characters",
|
|
55
|
+
"clock-skew": "fix your clock (NTP) to within 5 min of the carrier; retrying does not help",
|
|
56
|
+
"uuaid-pubkey-mismatch": "read the inbox of the UUAID your key derives, or send that UUAID's delegation in x-pillar-delegation",
|
|
57
|
+
"delegation-unparseable": "send x-pillar-delegation as the JSON delegation document",
|
|
58
|
+
"delegation-too-large": "send a delegation document under 8,192 bytes",
|
|
59
|
+
"delegation-principal-mismatch": "use a delegation whose principal is the inbox UUAID",
|
|
60
|
+
"delegation-delegate-key-mismatch": "sign with the key the delegation names as delegate",
|
|
61
|
+
"delegation-inbox-denied": "ask the principal for a delegation that covers inbox (or *)",
|
|
62
|
+
"busy": "retry after at least 2 s with jitter, or poll with wait=0 for a while",
|
|
63
|
+
// Carrier, any route
|
|
64
|
+
"not-found": "use POST /v1/envelopes or GET /v1/inbox/<uuaid>",
|
|
65
|
+
"not-a-seed": "do not list this carrier as a seed; it runs without a seed identity",
|
|
66
|
+
"internal-error": "retry later; if it repeats, tell the carrier operator",
|
|
67
|
+
// Client side: no server gave a code
|
|
68
|
+
"carrier-unreachable": "check the carrier URLs and your network, then retry",
|
|
69
|
+
"carrier-bad-response": "retry, or use another carrier; the carrier or its proxy answered without a reason",
|
|
70
|
+
"over-tier-limit": "send the payload as a blob (pillar-blob send, or Pillar.sendBlob)",
|
|
71
|
+
"blob-key-unproven": "use a uuaid:foundation:agent UUAID derived from this key (pillar init), or get a registry badge that names this key",
|
|
72
|
+
"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",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The fix for a carrier reason, or null for a reason this map does not know.
|
|
77
|
+
* An exact key wins; otherwise the reason's family (the part before the
|
|
78
|
+
* first ":" or space) is looked up, so `delegation-invalid:expired` and
|
|
79
|
+
* `bad-version 2` resolve to their family's fix. The result is always a
|
|
80
|
+
* value of REFUSAL_FIXES or null; nothing from `reason` is copied into it.
|
|
81
|
+
*/
|
|
82
|
+
export function carrierFix(reason) {
|
|
83
|
+
if (typeof reason !== "string" || !reason) return null;
|
|
84
|
+
if (Object.hasOwn(REFUSAL_FIXES, reason)) return REFUSAL_FIXES[reason];
|
|
85
|
+
const family = reason.split(/[: ]/, 1)[0];
|
|
86
|
+
return family !== reason && Object.hasOwn(REFUSAL_FIXES, family) ? REFUSAL_FIXES[family] : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A carrier response body with its `fix` added. Pure addition: only a
|
|
91
|
+
* refusal (status >= 400) that names a `reason` or `error` and has no `fix`
|
|
92
|
+
* yet gains one; every other field, and every other body, is returned as is.
|
|
93
|
+
*/
|
|
94
|
+
export function withCarrierFix(status, body) {
|
|
95
|
+
if (!(status >= 400) || !body || typeof body !== "object" || Array.isArray(body) || body.fix !== undefined) return body;
|
|
96
|
+
const fix = carrierFix(typeof body.reason === "string" ? body.reason : body.error);
|
|
97
|
+
return fix ? { ...body, fix } : body;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Terminal safety only: a line break or control character a remote sent
|
|
102
|
+
* becomes a space, so the output stays one line and cannot drive the
|
|
103
|
+
* terminal. Every other character is printed as received.
|
|
104
|
+
*/
|
|
105
|
+
function oneLine(value) {
|
|
106
|
+
return String(value).replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `<code>: <fix>` for an error that carries both (a carrier or broker
|
|
111
|
+
* refusal, or a client error that knows its next step), else null so the
|
|
112
|
+
* caller keeps its old output. A server's fix is printed as received.
|
|
113
|
+
*/
|
|
114
|
+
export function refusalLine(error) {
|
|
115
|
+
const code = typeof error?.code === "string" ? oneLine(error.code) : "";
|
|
116
|
+
const fix = typeof error?.fix === "string" ? oneLine(error.fix) : "";
|
|
117
|
+
return code && fix ? `${code}: ${fix}` : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `{ code, fix }` of an error, for a result row, omitting what is absent. */
|
|
121
|
+
export function refusalFields(error) {
|
|
122
|
+
return {
|
|
123
|
+
...(typeof error?.code === "string" && error.code ? { code: error.code } : {}),
|
|
124
|
+
...(typeof error?.fix === "string" && error.fix ? { fix: error.fix } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|